Compare commits

..

44 Commits

Author SHA1 Message Date
f41gh7
37013d36c0 follow-up after 24aef8ea90 fixes single-node branch build 2025-08-29 14:45:51 +02:00
f41gh7
c9b3088c9c CHANGELOG.md: cut v1.125.0 release 2025-08-29 14:36:08 +02:00
Charles-Antoine Mathieu
24aef8ea90 app/vmselect/graphite: enforce search.maxQueryLen for Graphite queries
This commit ensures that the -search.maxQueryLen flag applies to Graphite
queries, matching the behavior already present for Prometheus queries.
Previously, Graphite queries could bypass this limit, creating an
inconsistency and a potential vector for resource exhaustion.

Key changes:

Added getMaxQueryLen() to access the global query length limit.
Enforced query length validation in execExpr() for Graphite queries.
Added comprehensive tests for the new validation logic and edge cases.
Error messages are consistent with Prometheus query validation.
The default limit is 16KB (configurable via -search.maxQueryLen).
Setting the limit to 0 disables validation.
This change closes the gap where Graphite queries could exceed
configured length limits, providing consistent protection against
excessively long queries across both query APIs.

Follow-up for https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9534
Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9600
2025-08-29 14:29:37 +02:00
f41gh7
e540e5e381 make vmui-update 2025-08-29 14:16:58 +02:00
Aliaksandr Valialkin
51aebcd061 docs/victoriametrics/sd_configs.md: add titles per every target role in service discovery configs
This allows referring per-role docs via direct links to the correponsing sub-chapters with the given titles
2025-08-29 13:28:47 +02:00
Artem Fetishev
df7b752c7a lib/storage: fix double counting in vm_deleted_metrics_total
The vm_deleted_metrics_total metric value represents the number of
metricIDs stored in deletedMetricIDs cache. This cache lives at the
storage level and stores the deleted metrics from both prev and curr
idbs. However, the metric is populated at the idb level. Since there are
always 2 idbs (prev and curr), the value is populated twice. Hence the
doubled value of the metric.

The fix is to populate the metric value at the storage level.

Related issue https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9602
2025-08-29 11:18:07 +02:00
Andrii Chubatiuk
6f74b139cc app/vmui: craft UI configuration on backend instead of using /flags endpoint and static config.json file
- load and parse static`/vmui/config.json`, modify it according to
runtime values and use it as a replacement for static config.json
- remove using `/flags` endpoint for checking features, that should be
enabled on VMUI

 Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9635
2025-08-29 10:44:38 +02:00
Andrii Chubatiuk
e49609cbc2 app/vmui: removed home page hack
`router.home` represents `/` path, which is the same for all UI apps,
but content and title for root path differs depending on application
type. added `getDefaultOptions` function, which returns proper home
route configuration depending on application type, which allows to
remove renamings in respective layouts

 Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9641
2025-08-29 10:25:55 +02:00
Hui Wang
2e655a91bc lib/httpserver: properly issue automatic TLS certificates
Bug was introduced at commit 93ad502d6dcb4724e8ec40a4a0351b0316853af0

Related PR https://github.com/VictoriaMetrics/VictoriaMetrics-enterprise/pull/930
2025-08-29 10:08:51 +02:00
Max Kotliar
1e927b2e53 lib/prommetadata: Extract -enableMetadata flag to separate package, avoid pulling in promscrape discovery flags into vminsert
The commit
25cd5637bc
introduced the `-enableMetadata` flag and the
`promscrape.IsMetadataEnabled()` function, which is now used in multiple
places, including the `app/vminsert/prometheusimport` [request
handler](b24b76ff08/app/vminsert/prometheusimport/request_handler.go (L36)).

Because of the use of `promscrape` package vminsert registered all
`-promscrape.*` service discovery flags, which were not relevant for
`vminsert`.

This change moves the metadata flag logic into a dedicated package,
preventing vminsert from unintentionally loading unrelated promscrape
flags.

Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9631
2025-08-29 10:07:16 +02:00
Hui Wang
21963a1cad fix vmcluster docker-compose example (#9643)
1. fix vmcluster docker-compose example: vminsert scrape job and vmagent
remote write authorization.
2. upgrade grafana to v12.1.1
2025-08-29 14:36:46 +08:00
hagen1778
87b291debe deployment/docker: replace single-node image on transparent background version
Signed-off-by: hagen1778 <roman@victoriametrics.com>
2025-08-28 20:17:13 +02:00
hagen1778
cce1cdcb6d deployment/docker: strip victorialogs images from excalidraw sources
VictoriaLogs excalidraw images should be stored in VictoriaLogs repo

Signed-off-by: hagen1778 <roman@victoriametrics.com>
2025-08-28 20:12:19 +02:00
hagen1778
03e003c828 deployment/docker: use light and dark images for github markdown for cluster images
This is an attempt to adjust image styles to GitHub themes, because
existing images with transparent backround become unreadable on dark theme.

Signed-off-by: hagen1778 <roman@victoriametrics.com>
2025-08-28 20:08:19 +02:00
hagen1778
ad9d11ba3f deployment/docker: use light and dark images for github markdown
This is an attempt to adjust image styles to GitHub themes, because
existing images with transparent backround become unreadable on dark theme.

Signed-off-by: hagen1778 <roman@victoriametrics.com>
2025-08-28 20:05:42 +02:00
hagen1778
5c2ed99dab deployment/docker: rm victorialogs images
The vlogs images were moved to VictoriaLogs github repo
and aren't needed here anymore.

Signed-off-by: hagen1778 <roman@victoriametrics.com>
2025-08-28 20:04:24 +02:00
f41gh7
eaec80b7f3 follow-up after 76eb654e7e
mention change at changelog
2025-08-28 17:08:31 +02:00
Oron Sharabi
d6ef8a807b lib/storage: improve searchLabels and searchLabelValues performance
When having a `match` of `__name__` key alone for labels api, it's going
to hit max series limit in case of high cardinality metric name.
Instead, we can skip looking by `metricIDs` and fallback to inverted
index scan with a `composite key` since we only have some `__name__` and
a label name.

 Common requests for optimisations are:
1) /api/v1/labels?match=up or /api/v1/labels?extra_filters=up
2) /api/v1/label/job/values?match=up or /api/v1/labels?extra_filters =up

 It's widely used by grafana variables.

 Related PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9489
2025-08-28 17:08:25 +02:00
f41gh7
c0318a84f0 deployment/docker: switch from musl to glibc
This should remove vertical scalability limit for data ingestion at VictoriaMetrics running on machines with big number of CPU cores.

Related issue: https://github.com/VictoriaMetrics/VictoriaLogs/issues/517
2025-08-28 16:25:18 +02:00
Artem Fetishev
5a056321af lib/uint64set: Optimize subtract operation
a.Subtract(b) perfomance degrades as b becomes bigger than a. For
example if len(b2) == 10xlen(b1) then time(a.Subtract(b2)) == 10x
time(a.Subtract(b1)).

A quick fix is to iterate over a elements in len(b) > len(a). Iterating
over a's elements and at the same time deleting should be safe since no
elements are actually deleted (i.e. memory freed, etc). Deletion here
means setting a corresponding bit from 1 to 0.

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9602
2025-08-28 16:22:43 +02:00
Max Kotliar
686289c02b lib/flagutil: fix flag description. 2025-08-27 20:08:24 +03:00
Max Kotliar
9ae10247bb Revert "docs: sync documented flags with binaries"
This reverts commit 7c0c8cc702.
2025-08-27 19:10:31 +03:00
Aliaksandr Valialkin
06ce3f1496 go.mod: update github.com/valyala/gozstd from v1.22.0 to v1.23.2 2025-08-27 14:28:44 +02:00
Artem Fetishev
d0690ba15f benchmarks: support for all query types in TSBS (#9630)
### Describe Your Changes

Add the support of all standard TSDB query types that can be executed
against VictoriaMetrics. `double-groupby-all` is commented out as it
attempts to retrieve all 1B samples and fails. While this can be fixed
by setting the `-search.maxSamplesPerQuery` this query is left disabled
anyway because it will consume way too much memory and cpu time.

### Checklist

The following checks are **mandatory**:

- [x] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [x] My change adheres to [VictoriaMetrics development
goals](https://docs.victoriametrics.com/victoriametrics/goals/).

Signed-off-by: Artem Fetishev <rtm@victoriametrics.com>
2025-08-27 13:49:35 +02:00
Andrii Chubatiuk
483e00ffb9 vmui: replace VMAlert proxy with Alerting tab in VMUI (#8989)
### Describe Your Changes

Rules page header + content
<img width="1235" height="520" alt="image"
src="https://github.com/user-attachments/assets/bb0c5818-c44a-46e6-bc47-e6718be34016"
/>
Expanded rule without alert
<img width="1418" alt="image"
src="https://github.com/user-attachments/assets/ae0b265f-24fe-4549-8913-b1be8e7c2862"
/>
Expanded rule with alert
<img width="1418" alt="image"
src="https://github.com/user-attachments/assets/8a138403-0712-4de2-bfa5-467da3a979dd"
/>
Notifiers page
<img width="1419" alt="image"
src="https://github.com/user-attachments/assets/557c2831-e960-44ec-9b93-f1ebfeb1fbb0"
/>

fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8330
fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6091
fixes https://github.com/VictoriaMetrics/VictoriaLogs/issues/90

VMUI:
- Added added `Alerting -> Rules` and `Alerting -> Notifiers` pages for
VictoriaMetrics
- Support includeAll option in Select component

VMAlert:
- added `/api/v1/group`useful to get information about certain group
- added `lastError` for `/api/v1/notifiers` for each target to see
information about failed notifiers

### Checklist

The following checks are **mandatory**:

- [ ] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/).

---------

Signed-off-by: hagen1778 <roman@victoriametrics.com>
Co-authored-by: hagen1778 <roman@victoriametrics.com>
2025-08-27 13:48:56 +02:00
Artem Fetishev
06f969a4a7 lib/storage: Follow-up for 9517f5cf1 - use 100k series in all benchmarks, fix benchmark names
Signed-off-by: Artem Fetishev <rtm@victoriametrics.com>
2025-08-27 11:53:43 +02:00
Artem Fetishev
9517f5cf1a lib/storage: new storage search benchmarks (#9620)
### Describe Your Changes

New benchmarks for storage search (data and index):
- Use the same dataset that accounts for prev and curr indexDBs and
deleted series
- The code is more structured
- Account for various numbers of series in response including higher
numbers (>10k) as this appears to be a quite common use case.

These bechmarks were used for investigating #9602 performance issue and
helped discover that prefetching metric names needed to be restored
#9619.

Signed-off-by: Artem Fetishev <rtm@victoriametrics.com>
2025-08-27 11:19:29 +02:00
Alexander Frolov
e62e0685dc vmctl: inconsistent vm-native logs (#9607)
### Describe Your Changes

Some messages were written to `stdout` using `fmt.Printf` and
`fmt.Println`, while the other messages like import statistics were
written to `stderr` through the `log` package.

This led to ordering problems where the `Import finished!` +
`VictoriaMetrics importer stats` messages, which expected to be the last
messages, appeared before `Continue import process with filter`
messages, creating confusing output for users.

```
2025/08/20 13:07:26 Import finished!
2025/08/20 13:07:26 VictoriaMetrics importer stats:
  time spent while importing: 20h49m10.8497184s;
  total bytes: 277.1 GB;
  bytes/s: 3.7 MB;
  requests: 7978614;
  requests retries: 0;
2025/08/20 13:07:26 Total time: 20h49m10.851006088s
Continue import process with filter
        filter: match[]={__name__!=""}
        start: 2025-08-08T00:00:00Z
        end: 2025-08-15T00:00:00Z:
Continue import process with filter
        filter: match[]={__name__!=""}
        start: 2025-08-15T00:00:00Z
        end: 2025-08-19T16:18:15Z:
```


### Checklist

The following checks are **mandatory**:

- [x] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [x] My change adheres to [VictoriaMetrics development
goals](https://docs.victoriametrics.com/victoriametrics/goals/).
2025-08-26 18:53:59 +03:00
Max Kotliar
df92e617db Revert "app/{vminsert,vmagent}: added flags for periodical relabel and stream aggregation configs check (#9598)"
This reverts commit 07291c1d62 and partly
7c0c8cc702.

The reasons explained in
https://github.com/VictoriaMetrics/VictoriaMetrics/pull/9598#issuecomment-3223766551
2025-08-26 14:42:35 +03:00
Max Kotliar
7c0c8cc702 docs: sync documented flags with binaries 2025-08-26 10:53:43 +03:00
Andrii Chubatiuk
07291c1d62 app/{vminsert,vmagent}: added flags for periodical relabel and stream aggregation configs check (#9598)
related issue
https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9590

### Describe Your Changes

Please provide a brief description of the changes you made. Be as
specific as possible to help others understand the purpose and impact of
your modifications.

### Checklist

The following checks are **mandatory**:

- [ ] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [ ] My change adheres to [VictoriaMetrics development
goals](https://docs.victoriametrics.com/victoriametrics/goals/).

Co-authored-by: Max Kotliar <mkotlyar@victoriametrics.com>
2025-08-26 09:46:44 +03:00
Alexander Frolov
7c0015b836 app/vmagent/remotewrite: restore protocol downgrade logic (#9621)
### Describe Your Changes

It seems db39f045e1 accidentally reverted
#9419 changes.
```patch
--- a/app/vmagent/remotewrite/client.go
+++ b/app/vmagent/remotewrite/client.go
@@ -448,7 +448,8 @@ again:
 	}
 
 	metrics.GetOrCreateCounter(fmt.Sprintf(`vmagent_remotewrite_requests_total{url=%q, status_code="%d"}`, c.sanitizedURL, statusCode)).Inc()
-	if statusCode == 409 {
+	switch statusCode {
+	case 409:
 		logBlockRejected(block, c.sanitizedURL, resp)
 
 		// Just drop block on 409 status code like Prometheus does.
@@ -461,7 +462,13 @@ again:
 		// - Remote Write v2 specification explicitly specifies a `415 Unsupported Media Type` for unsupported encodings.
 		// - Real-world implementations of v1 use both 400 and 415 status codes.
 		// See more in research: https://github.com/VictoriaMetrics/VictoriaMetrics/pull/8462#issuecomment-2786918054
-	} else if statusCode == 415 || statusCode == 400 {
+	case 415, 400:
+		if c.canDowngradeVMProto.Swap(false) {
+			logger.Infof("received unsupported media type or bad request from remote storage at %q. Downgrading protocol from VictoriaMetrics to Prometheus remote write for all future requests. "+
+				"See https://docs.victoriametrics.com/victoriametrics/vmagent/#victoriametrics-remote-write-protocol", c.sanitizedURL)
+			c.useVMProto.Store(false)
+		}
+
 		if encoding.IsZstd(block) {
 			logger.Infof("received unsupported media type or bad request from remote storage at %q. Re-packing the block to Prometheus remote write and retrying."+
 				"See https://docs.victoriametrics.com/victoriametrics/vmagent/#victoriametrics-remote-write-protocol", c.sanitizedURL)
```

cc @makasim

### Checklist

The following checks are **mandatory**:

- [x] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [x] My change adheres to [VictoriaMetrics development
goals](https://docs.victoriametrics.com/victoriametrics/goals/).
2025-08-26 09:17:53 +03:00
Hui Wang
06e52a99fd lib/prompb: replace fields hardcoded hex values with their correspond… (#9617)
…ing bitwise operations

fix https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9608
2025-08-26 09:03:36 +03:00
f41gh7
f5840951a4 app/vmagent: pubsub properly handle ingestion error
Previously, if pushBlockPubSub function returned error, vmagent stopped
remote write worker thread assigned for it. Expected behavior for this
scenario is to retry error inside pushBlockPubSub function. It must
return only on vmagent shutdown.

 This commit properly handles this error and prevents from ingestion
stop.
2025-08-24 21:37:30 +02:00
Aliaksandr Valialkin
9ca5a8d0f4 lib/netutil: return tls.Conn from TCPListener.Accept for TLS connections
This is needed because the servers, which may use the TCPListener, such as net/http.Server,
expect to get tls.Conn for TLS connections in order to properly fill various fields such as net/http.Request.TLS.
If the listener returns some other net.Conn, then these fields aren't filled properly,
and this may prevent from the proper mTLS-based authorization and request routing
such as https://docs.victoriametrics.com/victoriametrics/vmauth/#mtls-based-request-routing

Updates https://github.com/VictoriaMetrics/VictoriaLogs/issues/29
2025-08-22 20:25:40 +02:00
Aliaksandr Valialkin
894b22590d docs/victoriametrics/enterprise.md: mention VictoriaLogs enterprise
Updates https://github.com/VictoriaMetrics/VictoriaLogs/issues/120
2025-08-22 18:31:51 +02:00
hagen1778
f85fd161e4 docs: reword -vmalert.proxyURL usage in vmalert
Make it clear that `-vmalert.proxyURL` needs to be applied to
VM single or vmselect.

Signed-off-by: hagen1778 <roman@victoriametrics.com>
2025-08-22 09:49:13 +02:00
Max Kotliar
7d552dbd9a metricsql: improve timestamp function compatibility with Prometheus when used with sub-expressions (#9603)
### Describe Your Changes

Fixes
[#9527](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9527)
Related PR: https://github.com/VictoriaMetrics/metricsql/pull/55

### Checklist

The following checks are **mandatory**:

- [ ] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [ ] My change adheres to [VictoriaMetrics development
goals](https://docs.victoriametrics.com/victoriametrics/goals/).
2025-08-21 17:38:12 +03:00
Max Kotliar
795c3deaee ib/appmetrics: revert accidental change 2025-08-21 17:30:12 +03:00
Max Kotliar
cb44353a36 docs/changelog: add update note 2025-08-21 17:29:32 +03:00
Andrii Chubatiuk
7e05200c60 deployment/rules: set proper job filters for rules (#9587)
### Describe Your Changes

related issue https://github.com/VictoriaMetrics/helm-charts/issues/2350

### Checklist

The following checks are **mandatory**:

- [ ] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [ ] My change adheres to [VictoriaMetrics development
goals](https://docs.victoriametrics.com/victoriametrics/goals/).
2025-08-21 15:26:36 +02:00
hagen1778
a2f033ce6c docs: refresh vmui description
* add missing features
* re-organize text without breaking links to improve clarity

Signed-off-by: hagen1778 <roman@victoriametrics.com>
2025-08-21 15:25:49 +02:00
Artur Minchukou
78b217d70c app/vmui: add export functionality for Query and RawQuery tabs with CSV/JSON support (#9463)
### Describe Your Changes

Related issue: #9332 
- add export functionality for Query and RawQuery tabs with CSV/JSON
support;
 - replace unused icons and update `DebugIcon` usage in `DownloadReport`

### Checklist

The following checks are **mandatory**:

- [x] My change adheres to [VictoriaMetrics contributing
guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist).
- [x] My change adheres to [VictoriaMetrics development
goals](https://docs.victoriametrics.com/victoriametrics/goals/).

---------

Signed-off-by: hagen1778 <roman@victoriametrics.com>
Co-authored-by: hagen1778 <roman@victoriametrics.com>
2025-08-21 14:37:27 +02:00
Aliaksandr Valialkin
c9b23de9ce lib/httpserver: add missing whitespace after the dot in the description for the -tlsAutocertEmail command-line flag
This is a follow-up for 1d80e8f860
2025-08-21 11:02:43 +02:00
179 changed files with 5591 additions and 5280 deletions

View File

@@ -7,8 +7,8 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmagent/common"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmagent/remotewrite"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/auth"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prommetadata"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/opentelemetry/firehose"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/opentelemetry/stream"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
@@ -68,7 +68,7 @@ func insertRows(at *auth.Token, tss []prompb.TimeSeries, mms []prompb.MetricMeta
ctx.WriteRequest.Timeseries = tssDst
var metadataTotal int
if promscrape.IsMetadataEnabled() {
if prommetadata.IsEnabled() {
var accountID, projectID uint32
if at != nil {
accountID = at.AccountID

View File

@@ -7,8 +7,8 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmagent/remotewrite"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/auth"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prommetadata"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/prometheus"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/prometheus/stream"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
@@ -36,7 +36,7 @@ func InsertHandler(at *auth.Token, req *http.Request) error {
return err
}
encoding := req.Header.Get("Content-Encoding")
return stream.Parse(req.Body, defaultTimestamp, encoding, true, promscrape.IsMetadataEnabled(), func(rows []prometheus.Row, mms []prometheus.Metadata) error {
return stream.Parse(req.Body, defaultTimestamp, encoding, true, prommetadata.IsEnabled(), func(rows []prometheus.Row, mms []prometheus.Metadata) error {
return insertRows(at, rows, mms, extraLabels)
}, func(s string) {
httpserver.LogError(req, s)

View File

@@ -6,8 +6,8 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmagent/common"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmagent/remotewrite"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/auth"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prommetadata"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/promremotewrite/stream"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/tenantmetrics"
@@ -71,7 +71,7 @@ func insertRows(at *auth.Token, timeseries []prompb.TimeSeries, mms []prompb.Met
ctx.WriteRequest.Timeseries = tssDst
var metadataTotal int
if promscrape.IsMetadataEnabled() {
if prommetadata.IsEnabled() {
var accountID, projectID uint32
if at != nil {
accountID = at.AccountID

View File

@@ -463,12 +463,6 @@ again:
// - Real-world implementations of v1 use both 400 and 415 status codes.
// See more in research: https://github.com/VictoriaMetrics/VictoriaMetrics/pull/8462#issuecomment-2786918054
case 415, 400:
if c.canDowngradeVMProto.Swap(false) {
logger.Infof("received unsupported media type or bad request from remote storage at %q. Downgrading protocol from VictoriaMetrics to Prometheus remote write for all future requests. "+
"See https://docs.victoriametrics.com/victoriametrics/vmagent/#victoriametrics-remote-write-protocol", c.sanitizedURL)
c.useVMProto.Store(false)
}
if encoding.IsZstd(block) {
logger.Infof("received unsupported media type or bad request from remote storage at %q. Re-packing the block to Prometheus remote write and retrying."+
"See https://docs.victoriametrics.com/victoriametrics/vmagent/#victoriametrics-remote-write-protocol", c.sanitizedURL)

View File

@@ -29,6 +29,18 @@ type manager struct {
groups map[uint64]*rule.Group
}
// groupAPI generates apiGroup object from group by its ID(hash)
func (m *manager) groupAPI(gID uint64) (*apiGroup, error) {
m.groupsMu.RLock()
defer m.groupsMu.RUnlock()
g, ok := m.groups[gID]
if !ok {
return nil, fmt.Errorf("can't find group with id %d", gID)
}
return groupToAPI(g), nil
}
// ruleAPI generates apiRule object from alert by its ID(hash)
func (m *manager) ruleAPI(gID, rID uint64) (apiRule, error) {
m.groupsMu.RLock()

View File

@@ -22,10 +22,11 @@ import (
// AlertManager represents integration provider with Prometheus alert manager
// https://github.com/prometheus/alertmanager
type AlertManager struct {
addr *url.URL
argFunc AlertURLGenerator
client *http.Client
timeout time.Duration
addr *url.URL
argFunc AlertURLGenerator
client *http.Client
timeout time.Duration
lastError string
authCfg *promauth.Config
// stores already parsed RelabelConfigs object
@@ -71,6 +72,10 @@ func (am AlertManager) Addr() string {
return am.addr.Redacted()
}
func (am *AlertManager) LastError() string {
return am.lastError
}
// Send an alert or resolve message
func (am *AlertManager) Send(ctx context.Context, alerts []Alert, headers map[string]string) error {
am.metrics.alertsSent.Add(len(alerts))
@@ -79,6 +84,9 @@ func (am *AlertManager) Send(ctx context.Context, alerts []Alert, headers map[st
am.metrics.alertsSendDuration.UpdateDuration(startTime)
if err != nil {
am.metrics.alertsSendErrors.Add(len(alerts))
am.lastError = err.Error()
} else {
am.lastError = ""
}
return err
}

View File

@@ -18,6 +18,11 @@ type FakeNotifier struct {
// Close does nothing
func (*FakeNotifier) Close() {}
// LastError returns last error message
func (*FakeNotifier) LastError() string {
return ""
}
// Addr returns ""
func (*FakeNotifier) Addr() string { return "" }

View File

@@ -10,6 +10,8 @@ type Notifier interface {
Send(ctx context.Context, alerts []Alert, notifierHeaders map[string]string) error
// Addr returns address where alerts are sent.
Addr() string
// LastError returns error, that occured during last attempt to send data
LastError() string
// Close is a destructor for the Notifier
Close()
}

View File

@@ -25,6 +25,11 @@ func (bh *blackHoleNotifier) Close() {
bh.metrics.close()
}
// LastError return last notifier's error
func (bh *blackHoleNotifier) LastError() string {
return ""
}
// newBlackHoleNotifier creates a new blackHoleNotifier
func newBlackHoleNotifier() *blackHoleNotifier {
address := "blackhole"

View File

@@ -30,6 +30,8 @@ var (
{"api/v1/alerts", "list all active alerts"},
{"api/v1/notifiers", "list all notifiers"},
{fmt.Sprintf("api/v1/alert?%s=<int>&%s=<int>", paramGroupID, paramAlertID), "get alert status by group and alert ID"},
{fmt.Sprintf("api/v1/rule?%s=<int>&%s=<int>", paramGroupID, paramRuleID), "get rule status by group and rule ID"},
{fmt.Sprintf("api/v1/group?%s=<int>", paramGroupID), "get group status by group ID"},
}
systemLinks = [][2]string{
{"vmalert/groups", "UI"},
@@ -195,6 +197,20 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Content-Type", "application/json")
w.Write(data)
return true
case "/vmalert/api/v1/group", "/api/v1/group":
group, err := rh.getGroup(r)
if err != nil {
httpserver.Errorf(w, r, "%s", err)
return true
}
data, err := json.Marshal(group)
if err != nil {
httpserver.Errorf(w, r, "failed to marshal group: %s", err)
return true
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
return true
case "/-/reload":
if !httpserver.CheckAuthFlag(w, r, reloadAuthKey) {
return true
@@ -209,6 +225,18 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool {
}
}
func (rh *requestHandler) getGroup(r *http.Request) (*apiGroup, error) {
groupID, err := strconv.ParseUint(r.FormValue(paramGroupID), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to read %q param: %w", paramGroupID, err)
}
obj, err := rh.m.groupAPI(groupID)
if err != nil {
return nil, errResponse(err, http.StatusNotFound)
}
return obj, nil
}
func (rh *requestHandler) getRule(r *http.Request) (apiRule, error) {
groupID, err := strconv.ParseUint(r.FormValue(paramGroupID), 10, 64)
if err != nil {
@@ -337,12 +365,12 @@ func (rh *requestHandler) groups(rf *rulesFilter) []*apiGroup {
rule.Alerts = nil
}
if rule.LastError != "" {
g.Unhealthy++
g.unhealthy++
} else {
g.Healthy++
g.healthy++
}
if isNoMatch(rule) {
g.NoMatch++
g.noMatch++
}
filteredRules = append(filteredRules, rule)
}
@@ -459,8 +487,9 @@ func (rh *requestHandler) listNotifiers() ([]byte, error) {
}
for _, target := range protoTargets {
notifier.Targets = append(notifier.Targets, &apiTarget{
Address: target.Addr(),
Labels: target.Labels.ToMap(),
Address: target.Addr(),
Labels: target.Labels.ToMap(),
LastError: target.LastError(),
})
}
lr.Data.Notifiers = append(lr.Data.Notifiers, notifier)

View File

@@ -113,7 +113,7 @@
{%= Controls(prefix, currentIcon, currentText, icons, filters, true) %}
{% if len(groups) > 0 %}
{% for _, g := range groups %}
<div id="group-{%s g.ID %}" class="d-flex w-100 border-0 flex-column group-items{% if g.Unhealthy > 0 %} alert-danger{% endif %}">
<div id="group-{%s g.ID %}" class="d-flex w-100 border-0 flex-column group-items{% if g.unhealthy > 0 %} alert-danger{% endif %}">
<span class="d-flex justify-content-between">
<a href="#group-{%s g.ID %}">{%s g.Name %}{% if g.Type != "prometheus" %} ({%s g.Type %}){% endif %} (every {%f.0 g.Interval %}s) #</a>
<span
@@ -123,9 +123,9 @@
data-bs-target="#sub-{%s g.ID %}"
>
<span class="d-flex gap-2">
{% if g.Unhealthy > 0 %}<span class="badge bg-danger" title="Number of rules with status Error">{%d g.Unhealthy %}</span> {% endif %}
{% if g.NoMatch > 0 %}<span class="badge bg-warning" title="Number of rules with status NoMatch">{%d g.NoMatch %}</span> {% endif %}
<span class="badge bg-success" title="Number of rules with status Ok">{%d g.Healthy %}</span>
{% if g.unhealthy > 0 %}<span class="badge bg-danger" title="Number of rules with status Error">{%d g.unhealthy %}</span> {% endif %}
{% if g.noMatch > 0 %}<span class="badge bg-warning" title="Number of rules with status NoMatch">{%d g.noMatch %}</span> {% endif %}
<span class="badge bg-success" title="Number of rules with status Ok">{%d g.healthy %}</span>
</span>
</span>
</span>

View File

@@ -363,7 +363,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*apiG
//line app/vmalert/web.qtpl:116
qw422016.N().S(`" class="d-flex w-100 border-0 flex-column group-items`)
//line app/vmalert/web.qtpl:116
if g.Unhealthy > 0 {
if g.unhealthy > 0 {
//line app/vmalert/web.qtpl:116
qw422016.N().S(` alert-danger`)
//line app/vmalert/web.qtpl:116
@@ -407,11 +407,11 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*apiG
<span class="d-flex gap-2">
`)
//line app/vmalert/web.qtpl:126
if g.Unhealthy > 0 {
if g.unhealthy > 0 {
//line app/vmalert/web.qtpl:126
qw422016.N().S(`<span class="badge bg-danger" title="Number of rules with status Error">`)
//line app/vmalert/web.qtpl:126
qw422016.N().D(g.Unhealthy)
qw422016.N().D(g.unhealthy)
//line app/vmalert/web.qtpl:126
qw422016.N().S(`</span> `)
//line app/vmalert/web.qtpl:126
@@ -420,11 +420,11 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*apiG
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:127
if g.NoMatch > 0 {
if g.noMatch > 0 {
//line app/vmalert/web.qtpl:127
qw422016.N().S(`<span class="badge bg-warning" title="Number of rules with status NoMatch">`)
//line app/vmalert/web.qtpl:127
qw422016.N().D(g.NoMatch)
qw422016.N().D(g.noMatch)
//line app/vmalert/web.qtpl:127
qw422016.N().S(`</span> `)
//line app/vmalert/web.qtpl:127
@@ -433,7 +433,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []*apiG
qw422016.N().S(`
<span class="badge bg-success" title="Number of rules with status Ok">`)
//line app/vmalert/web.qtpl:128
qw422016.N().D(g.Healthy)
qw422016.N().D(g.healthy)
//line app/vmalert/web.qtpl:128
qw422016.N().S(`</span>
</span>

View File

@@ -25,6 +25,7 @@ func TestHandler(t *testing.T) {
m := &manager{groups: map[uint64]*rule.Group{}}
var ar *rule.AlertingRule
var rr *rule.RecordingRule
var groupIDs []uint64
for _, dsType := range []string{"prometheus", "", "graphite"} {
g := rule.NewGroup(config.Group{
Name: "group",
@@ -45,7 +46,9 @@ func TestHandler(t *testing.T) {
ar = g.Rules[0].(*rule.AlertingRule)
rr = g.Rules[1].(*rule.RecordingRule)
g.ExecOnce(context.Background(), func() []notifier.Notifier { return nil }, nil, time.Time{})
m.groups[g.CreateID()] = g
id := g.CreateID()
m.groups[id] = g
groupIDs = append(groupIDs, id)
}
rh := &requestHandler{m: m}
@@ -188,6 +191,21 @@ func TestHandler(t *testing.T) {
t.Fatalf("expected %+v to have state updates field not empty", gotRuleWithUpdates.StateUpdates)
}
})
t.Run("/api/v1/group?groupID", func(t *testing.T) {
id := groupIDs[0]
g := m.groups[id]
expGroup := groupToAPI(g)
gotGroup := apiGroup{}
getResp(t, ts.URL+"/"+expGroup.APILink(), &gotGroup, 200)
if expGroup.ID != gotGroup.ID {
t.Fatalf("expected to get Group %q; got %q instead", expGroup.ID, gotGroup.ID)
}
gotGroup = apiGroup{}
getResp(t, ts.URL+"/vmalert/"+expGroup.APILink(), &gotGroup, 200)
if expGroup.ID != gotGroup.ID {
t.Fatalf("expected to get Group %q; got %q instead", expGroup.ID, gotGroup.ID)
}
})
t.Run("/api/v1/rules&filters", func(t *testing.T) {
check := func(url string, statusCode, expGroups, expRules int) {

View File

@@ -28,6 +28,8 @@ type apiNotifier struct {
type apiTarget struct {
Address string `json:"address"`
Labels map[string]string `json:"labels"`
// LastError contains the error faced while sending to notifier.
LastError string `json:"lastError"`
}
// apiAlert represents a notifier.AlertingRule state
@@ -109,11 +111,16 @@ type apiGroup struct {
// EvalDelay will adjust the `time` parameter of rule evaluation requests to compensate intentional query delay from datasource.
EvalDelay float64 `json:"eval_delay,omitempty"`
// Unhealthy unhealthy rules count
Unhealthy int
unhealthy int
// Healthy passing rules count
Healthy int
healthy int
// NoMatch not matching rules count
NoMatch int
noMatch int
}
// APILink returns a link to the group's JSON representation.
func (ag *apiGroup) APILink() string {
return fmt.Sprintf("api/v1/group?%s=%s", paramGroupID, ag.ID)
}
// groupAlerts represents a group of alerts for WEB view

View File

@@ -121,7 +121,7 @@ func (p *vmNativeProcessor) runSingle(ctx context.Context, f native.Filter, srcU
pr := bar.NewProxyReader(reader)
if pr != nil {
reader = pr
fmt.Printf("Continue import process with filter %s:\n", f.String())
fmt.Fprintf(log.Writer(), "Continue import process with filter %s:\n", f.String())
}
}
@@ -191,7 +191,7 @@ func (p *vmNativeProcessor) runBackfilling(ctx context.Context, tenantID string,
initParams = []any{srcURL, dstURL, p.filter.String(), tenantID}
}
fmt.Println("") // extra line for better output formatting
fmt.Fprintln(log.Writer(), "") // extra line for better output formatting
log.Printf(initMessage, initParams...)
if len(ranges) > 1 {
log.Printf("Selected time range will be split into %d ranges according to %q step", len(ranges), p.filter.Chunk)

View File

@@ -6,8 +6,8 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/common"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/relabel"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prommetadata"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/prometheus"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/prometheus/stream"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
@@ -30,7 +30,7 @@ func InsertHandler(req *http.Request) error {
return err
}
encoding := req.Header.Get("Content-Encoding")
return stream.Parse(req.Body, defaultTimestamp, encoding, true, promscrape.IsMetadataEnabled(), func(rows []prometheus.Row, _ []prometheus.Metadata) error {
return stream.Parse(req.Body, defaultTimestamp, encoding, true, prommetadata.IsEnabled(), func(rows []prometheus.Row, _ []prometheus.Metadata) error {
return insertRows(rows, extraLabels)
}, func(s string) {
httpserver.LogError(req, s)

View File

@@ -142,6 +142,12 @@ func (s *series) summarize(aggrFunc aggrFunc, startTime, endTime, step int64, xF
}
func execExpr(ec *evalConfig, query string) (nextSeriesFunc, error) {
// Validate query length to prevent memory exhaustion
maxLen := searchutil.GetMaxQueryLen()
if len(query) > maxLen {
return nil, fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen)
}
expr, err := graphiteql.Parse(query)
if err != nil {
return nil, fmt.Errorf("cannot parse %q: %w", query, err)

View File

@@ -4070,6 +4070,9 @@ func TestExecExprFailure(t *testing.T) {
f(`holtWintersConfidenceArea(group(time("foo.baz",15),time("foo.baz",15)))`)
f(`holtWintersConfidenceArea()`)
// too long query
f(`sumSeries(` + strings.Repeat("metric.very.long.name.that.takes.space,", 500) + `metric.final)`)
}
func compareSeries(ss, ssExpected []*series, expr graphiteql.Expr) error {

View File

@@ -2,6 +2,7 @@ package vmselect
import (
"embed"
"encoding/json"
"flag"
"fmt"
"net/http"
@@ -67,6 +68,7 @@ func Init() {
prometheus.InitMaxUniqueTimeseries(*maxConcurrentRequests)
concurrencyLimitCh = make(chan struct{}, *maxConcurrentRequests)
initVMUIConfig()
initVMAlertProxy()
}
@@ -460,6 +462,11 @@ func handleStaticAndSimpleRequests(w http.ResponseWriter, r *http.Request, path
return true
}
if strings.HasPrefix(path, "/vmui/") {
if path == "/vmui/config.json" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, vmuiConfig)
return true
}
if strings.HasPrefix(path, "/vmui/static/") {
// Allow clients caching static contents for long period of time, since it shouldn't change over time.
// Path to static contents (such as js and css) must be changed whenever its contents is changed.
@@ -734,8 +741,34 @@ func proxyVMAlertRequests(w http.ResponseWriter, r *http.Request) {
var (
vmalertProxyHost string
vmalertProxy *nethttputil.ReverseProxy
vmuiConfig string
)
func initVMUIConfig() {
var cfg struct {
License struct {
Type string `json:"type"`
} `json:"license"`
VMAlert struct {
Enabled bool `json:"enabled"`
} `json:"vmalert"`
}
data, err := vmuiFiles.ReadFile("vmui/config.json")
if err != nil {
logger.Fatalf("cannot read vmui default config: %s", err)
}
err = json.Unmarshal(data, &cfg)
if err != nil {
logger.Fatalf("cannot parse vmui default config: %s", err)
}
cfg.VMAlert.Enabled = len(*vmalertProxyURL) != 0
data, err = json.Marshal(&cfg)
if err != nil {
logger.Fatalf("cannot create vmui config: %s", err)
}
vmuiConfig = string(data)
}
// initVMAlertProxy must be called after flag.Parse(), since it uses command-line flags.
func initVMAlertProxy() {
if len(*vmalertProxyURL) == 0 {

View File

@@ -24,7 +24,6 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
@@ -38,7 +37,6 @@ var (
latencyOffset = flag.Duration("search.latencyOffset", time.Second*30, "The time when data points become visible in query results after the collection. "+
"It can be overridden on per-query basis via latency_offset arg. "+
"Too small value can result in incomplete last points for query results")
maxQueryLen = flagutil.NewBytes("search.maxQueryLen", 16*1024, "The maximum search query length in bytes")
maxLookback = flag.Duration("search.maxLookback", 0, "Synonym to -query.lookback-delta from Prometheus. "+
"The value is dynamically detected from interval between time series datapoints if not set. It can be overridden on per-query basis via max_lookback arg. "+
"See also '-search.maxStalenessInterval' flag, which has the same meaning due to historical reasons")
@@ -733,8 +731,9 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
step = defaultStep
}
if len(query) > maxQueryLen.IntN() {
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxQueryLen.N)
maxLen := searchutil.GetMaxQueryLen()
if len(query) > maxLen {
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen)
}
etfs, err := searchutil.GetExtraTagFilters(r)
if err != nil {
@@ -904,8 +903,9 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
}
// Validate input args.
if len(query) > maxQueryLen.IntN() {
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxQueryLen.N)
maxLen := searchutil.GetMaxQueryLen()
if len(query) > maxLen {
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen)
}
if start > end {
end = start + defaultStep

View File

@@ -7,10 +7,12 @@ import (
"strings"
"time"
"github.com/VictoriaMetrics/metricsql"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
"github.com/VictoriaMetrics/metricsql"
)
var (
@@ -20,6 +22,7 @@ var (
maxStatusRequestDuration = flag.Duration("search.maxStatusRequestDuration", time.Minute*5, "The maximum duration for /api/v1/status/* requests")
maxLabelsAPIDuration = flag.Duration("search.maxLabelsAPIDuration", time.Second*5, "The maximum duration for /api/v1/labels, /api/v1/label/.../values and /api/v1/series requests. "+
"See also -search.maxLabelsAPISeries and -search.ignoreExtraFiltersAtLabelsAPI")
maxQueryLen = flagutil.NewBytes("search.maxQueryLen", 16*1024, "The maximum search query length in bytes")
)
// GetMaxQueryDuration returns the maximum duration for query from r.
@@ -227,3 +230,8 @@ func toTagFilter(dst *storage.TagFilter, src *metricsql.LabelFilter) {
dst.IsRegexp = src.IsRegexp
dst.IsNegative = src.IsNegative
}
// GetMaxQueryLen returns the current value of the search.maxQueryLen flag.
func GetMaxQueryLen() int {
return maxQueryLen.IntN()
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -36,10 +36,10 @@
<meta property="og:title" content="UI for VictoriaMetrics">
<meta property="og:url" content="https://victoriametrics.com/">
<meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data">
<script type="module" crossorigin src="./assets/index-Ck5nH8JI.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-BVRvRxZ2.js">
<script type="module" crossorigin src="./assets/index-DY3sj68d.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-DBOs1yKE.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
<link rel="stylesheet" crossorigin href="./assets/index-BHg4iVVe.css">
<link rel="stylesheet" crossorigin href="./assets/index-XlRqIMog.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -683,7 +683,7 @@ func writeStorageMetrics(w io.Writer, strg *storage.Storage) {
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricIDs", reason="miss_percentage"}`, m.MetricIDCacheMissEvictionBytes)
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricIDs", reason="expiration"}`, m.MetricIDCacheExpireEvictionBytes)
metrics.WriteCounterUint64(w, `vm_deleted_metrics_total{type="indexdb"}`, idbm.DeletedMetricsCount)
metrics.WriteCounterUint64(w, `vm_deleted_metrics_total{type="indexdb"}`, m.DeletedMetricsCount)
metrics.WriteCounterUint64(w, `vm_cache_collisions_total{type="storage/tsid"}`, m.TSIDCacheCollisions)
metrics.WriteCounterUint64(w, `vm_cache_collisions_total{type="storage/metricName"}`, m.MetricNameCacheCollisions)

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine3.19
FROM node:22-alpine3.22
# Sets a custom location for the npm cache, preventing access errors in system directories
ENV NPM_CONFIG_CACHE=/build/.npm

View File

@@ -1177,7 +1177,7 @@
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -1188,24 +1188,36 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz",
"integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1250,6 +1262,316 @@
"node": ">= 8"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@preact/preset-vite": {
"version": "2.10.2",
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.2.tgz",
@@ -1750,9 +2072,9 @@
}
},
"node_modules/@types/node": {
"version": "24.0.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz",
"integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==",
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -2221,7 +2543,7 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -2523,7 +2845,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -2572,6 +2894,14 @@
"devOptional": true,
"license": "MIT/X11"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -2702,6 +3032,23 @@
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -2750,6 +3097,14 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3060,6 +3415,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3808,7 +4177,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -4522,7 +4891,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4577,7 +4946,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -4616,7 +4985,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -5119,7 +5488,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -5184,6 +5553,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/node-html-parser": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
@@ -5512,7 +5889,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -5754,6 +6131,21 @@
"react-dom": ">=18"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -6054,6 +6446,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/sass": {
"version": "1.89.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz",
"integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded": {
"version": "1.89.2",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.89.2.tgz",
@@ -6583,6 +6997,29 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/source-map-support/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stack-trace": {
"version": "1.0.0-pre2",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz",
@@ -6849,6 +7286,26 @@
"node": ">=16.0.0"
}
},
"node_modules/terser": {
"version": "5.43.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz",
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6959,7 +7416,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"

View File

@@ -18,84 +18,96 @@ import QueryAnalyzer from "./pages/QueryAnalyzer";
import DownsamplingFilters from "./pages/DownsamplingFilters";
import RetentionFilters from "./pages/RetentionFilters";
import RawQueryPage from "./pages/RawQueryPage";
import ExploreRules from "./pages/ExploreAlerts/ExploreRules";
import ExploreNotifiers from "./pages/ExploreAlerts/ExploreNotifiers";
const App: FC = () => {
const [loadedTheme, setLoadedTheme] = useState(false);
return <>
<HashRouter>
<AppContextProvider>
<>
<ThemeProvider onLoaded={setLoadedTheme}/>
{loadedTheme && (
<Routes>
<Route
path={"/"}
element={<MainLayout/>}
>
return (
<>
<HashRouter>
<AppContextProvider>
<>
<ThemeProvider onLoaded={setLoadedTheme} />
{loadedTheme && (
<Routes>
<Route
path={router.home}
element={<CustomPanel/>}
/>
<Route
path={router.rawQuery}
element={<RawQueryPage/>}
/>
<Route
path={router.metrics}
element={<ExploreMetrics/>}
/>
<Route
path={router.cardinality}
element={<CardinalityPanel/>}
/>
<Route
path={router.topQueries}
element={<TopQueries/>}
/>
<Route
path={router.trace}
element={<TracePage/>}
/>
<Route
path={router.queryAnalyzer}
element={<QueryAnalyzer/>}
/>
<Route
path={router.dashboards}
element={<DashboardsLayout/>}
/>
<Route
path={router.withTemplate}
element={<WithTemplate/>}
/>
<Route
path={router.relabel}
element={<Relabel/>}
/>
<Route
path={router.activeQueries}
element={<ActiveQueries/>}
/>
<Route
path={router.icons}
element={<PreviewIcons/>}
/>
<Route
path={router.downsamplingDebug}
element={<DownsamplingFilters/>}
/>
<Route
path={router.retentionDebug}
element={<RetentionFilters/>}
/>
</Route>
</Routes>
)}
</>
</AppContextProvider>
</HashRouter>
</>;
path={"/"}
element={<MainLayout />}
>
<Route
path={router.home}
element={<CustomPanel />}
/>
<Route
path={router.rawQuery}
element={<RawQueryPage />}
/>
<Route
path={router.metrics}
element={<ExploreMetrics />}
/>
<Route
path={router.cardinality}
element={<CardinalityPanel />}
/>
<Route
path={router.topQueries}
element={<TopQueries />}
/>
<Route
path={router.trace}
element={<TracePage />}
/>
<Route
path={router.queryAnalyzer}
element={<QueryAnalyzer />}
/>
<Route
path={router.dashboards}
element={<DashboardsLayout />}
/>
<Route
path={router.withTemplate}
element={<WithTemplate />}
/>
<Route
path={router.relabel}
element={<Relabel />}
/>
<Route
path={router.activeQueries}
element={<ActiveQueries />}
/>
<Route
path={router.icons}
element={<PreviewIcons />}
/>
<Route
path={router.downsamplingDebug}
element={<DownsamplingFilters />}
/>
<Route
path={router.retentionDebug}
element={<RetentionFilters />}
/>
<Route
path={router.rules}
element={<ExploreRules />}
/>
<Route
path={router.notifiers}
element={<ExploreNotifiers />}
/>
</Route>
</Routes>
)}
</>
</AppContextProvider>
</HashRouter>
</>
);
};
export default App;

View File

@@ -1,2 +1,2 @@
export const getAccountIds = (server: string) =>
`${server.replace(/^(.+)(\/select.+)/, "$1")}/admin/tenants`;
import { getUrlWithoutTenant } from "../utils/tenants";
export const getAccountIds = (server: string) => `${getUrlWithoutTenant(server)}/admin/tenants`;

View File

@@ -0,0 +1,23 @@
export const getGroupsUrl = (server: string): string => {
return `${server}/vmalert/api/v1/rules?datasource_type=prometheus`;
};
export const getItemUrl = (
server: string,
groupId: string,
id: string,
mode: string,
): string => {
return `${server}/vmalert/api/v1/${mode}?group_id=${groupId}&${mode}_id=${id}`;
};
export const getGroupUrl = (
server: string,
id: string,
): string => {
return `${server}/vmalert/api/v1/group?group_id=${id}`;
};
export const getNotifiersUrl = (server: string): string => {
return `${server}/vmalert/api/v1/notifiers`;
};

View File

@@ -15,3 +15,24 @@ export const getExportDataUrl = (server: string, query: string, period: TimePara
if (reduceMemUsage) params.set("reduce_mem_usage", "1");
return `${server}/api/v1/export?${params}`;
};
export const getExportCSVDataUrl = (server: string, query: string[], period: TimeParams, reduceMemUsage: boolean): string => {
const params = new URLSearchParams({
start: period.start.toString(),
end: period.end.toString(),
format: "__name__,__value__,__timestamp__:unix_ms",
});
query.forEach((q => params.append("match[]", q)));
if (reduceMemUsage) params.set("reduce_mem_usage", "1");
return `${server}/api/v1/export/csv?${params}`;
};
export const getExportJSONDataUrl = (server: string, query: string[], period: TimeParams, reduceMemUsage: boolean): string => {
const params = new URLSearchParams({
start: period.start.toString(),
end: period.end.toString(),
});
query.forEach((q => params.append("match[]", q)));
if (reduceMemUsage) params.set("reduce_mem_usage", "1");
return `${server}/api/v1/export?${params}`;
};

View File

@@ -30,7 +30,13 @@ const delayOptions: AutoRefreshOption[] = [
{ seconds: 7200, title: "2h" }
];
export const ExecutionControls: FC = () => {
interface ExecutionControlsProps {
tooltip: string;
useAutorefresh?: boolean;
closeModal: () => void;
}
export const ExecutionControls: FC<ExecutionControlsProps> = ({ tooltip, useAutorefresh, closeModal }) => {
const { isMobile } = useDeviceDetect();
const dispatch = useTimeDispatch();
@@ -56,6 +62,9 @@ export const ExecutionControls: FC = () => {
const handleUpdate = () => {
dispatch({ type: "RUN_QUERY" });
if (!useAutorefresh && isMobile) {
closeModal();
}
};
useEffect(() => {
@@ -77,91 +86,118 @@ export const ExecutionControls: FC = () => {
handleChange(d);
};
return <>
<div className="vm-execution-controls">
<div
className={classNames({
"vm-execution-controls-buttons": true,
"vm-execution-controls-buttons_mobile": isMobile,
"vm-header-button": !appModeEnable,
})}
>
{!isMobile && (
<Tooltip title="Refresh dashboard">
<Button
variant="contained"
color="primary"
onClick={handleUpdate}
startIcon={<RefreshIcon/>}
ariaLabel="refresh dashboard"
/>
</Tooltip>
)}
{isMobile ? (
<div
className="vm-mobile-option"
onClick={toggleOpenOptions}
>
<span className="vm-mobile-option__icon"><RestartIcon/></span>
<div className="vm-mobile-option-text">
<span className="vm-mobile-option-text__label">Auto-refresh</span>
<span className="vm-mobile-option-text__value">{selectedDelay.title}</span>
</div>
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</div>
) : (
<Tooltip title="Auto-refresh control">
<div ref={optionsButtonRef}>
return (
<>
<div className="vm-execution-controls">
<div
className={classNames({
"vm-execution-controls-buttons": true,
"vm-execution-controls-buttons_mobile": isMobile,
"vm-header-button": !appModeEnable,
"vm-autorefresh": useAutorefresh,
})}
>
{useAutorefresh ? (
isMobile ? (
<div
className="vm-mobile-option"
onClick={toggleOpenOptions}
>
<span className="vm-mobile-option__icon"><RestartIcon/></span>
<div className="vm-mobile-option-text">
<span className="vm-mobile-option-text__label">Auto-refresh</span>
<span className="vm-mobile-option-text__value">{selectedDelay.title}</span>
</div>
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</div>
) : (
<>
<Tooltip title={tooltip}>
<Button
variant="contained"
color="primary"
onClick={handleUpdate}
startIcon={<RefreshIcon/>}
ariaLabel={tooltip}
/>
</Tooltip>
<Tooltip title="Auto-refresh control">
<div ref={optionsButtonRef}>
<Button
variant="contained"
color="primary"
fullWidth
endIcon={(
<div
className={classNames({
"vm-execution-controls-buttons__arrow": true,
"vm-execution-controls-buttons__arrow_open": openOptions,
})}
>
<ArrowDownIcon/>
</div>
)}
onClick={toggleOpenOptions}
>
{selectedDelay.title}
</Button>
</div>
</Tooltip>
</>
)
) : (
isMobile ? (
<div
className="vm-mobile-option"
onClick={handleUpdate}
>
<span className="vm-mobile-option__icon"><RestartIcon/></span>
<div className="vm-mobile-option-text">
<span className="vm-mobile-option-text__label">Refresh</span>
</div>
</div>
) : (
<Button
variant="contained"
color="primary"
fullWidth
endIcon={(
<div
className={classNames({
"vm-execution-controls-buttons__arrow": true,
"vm-execution-controls-buttons__arrow_open": openOptions,
})}
>
<ArrowDownIcon/>
</div>
)}
onClick={toggleOpenOptions}
>
{selectedDelay.title}
</Button>
</div>
</Tooltip>
)}
onClick={handleUpdate}
startIcon={<RefreshIcon/>}
ariaLabel={tooltip}
/>
)
)}
</div>
</div>
</div>
<Popper
open={openOptions}
placement="bottom-right"
onClose={handleCloseOptions}
buttonRef={optionsButtonRef}
title={isMobile ? "Auto-refresh duration" : undefined}
>
<div
className={classNames({
"vm-execution-controls-list": true,
"vm-execution-controls-list_mobile": isMobile,
})}
>
{delayOptions.map(d => (
{useAutorefresh && (
<Popper
open={openOptions}
placement="bottom-right"
onClose={handleCloseOptions}
buttonRef={optionsButtonRef}
title={isMobile ? "Auto-refresh duration" : undefined}
>
<div
className={classNames({
"vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": d.seconds === selectedDelay.seconds
"vm-execution-controls-list": true,
"vm-execution-controls-list_mobile": isMobile,
})}
key={d.seconds}
onClick={createHandlerChange(d)}
>
{d.title}
{delayOptions.map(d => (
<div
className={classNames({
"vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": d.seconds === selectedDelay.seconds
})}
key={d.seconds}
onClick={createHandlerChange(d)}
>
{d.title}
</div>
))}
</div>
))}
</div>
</Popper>
</>;
</Popper>
)}
</>
);
};

View File

@@ -7,7 +7,10 @@
display: flex;
justify-content: space-between;
border-radius: calc($button-radius + 1px);
min-width: 107px;
:is(.vm-autorefresh) {
min-width: 107px;
}
&_mobile {
flex-direction: column;

View File

@@ -1,20 +1,18 @@
import { FC, useCallback } from "preact/compat";
import { useCallback, useRef } from "preact/compat";
import Tooltip from "../Main/Tooltip/Tooltip";
import Button from "../Main/Button/Button";
import { DownloadIcon } from "../Main/Icons";
import Popper from "../Main/Popper/Popper";
import { useRef } from "react";
import "./style.scss";
import useBoolean from "../../hooks/useBoolean";
interface DownloadButtonProps {
interface DownloadButtonProps<T extends string> {
title: string;
downloadFormatOptions?: string[];
onDownload: (format?: string) => void;
downloadFormatOptions?: T[];
onDownload: (format?: T) => void;
}
/** TODO: Currently unused, later will be added for the exporting metrics */
const DownloadButton: FC<DownloadButtonProps> = ({ title, downloadFormatOptions, onDownload }) => {
const DownloadButton = <T extends string>({ title, downloadFormatOptions, onDownload }: DownloadButtonProps<T>) => {
const {
value: isPopupOpen,
setTrue: onOpenPopup,
@@ -35,9 +33,19 @@ const DownloadButton: FC<DownloadButtonProps> = ({ title, downloadFormatOptions,
}
}, [onDownload, onClosePopup, isPopupOpen, onOpenPopup]);
const isDownloadFormat = useCallback((format: string): format is T => {
return (downloadFormatOptions as string[])?.includes(format);
}, [downloadFormatOptions]);
const onDownloadFormatClick = useCallback((event: Event) => {
const button = event.currentTarget as HTMLButtonElement;
onDownload(button.textContent ?? undefined);
const format = button.textContent;
if (format && isDownloadFormat(format)) {
onDownload(format);
} else {
onDownload();
}
onClosePopup();
}, [onDownload]);
return (

View File

@@ -0,0 +1,34 @@
import "./style.scss";
import { ReactNode } from "react";
export type BadgeColor = "firing" | "inactive" | "pending" | "no-match" | "unhealthy" | "ok" | "passive";
interface BadgeItem {
value?: number | string;
color: BadgeColor;
}
interface BadgesProps {
items: Record<string, BadgeItem>;
align?: "center" | "start" | "end";
children?: ReactNode;
}
const Badges = ({ items, children, align = "start" }: BadgesProps) => {
return (
<div
className="vm-badges"
style={{ "justify-content": align }}
>
{Object.entries(items).map(([name, props]) => (
<span
key={name}
className={`vm-badge ${props.color}`}
>{props.value ? `${name}: ${props.value}` : name}</span>
))}
{children}
</div>
);
};
export default Badges;

View File

@@ -0,0 +1,69 @@
@use "src/styles/variables" as *;
$badge-colors: (
"firing": $color-error,
"inactive": $color-success,
"pending": $color-warning,
"no-match": $color-notice,
"unhealthy": $color-broken,
"ok": $color-info,
"passive": $color-passive,
"all": $color-passive,
);
.vm-badges {
display: flex;
flex-wrap: wrap;
gap: $padding-small;
&.align-center {
justify-content: center;
}
.vm-badge {
padding: 0 $padding-tiny;
width: fit-content;
@each $class, $color in $badge-colors {
&.#{$class} {
border: 1px solid $color;
color: $color;
}
}
}
}
.vm-badge-base {
font-weight: 400;
border-radius: $border-radius-small;
}
.vm-badge-menu-item {
@extend .vm-badge-base;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 22px;
@each $class, $color in $badge-colors {
&.#{$class} {
border-right: $border-radius-small solid $color;
}
}
}
.vm-badge-item {
@extend .vm-badge-base;
@each $class, $color in $badge-colors {
&.#{$class} {
border-left: $border-radius-small solid $color;
}
}
}
.vm-badge {
@extend .vm-badge-base;
background-color: transparent;
padding: 0 $padding-tiny;
line-height: 22px;
max-width: 300px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,92 @@
import "./style.scss";
import { Alert as APIAlert } from "../../../types";
import { createSearchParams } from "react-router-dom";
import Button from "../../Main/Button/Button";
import Badges from "../Badges";
import {
SearchIcon,
} from "../../Main/Icons";
import dayjs from "dayjs";
interface BaseAlertProps {
item: APIAlert;
}
const BaseAlert = ({ item }: BaseAlertProps) => {
const query = item?.expression;
const openQueryLink = () => {
const params = {
"g0.expr": query,
"g0.end_time": ""
};
window.open(`#/?${createSearchParams(params).toString()}`, "_blank", "noopener noreferrer");
};
return (
<div className="vm-explore-alerts-alert-item">
<table>
<tbody>
<tr>
<td
style={{ "text-align": "end" }}
colSpan={2}
>
<Button
size="small"
variant="outlined"
color="gray"
startIcon={<SearchIcon />}
onClick={openQueryLink}
>
<span className="vm-button-text">Run query</span>
</Button>
</td>
</tr>
<tr>
<td className="vm-col-md">Query</td>
<td>
<pre>
<code className="language-promql">{query}</code>
</pre>
</td>
</tr>
<tr>
<td className="vm-col-md">Active at</td>
<td>{dayjs(item.activeAt).format("DD MMM YYYY HH:mm:ss")}</td>
</tr>
{!!Object.keys(item?.labels || {}).length && (
<tr>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={Object.fromEntries(Object.entries(item.labels).map(([name, value]) => [name, {
color: "passive",
value: value,
}]))}
/>
</td>
</tr>
)}
</tbody>
</table>
{!!Object.keys(item.annotations || {}).length && (
<>
<span className="title">Annotations</span>
<table>
<tbody>
{Object.entries(item.annotations || {}).map(([name, value]) => (
<tr key={name}>
<td className="vm-col-md">{name}</td>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
);
};
export default BaseAlert;

View File

@@ -0,0 +1,74 @@
@use "src/styles/variables" as *;
.vm-modal {
.vm-explore-alerts-alert-item {
table {
width: auto;
}
}
}
.vm-explore-alerts-alert-item {
row-gap: $padding-global;
margin-right: $padding-global;
display: flex;
flex-direction: column;
.title {
font-weight: bold;
text-align: center;
}
a:hover > pre {
background-color: $color-background-badge;
cursor: pointer;
}
a:hover {
background-color: $color-background-hover;
cursor: pointer;
}
pre {
background-color: $color-background-badge;
padding: 0 $padding-global;
border-radius: $border-radius-small;
word-break: break-word;
white-space: pre-wrap;
.keyword,
.function,
.attr-name,
.range-duration {
color: $color-keyword;
}
}
.vm-col-sm {
width: 10%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.vm-col-md {
width: 15%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
table {
width: 100%;
td, th {
line-height: 30px;
padding: 4px $padding-small;
}
th {
font-weight: bold;
text-align: center;
padding: 0 $padding-small;
}
}
}

View File

@@ -0,0 +1,108 @@
import "./style.scss";
import { Group as APIGroup } from "../../../types";
import dayjs from "dayjs";
import { formatDuration } from "../helpers";
import Badges from "../Badges";
interface BaseGroupProps {
group: APIGroup;
}
const BaseGroup = ({ group }: BaseGroupProps) => {
return (
<div className="vm-explore-alerts-group">
<div></div>
<table>
<tbody>
{!!group.interval && (
<tr>
<td className="vm-col-md">Interval</td>
<td>{formatDuration(group.interval)}</td>
</tr>
)}
{!!group.lastEvaluation && (
<tr>
<td className="vm-col-md">Last evaluation</td>
<td>{dayjs(group.lastEvaluation).format("DD MMM YYYY HH:mm:ss")}</td>
</tr>
)}
{!!group.eval_offset && (
<tr>
<td className="vm-col-md">Eval offset</td>
<td>{formatDuration(group.eval_offset)}</td>
</tr>
)}
{!!group.eval_delay && (
<tr>
<td className="vm-col-md">Eval delay</td>
<td>{formatDuration(group.eval_delay)}</td>
</tr>
)}
{!!group.file && (
<tr>
<td className="vm-col-md">File</td>
<td>{group.file}</td>
</tr>
)}
{!!group.concurrency && (
<tr>
<td className="vm-col-md">Concurrency</td>
<td>{group.concurrency}</td>
</tr>
)}
{!!group?.labels?.length && (
<tr>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={Object.fromEntries(Object.entries(group.labels).map(([name, value]) => [name, {
color: "passive",
value: value,
}]))}
/>
</td>
</tr>
)}
{!!group?.params?.length && (
<tr>
<td className="vm-col-md">Params</td>
<td>
<Badges
items={Object.fromEntries(group.params.map(value => [value, {
color: "passive",
}]))}
/>
</td>
</tr>
)}
{!!group?.headers?.length && (
<tr>
<td className="vm-col-md">Headers</td>
<td>
<Badges
items={Object.fromEntries(group.headers.map(value => [value, {
color: "passive",
}]))}
/>
</td>
</tr>
)}
{!!group?.notifier_headers?.length && (
<tr>
<td className="vm-col-md">Notifier headers</td>
<td>
<Badges
items={Object.fromEntries(group.notifier_headers.map(value => [value, {
color: "passive",
}]))}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export default BaseGroup;

View File

@@ -0,0 +1,78 @@
@use "src/styles/variables" as *;
.vm-modal {
.vm-explore-alerts-group {
table {
width: auto;
}
}
}
.vm-explore-alerts-group {
row-gap: $padding-global;
margin-right: $padding-global;
display: flex;
flex-direction: column;
.title {
font-weight: bold;
text-align: center;
}
pre {
position: relative;
background-color: $color-background-badge;
padding: 0 $padding-global;
border-radius: $border-radius-small;
word-break: break-word;
white-space: pre-wrap;
.keyword,
.function,
.attr-name,
.range-duration {
color: $color-keyword;
}
div {
position: absolute;
top: 0;
right: 0;
display: flex;
column-gap: 4px;
}
}
.vm-col-sm {
width: 10%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.vm-col-md {
width: 15%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
table {
width: 100%;
tr.hoverable {
cursor: pointer;
&:hover {
background-color: $color-background-hover;
}
}
td, th {
line-height: 30px;
padding: 4px $padding-small;
}
th {
font-weight: bold;
text-align: center;
padding: 0 $padding-small;
}
}
}

View File

@@ -0,0 +1,204 @@
import "./style.scss";
import { Rule as APIRule } from "../../../types";
import { useNavigate, createSearchParams } from "react-router-dom";
import { SearchIcon, DetailsIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button";
import Alert from "../../Main/Alert/Alert";
import Badges, { BadgeColor } from "../Badges";
import dayjs from "dayjs";
import { formatDuration } from "../helpers";
interface BaseRuleProps {
item: APIRule;
}
const BaseRule = ({ item }: BaseRuleProps) => {
const query = item?.query;
const navigate = useNavigate();
const openAlertLink = (id: string) => {
return () => {
navigate({
pathname: "/rules",
search: `group_id=${item.group_id}&alert_id=${id}`,
});
};
};
const openQueryLink = () => {
const params = {
"g0.expr": query,
"g0.end_time": ""
};
window.open(`#/?${createSearchParams(params).toString()}`, "_blank", "noopener noreferrer");
};
return (
<div className="vm-explore-alerts-rule-item">
<div></div>
<table>
<tbody>
<tr>
<td
style={{ "text-align": "end" }}
colSpan={2}
>
<Button
size="small"
variant="outlined"
color="gray"
startIcon={<SearchIcon />}
onClick={openQueryLink}
>
<span className="vm-button-text">Run query</span>
</Button>
</td>
</tr>
<tr>
<td className="vm-col-md">Query</td>
<td>
<pre>
<code className="language-promql">{query}</code>
</pre>
</td>
</tr>
{!!item.duration && (
<tr>
<td className="vm-col-md">For</td>
<td>{formatDuration(item.duration)}</td>
</tr>
)}
{!!item.lastEvaluation && (
<tr>
<td className="vm-col-md">Last evaluation</td>
<td>{dayjs(item.lastEvaluation).format("DD MMM YYYY HH:mm:ss")}</td>
</tr>
)}
{!!item.lastError && item.health !== "ok" && (
<tr>
<td className="vm-col-md">Last error</td>
<td>
<Alert variant="error">{item.lastError}</Alert>
</td>
</tr>
)}
{!!Object.keys(item?.labels || {}).length && (
<tr>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={Object.fromEntries(Object.entries(item.labels).map(([name, value]) => [name, {
color: "passive",
value: value,
}]))}
/>
</td>
</tr>
)}
</tbody>
</table>
{!!Object.keys(item?.annotations || {}).length && (
<>
<span className="title">Annotations</span>
<table className="fixed">
<tbody>
{Object.entries(item.annotations || {}).map(([name, value]) => (
<tr key={name}>
<td className="vm-col-md">{name}</td>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{!!item?.updates?.length && (
<>
<span className="title">{`Last updates ${item.updates.length}/${item.max_updates_entries}`}</span>
<table className="fixed">
<thead>
<tr>
<th className="vm-col-md">Updated at</th>
<th className="vm-col-md">Series returned</th>
<th className="vm-col-md">Series fetched</th>
<th className="vm-col-md">Duration</th>
<th className="vm-col-md">Executed at</th>
</tr>
</thead>
<tbody>
{item.updates.map((update) => (
<tr
key={update.at}
>
<td className="vm-col-md">{dayjs(update.time).format("DD MMM YYYY HH:mm:ss")}</td>
<td className="vm-col-md">{update.samples}</td>
<td className="vm-col-md">{update.series_fetched}</td>
<td className="vm-col-md">{formatDuration(update.duration / 1e9)}</td>
<td className="vm-col-md">{dayjs(update.at).format("DD MMM YYYY HH:mm:ss")}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{!!item?.alerts?.length && (
<>
<span className="title">Alerts</span>
<table className="fixed">
<thead>
<tr>
<th className="vm-col-sm">Active since</th>
<th className="vm-col-sm">State</th>
<th className="vm-col-sm">Value</th>
<th>Labels</th>
<th className="vm-col-hidden"></th>
</tr>
</thead>
<tbody>
{item.alerts.map((alert) => (
<tr
id={`alert-${alert.id}`}
key={alert.id}
>
<td className="vm-col-sm">
{dayjs(alert.activeAt).format("DD MMM YYYY HH:mm:ss")}
</td>
<td className="vm-col-sm">
<Badges
items={{ [alert.state]: { color: alert.state as BadgeColor } }}
/>
</td>
<td className="vm-col-sm">
<Badges
items={{ [alert.value]: { color: "passive" } }}
/>
</td>
<td>
<Badges
align="center"
items={Object.fromEntries(Object.entries(alert.labels || {}).map(([name, value]) => [name, {
color: "passive",
value: value,
}]))}
/>
</td>
<td className="vm-col-hidden">
<Button
className="vm-button-borderless"
size="small"
variant="outlined"
color="gray"
startIcon={<DetailsIcon />}
onClick={openAlertLink(alert.id)}
/>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
);
};
export default BaseRule;

View File

@@ -0,0 +1,88 @@
@use "src/styles/variables" as *;
.vm-modal {
.vm-explore-alerts-rule-item {
table {
width: auto;
}
}
}
.vm-explore-alerts-rule-item {
row-gap: $padding-global;
margin-right: $padding-global;
display: flex;
flex-direction: column;
.title {
font-weight: bold;
text-align: center;
}
pre {
position: relative;
background-color: $color-background-badge;
padding: 0 $padding-global;
border-radius: $border-radius-small;
word-break: break-word;
white-space: pre-wrap;
.keyword,
.function,
.attr-name,
.range-duration {
color: $color-keyword;
}
div {
position: absolute;
top: 0;
right: 0;
display: flex;
column-gap: 4px;
}
}
.vm-col-hidden {
width: 30px;
}
.vm-button {
color: $color-passive;
border: 1px solid var(--color-passive);
}
.vm-col-sm {
width: 10%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.vm-col-md {
width: 15%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
table {
&.fixed {
table-layout: fixed;
}
width: 100%;
td, th {
line-height: 30px;
padding: 4px $padding-small;
vertical-align: middle;
}
td.align-center {
text-align: center
}
th {
font-weight: bold;
text-align: center;
padding: 0 $padding-small;
}
}
}

View File

@@ -0,0 +1,57 @@
import { FC } from "preact/compat";
import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { useNavigate } from "react-router-dom";
import { Group as APIGroup } from "../../../types";
import { DetailsIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button";
import Badges, { BadgeColor } from "../Badges";
import classNames from "classnames";
interface GroupHeaderControlsProps {
group: APIGroup;
}
const GroupHeaderHeader: FC<GroupHeaderControlsProps> = ({ group }) => {
const { isMobile } = useDeviceDetect();
const navigate = useNavigate();
const openGroupModal = async () => {
navigate({
pathname: "/rules",
search: `group_id=${group.id}`,
});
};
const headerClasses = classNames({
"vm-explore-alerts-group-header": true,
"vm-explore-alerts-group-header_mobile": isMobile,
});
return (
<div className={headerClasses}>
<div className="vm-explore-alerts-group-header__desc">
<div className="vm-explore-alerts-group-header__name">{group.name}</div>
{!isMobile && (
<div className="vm-explore-alerts-group-header__file">{group.file}</div>
)}
</div>
<Badges
items={Object.fromEntries(Object.entries(group.states || {}).map(([name, value]) => [name.toLowerCase(), {
color: name.toLowerCase().replace(" ", "-") as BadgeColor,
value: value,
}]))}
>
<Button
className="vm-button-borderless"
size="small"
color="gray"
variant="outlined"
startIcon={<DetailsIcon />}
onClick={openGroupModal}
/>
</Badges>
</div>
);
};
export default GroupHeaderHeader;

View File

@@ -0,0 +1,60 @@
@use "src/styles/variables" as *;
.vm-explore-alerts-group-header {
display: flex;
align-items: center;
padding: $padding-tiny 0 $padding-tiny $padding-global;
justify-content: space-between;
.vm-button_small {
padding: 4px;
}
.vm-button-borderless {
border: 0;
}
@media(max-width: 768px) {
.vm-button-text {
display: none;
}
}
&_mobile {
.vm-button-text {
display: none;
}
}
&__desc {
display: flex;
flex-direction: column;
gap: $padding-tiny;
}
&__index {
color: $color-text-secondary;
font-size: $font-size-small;
}
&__name {
flex-grow: 1;
font-weight: bold;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
line-height: 130%;
word-break: break-word;
}
&__file {
color: $color-text-disabled;
}
code {
padding: 0.2em 0.4em;
font-size: 85%;
background-color: $color-hover-black;
border-radius: 6px;
}
}

View File

@@ -0,0 +1,127 @@
import { FC } from "preact/compat";
import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
import { useAppState } from "../../../state/common/StateContext";
import Tooltip from "../../Main/Tooltip/Tooltip";
import classNames from "classnames";
import { useNavigate } from "react-router-dom";
import Badges, { BadgeColor } from "../Badges";
import {
LinkIcon,
GroupIcon,
AlertIcon,
AlertingRuleIcon,
RecordingRuleIcon,
DetailsIcon,
} from "../../Main/Icons";
import Button from "../../Main/Button/Button";
interface ItemHeaderControlsProps {
entity: string;
type?: string;
groupId: string;
states?: Record<string, number>;
id?: string;
name: string;
onClose?: () => void;
}
const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, type, states, onClose }) => {
const { isMobile } = useDeviceDetect();
const { serverUrl } = useAppState();
const navigate = useNavigate();
const copyToClipboard = useCopyToClipboard();
const openItemLink = () => {
navigate({
pathname: "/rules",
search: `group_id=${groupId}&${entity}_id=${id}`,
});
};
const copyLink = async () => {
let link = `${serverUrl}/vmui/#/rules?group_id=${groupId}`;
if (type) link = `${link}&${entity}_id=${id}`;
await copyToClipboard(link, `Link to ${entity} has been copied`);
};
const headerClasses = classNames({
"vm-explore-alerts-item-header": true,
"vm-explore-alerts-item-header_mobile": isMobile,
});
const renderIcon = () => {
switch(entity) {
case "alert":
return (
<Tooltip title="Alert">
<AlertIcon />
</Tooltip>
);
case "group":
return (
<Tooltip title="Group">
<GroupIcon />
</Tooltip>
);
default:
switch(type) {
case "alerting":
return (
<Tooltip title="Alerting rule">
<AlertingRuleIcon />
</Tooltip>
);
default:
return (
<Tooltip title="Recording rule">
<RecordingRuleIcon />
</Tooltip>
);
}
}
};
return (
<div
className={headerClasses}
id={`rule-${id}`}
>
<div className="vm-explore-alerts-item-header__title">
{renderIcon()}
<div className="vm-explore-alerts-item-header__name">{name}</div>
</div>
<Badges
items={Object.fromEntries(Object.entries(states || {}).map(([name, value]) => [name, {
color: name.toLowerCase().replace(" ", "-") as BadgeColor,
value: value == 1 ? 0 : value,
}]))}
>
{onClose ? (
<Button
className="vm-back-button"
size="small"
variant="outlined"
color="gray"
startIcon={<LinkIcon />}
onClick={copyLink}
>
<span className="vm-button-text">Copy Link</span>
</Button>
) : (
<Button
className="vm-button-borderless"
size="small"
variant="outlined"
color="gray"
startIcon={<DetailsIcon />}
onClick={openItemLink}
/>
)}
</Badges>
</div>
);
};
export default ItemHeader;

View File

@@ -0,0 +1,70 @@
@use "src/styles/variables" as *;
.vm-explore-alerts-item-header {
display: flex;
grid-template-columns: auto 1fr auto auto;
align-items: center;
justify-content: space-between;
gap: $padding-global;
.vm-button_small {
padding: 4px;
}
@media(max-width: 768px) {
.vm-button-text {
display: none;
}
}
.vm-button-borderless {
border: 0;
}
.vm-back-button {
svg {
transform: rotate(90deg);
}
}
&_mobile {
grid-template-columns: 1fr auto;
.vm-button-text {
display: none;
}
}
&__index {
color: $color-text-secondary;
font-size: $font-size-small;
}
&__name {
font-weight: bold;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
line-height: 130%;
word-break: break-word;
}
&__title {
display: flex;
column-gap: $padding-global;
svg {
fill: $color-text-disabled;
width: 14px;
}
}
&__file {
color: $color-text-disabled;
}
code {
padding: 0.2em 0.4em;
font-size: 85%;
background-color: $color-hover-black;
border-radius: 6px;
}
}

View File

@@ -0,0 +1,30 @@
import { FC } from "preact/compat";
import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { Notifier } from "../../../types";
import classNames from "classnames";
interface NotifierHeaderControlsProps {
notifier: Notifier;
}
const NotifierHeaderHeader: FC<NotifierHeaderControlsProps> = ({
notifier,
}) => {
const { isMobile } = useDeviceDetect();
return (
<div
className={classNames({
"vm-explore-alerts-notifier-header": true,
"vm-explore-alerts-notifier-header_mobile": isMobile,
})}
>
<div className="vm-explore-alerts-notifier-header__name">
{notifier.kind}
</div>
</div>
);
};
export default NotifierHeaderHeader;

View File

@@ -0,0 +1,40 @@
@use "src/styles/variables" as *;
.vm-explore-alerts-notifier-header {
display: flex;
grid-template-columns: auto 1fr auto auto;
align-items: center;
padding: $padding-global;
justify-content: space-between;
gap: $padding-global;
&_mobile {
grid-template-columns: 1fr auto;
padding: $padding-small $padding-global;
}
&__index {
color: $color-text-secondary;
font-size: $font-size-small;
}
&__name {
flex-grow: 1;
font-weight: bold;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
line-height: 130%;
}
&__file {
color: $color-text-disabled;
}
code {
padding: 0.2em 0.4em;
font-size: 85%;
background-color: $color-hover-black;
border-radius: 6px;
}
}

View File

@@ -0,0 +1,59 @@
import { FC } from "preact/compat";
import Select from "../../Main/Select/Select";
import { SearchIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField";
import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface NotifiersHeaderProps {
kinds: string[];
allKinds: string[];
onChangeKinds: (input: string) => void;
onChangeSearch: (input: string) => void;
}
const NotifiersHeader: FC<NotifiersHeaderProps> = ({
kinds,
allKinds,
onChangeKinds,
onChangeSearch,
}) => {
const { isMobile } = useDeviceDetect();
return (
<>
<div
className={classNames({
"vm-explore-alerts-header": true,
"vm-explore-alerts-header_mobile": isMobile,
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div className="vm-explore-alerts-header__rule_type">
<Select
value={kinds}
list={allKinds}
label="Notifier type"
placeholder="Please select notifier type"
onChange={onChangeKinds}
autofocus={!!kinds.length && !isMobile}
includeAll
searchable
/>
</div>
<div className="vm-explore-alerts-header-search">
<TextField
label="Search"
placeholder="Filter by kind, address or labels"
startIcon={<SearchIcon />}
onChange={onChangeSearch}
/>
</div>
</div>
</>
);
};
export default NotifiersHeader;

View File

@@ -0,0 +1,65 @@
@use "src/styles/variables" as *;
.vm-explore-alerts-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: $padding-global calc($padding-small + 10px);
width: 100%;
&_mobile {
flex-direction: column;
align-items: stretch;
}
&__rule_type {
min-width: 120px;
}
&__state {
min-width: 150px;
}
&-description {
display: grid;
grid-template-columns: 1fr auto;
align-items: flex-start;
gap: $padding-small;
ul {
list-style-position: inside;
}
button {
color: inherit;
min-height: 29px;
}
code {
margin: 0 3px;
}
}
&-search {
flex-grow: 1;
.vm-text-field__input {
padding: 11px 28px;
}
.vm-text-field__icon-start {
height: 42px;
}
}
&__clear-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
}

View File

@@ -0,0 +1,34 @@
import { FC } from "preact/compat";
import ItemHeader from "../ItemHeader";
import Accordion from "../../Main/Accordion/Accordion";
import "./style.scss";
import { Rule as APIRule } from "../../../types";
import BaseRule from "../BaseRule";
interface RuleProps {
states: Record<string, number>;
rule: APIRule;
}
const Rule: FC<RuleProps> = ({ states, rule }) => {
const state = Object.keys(states).length > 0 ? Object.keys(states)[0] : "ok";
return (
<div className={`vm-explore-alerts-rule vm-badge-item ${state.replace(" ", "-")}`}>
<Accordion
key={`rule-${rule.id}`}
title={<ItemHeader
entity="rule"
type={rule.type}
groupId={rule.group_id}
states={states}
id={rule.id}
name={rule.name}
/>}
>
<BaseRule item={rule} />
</Accordion>
</div>
);
};
export default Rule;

View File

@@ -0,0 +1,18 @@
@use "src/styles/variables" as *;
.vm-explore-alerts-rule {
padding: $padding-tiny;
padding-right: 0;
display: flex;
row-gap: $padding-tiny;
flex-direction: column;
position: relative;
&:has(>details[open]) {
background-color: $color-background-item;
}
&:hover {
background-color: $color-background-item;
}
}

View File

@@ -0,0 +1,82 @@
import { FC, useMemo } from "preact/compat";
import Select from "../../Main/Select/Select";
import { SearchIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField";
import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface RulesHeaderProps {
types: string[];
allTypes: string[];
allStates: string[];
states: string[];
onChangeTypes: (input: string) => void;
onChangeStates: (input: string) => void;
onChangeSearch: (input: string) => void;
}
const RulesHeader: FC<RulesHeaderProps> = ({
types,
allTypes,
allStates,
states,
onChangeTypes,
onChangeStates,
onChangeSearch,
}) => {
const noStateText = useMemo(
() => (types.length ? "" : "No states. Please select rule states"),
[types],
);
const { isMobile } = useDeviceDetect();
return (
<>
<div
className={classNames({
"vm-explore-alerts-header": true,
"vm-explore-alerts-header_mobile": isMobile,
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div className="vm-explore-alerts-header__rule_type">
<Select
value={types}
list={allTypes}
label="Rules type"
placeholder="Please select rule type"
onChange={onChangeTypes}
autofocus={!!types.length && !isMobile}
includeAll
searchable
/>
</div>
<div className="vm-explore-alerts-header__state">
<Select
itemClassName="vm-badge-menu-item"
value={states}
list={allStates}
label="State"
placeholder="Please rule state"
onChange={onChangeStates}
noOptionsText={noStateText}
includeAll
searchable
/>
</div>
<div className="vm-explore-alerts-header-search">
<TextField
label="Search"
placeholder="Filter by rule, name or labels"
startIcon={<SearchIcon />}
onChange={onChangeSearch}
/>
</div>
</div>
</>
);
};
export default RulesHeader;

View File

@@ -0,0 +1,65 @@
@use "src/styles/variables" as *;
.vm-explore-alerts-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: $padding-global calc($padding-small + 10px);
width: 100%;
&_mobile {
flex-direction: column;
align-items: stretch;
}
&__rule_type {
min-width: 120px;
}
&__state {
min-width: 150px;
}
&-description {
display: grid;
grid-template-columns: 1fr auto;
align-items: flex-start;
gap: $padding-small;
ul {
list-style-position: inside;
}
button {
color: inherit;
min-height: 29px;
}
code {
margin: 0 3px;
}
}
&-search {
flex-grow: 1;
.vm-text-field__input {
padding: 11px 28px;
}
.vm-text-field__icon-start {
height: 42px;
}
}
&__clear-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
}

View File

@@ -0,0 +1,58 @@
import { FC } from "preact/compat";
import "./style.scss";
import { Target as APITarget } from "../../../types";
import Alert from "../../Main/Alert/Alert";
import Accordion from "../../Main/Accordion/Accordion";
import Badges from "../Badges";
interface TargetProps {
target: APITarget;
}
const Target: FC<TargetProps> = ({ target }) => {
const state = target?.lastError ? "unhealthy" : "ok";
return (
<div className={`vm-explore-alerts-target vm-badge-item ${state.replace(" ", "-")}`}>
{(!!target?.labels?.length || !!target?.lastError) ? (
<Accordion
key={`target-${target.address}`}
title={(
<div className="vm-explore-alerts-target-header__name">{target.address}</div>
)}
>
<div className="vm-explore-alerts-target-item">
<table>
<tbody>
{!!target?.labels?.length && (
<tr>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={Object.fromEntries(Object.entries(target.labels).map(([name, value]) => [name, {
value: value,
color: "passive",
}]))}
/>
</td>
</tr>
)}
{!!target.lastError && (
<tr>
<td className="vm-col-md">Last error</td>
<td>
<Alert variant="error">{target.lastError}</Alert>
</td>
</tr>
)}
</tbody>
</table>
</div>
</Accordion>
) : (
<span>{target.address}</span>
)}
</div>
);
};
export default Target;

View File

@@ -0,0 +1,48 @@
@use "src/styles/variables" as *;
.vm-explore-alerts-target {
row-gap: $padding-global;
margin-right: $padding-global;
display: flex;
flex-direction: column;
.vm-col-md {
width: 40%;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
table {
width: 100%;
td {
vertical-align: middle;
padding: $padding-global $padding-small;
}
th {
font-weight: bold;
text-align: center;
padding: 0 $padding-small;
}
}
padding: $padding-tiny;
padding-right: 0;
display: flex;
row-gap: $padding-tiny;
flex-direction: column;
position: relative;
border-radius: $border-radius-small;
&:has(>details[open]) {
background-color: $color-background-item;
}
&:hover {
background-color: $color-background-item;
}
.vm-explore-alerts-item-header__name {
line-height: 22px;
}
}

View File

@@ -0,0 +1,15 @@
import dayjs from "dayjs";
export const formatDuration = (raw: number) => {
const duration = dayjs.duration(Math.round(raw * 1000));
const fmt = [];
if (duration.get("day")) fmt.push("D[d]");
if (duration.get("hour")) fmt.push("H[h]");
if (duration.get("minute")) fmt.push("m[m]");
if (duration.get("millisecond")) {
fmt.push("s.SSS[s]");
} else if (!fmt.length || duration.get("second")) {
fmt.push("s[s]");
}
return duration.format(fmt.join(" "));
};

View File

@@ -1,9 +1,11 @@
import { FC, useState, useEffect } from "preact/compat";
import { JSX } from "preact";
import { ArrowDownIcon } from "../Icons";
import "./style.scss";
import { ReactNode } from "react";
interface AccordionProps {
id?: string
title: ReactNode
children: ReactNode
defaultExpanded?: boolean
@@ -14,21 +16,24 @@ const Accordion: FC<AccordionProps> = ({
defaultExpanded = false,
onChange,
title,
children
children,
id,
}) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
const toggleOpen = () => {
const toggleOpen = (event: JSX.TargetedMouseEvent<HTMLElement>) => {
const selection = window.getSelection();
if (selection && selection.toString()) {
if ((event.target as HTMLElement).closest("button")) {
event.preventDefault();
return; // If the text is selected, cancel the execution of toggle.
}
setIsOpen((prev) => {
const newState = !prev;
onChange && onChange(newState);
return newState;
});
if (selection && selection.toString()) {
event.preventDefault();
return; // If the text is selected, cancel the execution of toggle.
}
const details = event.currentTarget.parentElement as HTMLDetailsElement;
onChange && onChange(details.open);
setIsOpen(details.open);
};
useEffect(() => {
@@ -37,23 +42,23 @@ const Accordion: FC<AccordionProps> = ({
return (
<>
<header
className={`vm-accordion-header ${isOpen && "vm-accordion-header_open"}`}
onClick={toggleOpen}
<details
className="vm-accordion-section"
key="content"
open={isOpen}
id={id}
>
{title}
<div className={`vm-accordion-header__arrow ${isOpen && "vm-accordion-header__arrow_open"}`}>
<ArrowDownIcon />
</div>
</header>
{isOpen && (
<section
className="vm-accordion-section"
key="content"
<summary
className="vm-accordion-header"
onClick={toggleOpen}
>
{children}
</section>
)}
{title}
<div className="vm-accordion-header__arrow">
<ArrowDownIcon />
</div>
</summary>
{children}
</details>
</>
);
};

View File

@@ -17,10 +17,6 @@
transform: rotate(0);
transition: transform 200ms ease-in-out;
&_open {
transform: rotate(180deg);
}
svg {
width: 14px;
height: auto;
@@ -28,6 +24,14 @@
}
}
.vm-accordion-section[open] > summary {
& > .vm-accordion-header {
&__arrow {
transform: rotate(180deg);
}
}
}
.accordion-section {
overflow: hidden;
}

View File

@@ -1,41 +1,27 @@
@use "src/styles/variables" as *;
.vm-alert {
position: relative;
z-index: 20;
position: sticky;
top: $padding-global;
display: grid;
grid-template-columns: 20px 1fr;
align-items: center;
gap: $padding-small;
padding: $padding-global;
background-color: $color-background-block;
border-radius: $border-radius-medium;
box-shadow: $box-shadow;
font-size: $font-size;
font-weight: normal;
color: $color-text;
line-height: 1.5;
opacity: 0.8;
&_mobile {
align-items: flex-start;
border-radius: 0;
}
&:after {
position: absolute;
content: '';
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: $border-radius-medium;
z-index: 1;
opacity: 0.1;
}
&_mobile:after {
border-radius: 0;
}
&__icon,
&__content {
position: relative;
@@ -48,54 +34,53 @@
justify-content: center;
align-self: flex-start;
min-height: 24px;
filter: brightness(0.6);
}
&__content {
filter: brightness(0.6);
white-space: pre-line;
text-wrap: balance;
overflow-wrap: anywhere;
filter: brightness(0.6);
}
&_success {
color: $color-success;
&:after {
background-color: $color-success;
}
background-color: $color-background-success;
}
&_error {
color: $color-error;
&:after {
background-color: $color-error;
}
background-color: $color-background-error;
}
&_info {
color: $color-info;
&:after {
background-color: $color-info;
}
background-color: $color-background-info;
}
&_warning {
color: $color-warning;
&:after {
background-color: $color-warning;
}
background-color: $color-background-warning;
}
&_dark {
&:after {
opacity: 0.1;
}
}
&_dark &__content {
&_dark &__content, &_dark &__icon {
filter: none;
}
&_dark:is(&_success) {
border: 0.5px solid $color-success;
}
&_dark:is(&_error) {
border: 0.5px solid $color-error;
}
&_dark:is(&_info) {
border: 0.5px solid $color-info;
}
&_dark:is(&_warning) {
border: 0.5px solid $color-warning;
}
}

View File

@@ -15,6 +15,7 @@ export interface AutocompleteOptions {
}
interface AutocompleteProps {
itemClassName?: string
value: string
options: AutocompleteOptions[]
anchor: React.RefObject<HTMLElement>
@@ -41,6 +42,7 @@ enum FocusType {
const Autocomplete: FC<AutocompleteProps> = ({
value,
itemClassName,
options,
anchor,
disabled,
@@ -212,7 +214,9 @@ const Autocomplete: FC<AutocompleteProps> = ({
>
{selected?.includes(option.value) && <DoneIcon/>}
<>{option.icon}</>
<span>{option.value}</span>
<div className={`vm-list-item-inner ${itemClassName} ${option.value.toLowerCase().replace(" ", "-")}`}>
<span>{option.value}</span>
</div>
</div>
)}
</div>

View File

@@ -1,5 +1,61 @@
import { getCssVariable } from "../../../utils/theme";
export const LinkIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.975 14.51a1.05 1.05 0 0 0 0-1.485 2.95 2.95 0 0 1 0-4.172l3.536-3.535a2.95 2.95 0 1 1 4.172 4.172l-1.093 1.092a1.05 1.05 0 0 0 1.485 1.485l1.093-1.092a5.05 5.05 0 0 0-7.142-7.142L9.49 7.368a5.05 5.05 0 0 0 0 7.142c.41.41 1.075.41 1.485 0m2.05-5.02a1.05 1.05 0 0 0 0 1.485 2.95 2.95 0 0 1 0 4.172l-3.5 3.5a2.95 2.95 0 1 1-4.171-4.172l1.025-1.025a1.05 1.05 0 0 0-1.485-1.485L3.87 12.99a5.05 5.05 0 0 0 7.142 7.142l3.5-3.5a5.05 5.05 0 0 0 0-7.142 1.05 1.05 0 0 0-1.485 0z"/>
</svg>
);
export const GroupIcon = () => (
<svg
fill="currentColor"
viewBox="0 0 512 512"
>
<path d="M170.667 64v42.667h-64v298.666h64V448H64V64zM448 64v384H341.333v-42.667h64V106.667h-64V64zm-85.333 256v42.667H149.333V320zm0-85.333v42.666H149.333v-42.666zm0-85.334V192H149.333v-42.667z"/>
</svg>
);
export const DetailsIcon = () => (
<svg
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M12 3a2 2 0 1 0-4 0 2 2 0 0 0 4 0m-2 5a2 2 0 1 1 0 4 2 2 0 0 1 0-4m0 7a2 2 0 1 1 0 4 2 2 0 0 1 0-4"/>
</svg>
);
export const AlertIcon = () => (
<svg
viewBox="-1 0 30 30"
fill="currentColor"
>
<path d="m3 24 3-6v-8a8 8 0 0 1 16 0v8l3 6zm11 4a2.99 2.99 0 0 1-2.816-2h5.632A2.99 2.99 0 0 1 14 28m10-10v-8c0-5.522-4.478-10-10-10S4 4.478 4 10v8l-4 8h9.101a5 5 0 0 0 9.798 0H28z"/>
</svg>
);
export const AlertingRuleIcon = () => (
<svg
viewBox="411.014 448.582 21.637 17.836"
fill="currentColor"
>
<path d="M411.47 453.837a.72.72 0 0 0-.422.679c0 .319.137.535.422.667.139.065.322.068 3.814.069l3.668.001.164-.083c.484-.245.535-.904.096-1.254l-.141-.112-3.746-.008c-3.05-.006-3.766.001-3.855.041m-.016 5.54c-.284.124-.44.372-.439.701.001.354.204.614.553.708.092.025 1.214.034 3.267.028 3.481-.011 3.23.008 3.468-.264a.714.714 0 0 0-.003-.963c-.249-.283-.03-.267-3.564-.266-2.735 0-3.171.008-3.282.056m-.018 5.016a.72.72 0 0 0-.422.679c0 .319.137.535.422.667.139.065.322.068 3.814.069l3.668.001.164-.083c.484-.245.535-.904.096-1.254l-.141-.112-3.746-.008c-3.05-.006-3.766.001-3.855.041m.161-15.761a.85.85 0 0 0-.389.417c-.023.07-.034.224-.026.343.018.239.078.345.301.525l.139.112 8.729.01 8.73.009.163-.079c.193-.093.36-.332.395-.565a.75.75 0 0 0-.388-.75c-.142-.072-.227-.073-8.844-.072-7.105.002-8.721.011-8.81.05m14.005 2.595c-.286.18-.371.401-.371.961v.334l-.499.024c-.598.028-.961.126-1.456.392a3.5 3.5 0 0 0-1.721 2.199c-.081.307-.091.479-.115 1.923-.027 1.566-.028 1.59-.138 1.966-.145.496-.557 1.361-.929 1.945a5 5 0 0 0-.368.677c-.1.292-.095.679.013.982.112.32.461.686.75.789.276.099 1.255.259 2.268.373l.84.095.028.287q.089.935.767 1.579a2.383 2.383 0 0 0 3.659-.434c.227-.351.36-.745.396-1.161l.023-.283.291-.027c.956-.093 2.47-.32 2.715-.408.393-.14.694-.464.817-.875.16-.539.093-.833-.354-1.554-.373-.601-.832-1.565-.956-2.007-.083-.29-.093-.448-.119-1.903-.027-1.427-.039-1.619-.118-1.924a3.5 3.5 0 0 0-1.895-2.327c-.422-.202-.758-.282-1.309-.312l-.489-.025-.022-.473c-.022-.521-.062-.621-.325-.806-.124-.088-.182-.096-.69-.096-.489.001-.57.012-.693.089m2.696 2.786c.546.176.994.583 1.249 1.135l.149.326.025 1.543c.027 1.672.046 1.837.286 2.598.166.52.621 1.468.974 2.028.189.303.274.472.244.492-.104.066-1.778.288-2.915.387-.788.068-3.246.068-4.037 0-1.154-.099-2.811-.32-2.919-.39-.035-.023.03-.16.223-.469.375-.603.805-1.493.976-2.024.246-.763.272-1 .272-2.426 0-.701.019-1.398.043-1.549.083-.554.47-1.148.931-1.429.103-.063.308-.157.453-.209.258-.092.315-.094 2.025-.096 1.642-.001 1.776.005 2.021.083m-1.384 10.771a1.06 1.06 0 0 1-.748.2c-.394-.066-.776-.451-.835-.841l-.026-.168h2.005v.108z"/>
</svg>
);
export const RecordingRuleIcon = () => (
<svg
viewBox="411.014 448.582 23.358 18.492"
fill="currentColor"
>
<path d="M411.47 453.837a.72.72 0 0 0-.422.679c0 .319.137.535.422.667.139.065.322.068 3.814.069l3.668.001.164-.083c.484-.245.535-.904.096-1.254l-.141-.112-3.746-.008c-3.05-.006-3.766.001-3.855.041m15.967 7.103a1.592 1.612 0 1 1 1.592-1.612 1.592 1.612 0 0 1-1.592 1.612m0-1.612"/>
<path d="M427.405 466.377a6.966 7.052 0 1 1 6.965-7.053 6.974 7.06 0 0 1-6.965 7.053m0-12.09a4.975 5.037 0 1 0 4.975 5.037 4.981 5.043 0 0 0-4.975-5.037"/>
<path d="M421.832 467.074a.996 1.008 0 0 1-.708-1.715l3.582-3.675a.995 1.008 0 0 1 1.417 1.415l-3.582 3.675a.995 1.007 0 0 1-.709.3m-10.378-7.697c-.284.124-.44.372-.439.701.001.354.204.614.553.708.092.025 1.214.034 3.267.028 3.481-.011 3.23.008 3.468-.264a.714.714 0 0 0-.003-.963c-.249-.283-.03-.267-3.564-.266-2.735 0-3.171.008-3.282.056m-.018 5.016a.72.72 0 0 0-.422.679c0 .319.137.535.422.667.139.065.322.068 3.814.069l3.668.001.164-.083c.484-.245.535-.904.096-1.254l-.141-.112-3.746-.008c-3.05-.006-3.766.001-3.855.041m.161-15.761a.85.85 0 0 0-.389.417c-.023.07-.034.224-.026.343.018.239.078.345.301.525l.139.112 8.729.01 8.73.009.163-.079c.193-.093.36-.332.395-.565a.75.75 0 0 0-.388-.75c-.142-.072-.227-.073-8.844-.072-7.105.002-8.721.011-8.81.05"/>
</svg>
);
export const LogoIcon = () => (
<svg
viewBox="0 0 74 24"
@@ -578,97 +634,13 @@ export const CommentIcon = () => (
</svg>
);
export const FilterIcon = () => (
export const DebugIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M4.25 5.61C6.27 8.2 10 13 10 13v6c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-6s3.72-4.8 5.74-7.39c.51-.66.04-1.61-.79-1.61H5.04c-.83 0-1.3.95-.79 1.61"
></path>
</svg>
);
export const FilterOffIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M19.79 5.61C20.3 4.95 19.83 4 19 4H6.83l7.97 7.97zM2.81 2.81 1.39 4.22 10 13v6c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-2.17l5.78 5.78 1.41-1.41z"
></path>
</svg>
);
export const OpenNewIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3z"
></path>
</svg>
);
export const ModalIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.89-2-2-2m0 14H5V8h14z"></path>
</svg>
);
export const PauseIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
);
export const ScrollToTopIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M8 12l4-4 4 4m-4-4v12"
strokeWidth="2"
stroke="currentColor"
fill="none"
d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"
/>
</svg>
);
export const SortIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4 3 L4 15 L1.5 15 L5.5 21 L9.5 15 L7 15 L7 3 Z"/>
<path d="M13 21 L13 9 L10.5 9 L14.5 3 L18.5 9 L16 9 L16 21 Z"/>
</svg>
);
export const SortArrowDownIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.5 3 L10.5 15 L8 15 L12 21 L16 15 L13.5 15 L13.5 3 Z"/>
</svg>
);
export const SortArrowUpIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.5 21 L10.5 9 L8 9 L12 3 L16 9 L13.5 9 L13.5 21 Z"/>
</svg>
);

View File

@@ -1,4 +1,5 @@
import { FC, useCallback, useEffect, createPortal } from "preact/compat";
import { JSX } from "preact/jsx-runtime";
import { CloseIcon } from "../Icons";
import Button from "../Button/Button";
import { ReactNode, MouseEvent } from "react";
@@ -9,7 +10,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import useEventListener from "../../../hooks/useEventListener";
interface ModalProps {
title?: string
title: JSX.Element | string
children: ReactNode
onClose: () => void
className?: string

View File

@@ -5,10 +5,11 @@ import { MouseEvent } from "react";
interface MultipleSelectedValueProps {
values: string[]
itemClassName?: string
onRemoveItem: (val: string) => void
}
const MultipleSelectedValue: FC<MultipleSelectedValueProps> = ({ values, onRemoveItem }) => {
const MultipleSelectedValue: FC<MultipleSelectedValueProps> = ({ values, itemClassName, onRemoveItem }) => {
const { isMobile } = useDeviceDetect();
const createHandleClick = (value: string) => (e: MouseEvent<HTMLDivElement>) => {
@@ -27,7 +28,7 @@ const MultipleSelectedValue: FC<MultipleSelectedValueProps> = ({ values, onRemov
return <>
{values.map(item => (
<div
className="vm-select-input-content__selected"
className={`vm-select-input-content__selected ${itemClassName} ${item.toLowerCase().replace(" ", "-")}`}
key={item}
>
<span>{item}</span>

View File

@@ -11,6 +11,7 @@ import useEventListener from "../../../hooks/useEventListener";
import useClickOutside from "../../../hooks/useClickOutside";
interface SelectProps {
itemClassName?: string
value: string | string[]
list: string[]
label?: string
@@ -20,6 +21,7 @@ interface SelectProps {
searchable?: boolean
autofocus?: boolean
disabled?: boolean
includeAll?: boolean
onChange: (value: string) => void
}
@@ -27,12 +29,14 @@ const Select: FC<SelectProps> = ({
value,
list,
label,
itemClassName,
placeholder,
noOptionsText,
clearable = false,
searchable = false,
autofocus,
disabled,
includeAll,
onChange
}) => {
const { isDarkTheme } = useAppState();
@@ -46,7 +50,7 @@ const Select: FC<SelectProps> = ({
const inputRef = useRef<HTMLInputElement>(null);
const isMultiple = Array.isArray(value);
const selectedValues = Array.isArray(value) ? value : undefined;
const selectedValues = Array.isArray(value) ? value.slice() : [];
const hideInput = isMobile && isMultiple && !!selectedValues?.length;
const textFieldValue = useMemo(() => {
@@ -119,6 +123,9 @@ const Select: FC<SelectProps> = ({
useEventListener("keyup", handleKeyUp);
useClickOutside(autocompleteAnchorEl, handleCloseList, wrapperRef);
includeAll && !list.includes("All") && list.push("All");
includeAll && !selectedValues?.length && selectedValues.push("All");
return (
<div
className={classNames({
@@ -135,11 +142,12 @@ const Select: FC<SelectProps> = ({
<div className="vm-select-input-content">
{!!selectedValues?.length && (
<MultipleSelectedValue
itemClassName={itemClassName}
values={selectedValues}
onRemoveItem={handleSelected}
/>
)}
{!hideInput && (
{!hideInput && !selectedValues?.length && (
<input
value={textFieldValue}
type="text"
@@ -171,9 +179,10 @@ const Select: FC<SelectProps> = ({
</div>
</div>
<Autocomplete
itemClassName={itemClassName}
label={label}
value={autocompleteValue}
options={list.map(el => ({ value: el }))}
options={list.map(l => ({ value: l }))}
anchor={autocompleteAnchorEl}
selected={selectedValues}
minLength={1}

View File

@@ -0,0 +1,17 @@
import { RuleType } from "../types";
export const RULE_TYPES: RuleType[] = [
{
id: "alerts",
title: "Alerts",
isDefault: true,
},
{
title: "Recording",
id: "recording",
},
{
title: "All",
id: "all",
},
];

View File

@@ -6,5 +6,3 @@ export enum AppType {
export const APP_TYPE = import.meta.env.VITE_APP_TYPE;
export const APP_TYPE_VM = APP_TYPE === AppType.victoriametrics;
export const APP_TYPE_ANOMALY = APP_TYPE === AppType.vmanomaly;

View File

@@ -1,13 +1,21 @@
export const darkPalette = {
"color-primary": "#589DF6",
"color-primary": "#589df6",
"color-secondary": "#316eca",
"color-error": "#e5534b",
"color-background-error": "#240705",
"color-warning": "#c69026",
"color-background-warning": "#221906",
"color-info": "#539bf5",
"color-background-info": "#021327",
"color-success": "#57ab5a",
"color-background-success": "#0e1b0e",
"color-passive": "#a7acb3",
"color-background-body": "#22272e",
"color-background-block": "#2d333b",
"color-background-tooltip": "rgba(22, 22, 22, 0.8)",
"color-background-item": "#313944",
"color-background-badge": "#4e5a6a",
"color-background-hover": "#3D4652",
"color-text": "#cdd9e5",
"color-text-secondary": "#768390",
"color-text-disabled": "#636e7b",
@@ -25,15 +33,23 @@ export const darkPalette = {
};
export const lightPalette = {
"color-primary": "#3F51B5",
"color-secondary": "#E91E63",
"color-error": "#FD080E",
"color-warning": "#FF8308",
"color-info": "#03A9F4",
"color-success": "#4CAF50",
"color-primary": "#3f51b5",
"color-secondary": "#e91e63",
"color-error": "#fd080e",
"color-background-error": "#ffd7d8",
"color-warning": "#ff8308",
"color-background-warning": "#ffd6ad",
"color-info": "#03a9f4",
"color-background-info": "#d7f2fe",
"color-success": "#4caf50",
"color-background-success": "#d4ecd5",
"color-passive": "#5d6267",
"color-background-body": "#FEFEFF",
"color-background-block": "#FFFFFF",
"color-background-tooltip": "rgba(80,80,80,0.9)",
"color-background-item": "#f8f9fa",
"color-background-badge": "#e1e4e7",
"color-background-hover": "#edf0f2",
"color-text": "#110f0f",
"color-text-secondary": "#706F6F",
"color-text-disabled": "#A09F9F",

View File

@@ -3,7 +3,7 @@ import { useEffect, useState } from "preact/compat";
import { ErrorTypes } from "../types";
import { APP_TYPE_VM } from "../constants/appType";
const useFetchFlags = () => {
const useFetchAppConfig = () => {
const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState(false);
@@ -31,5 +31,5 @@ const useFetchFlags = () => {
return { isLoading, error };
};
export default useFetchFlags;
export default useFetchAppConfig;

View File

@@ -1,45 +0,0 @@
import { useAppDispatch, useAppState } from "../state/common/StateContext";
import { useEffect, useState } from "preact/compat";
import { ErrorTypes } from "../types";
import { APP_TYPE_VM } from "../constants/appType";
import { getUrlWithoutTenant } from "../utils/tenants";
const useFetchFlags = () => {
const { serverUrl } = useAppState();
const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>("");
useEffect(() => {
const fetchFlags = async () => {
if (!serverUrl || !APP_TYPE_VM) return;
setError("");
setIsLoading(true);
try {
const url = getUrlWithoutTenant(serverUrl).replace(/\/prometheus\/?$/, "");
const response = await fetch(`${url}/flags`);
const data = await response.text();
const flags = data.split("\n").filter(flag => flag.trim() !== "")
.reduce((acc, flag) => {
const [keyRaw, valueRaw] = flag.split("=");
const key = keyRaw.trim().replace(/^-/, "");
acc[key.trim()] = valueRaw ? valueRaw.trim().replace(/^"(.*)"$/, "$1") : null;
return acc;
}, {} as Record<string, string|null>);
dispatch({ type: "SET_FLAGS", payload: flags });
} catch (e) {
setIsLoading(false);
if (e instanceof Error) setError(`${e.name}: ${e.message}`);
}
};
fetchFlags();
}, [serverUrl]);
return { isLoading, error };
};
export default useFetchFlags;

View File

@@ -7,12 +7,20 @@ const useSearchParamsFromObject = () => {
const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsFromKeys = useCallback((objectParams: Record<string, string | number>) => {
const hasSearchParams = !!Array.from(searchParams.values()).length;
const hasSearchParams = !!searchParams.size;
let hasChanged = false;
const newSearchParams = new URLSearchParams(searchParams);
searchParams.keys().forEach(key => {
if (!(key in objectParams)) {
newSearchParams.delete(key);
hasChanged = true;
}
});
Object.entries(objectParams).forEach(([key, value]) => {
if (searchParams.get(key) !== `${value}`) {
searchParams.set(key, `${value}`);
if (newSearchParams.get(key) !== `${value}`) {
newSearchParams.set(key, `${value}`);
hasChanged = true;
}
});
@@ -20,7 +28,7 @@ const useSearchParamsFromObject = () => {
if (!hasChanged) return;
if (hasSearchParams) {
setSearchParams(searchParams);
setSearchParams(newSearchParams);
} else {
navigate(`?${searchParams.toString()}`, { replace: true });
}

View File

@@ -1,12 +1,11 @@
import Header from "../Header/Header";
import { FC, useEffect } from "preact/compat";
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
import { Outlet, useSearchParams } from "react-router-dom";
import qs from "qs";
import "../MainLayout/style.scss";
import { getAppModeEnable } from "../../utils/app-mode";
import classNames from "classnames";
import Footer from "../Footer/Footer";
import { routerOptions } from "../../router";
import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import ControlsAnomalyLayout from "./ControlsAnomalyLayout";
@@ -14,17 +13,10 @@ import ControlsAnomalyLayout from "./ControlsAnomalyLayout";
const AnomalyLayout: FC = () => {
const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
const { pathname } = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
useFetchDefaultTimezone();
const setDocumentTitle = () => {
const defaultTitle = "vmui for vmanomaly";
const routeTitle = routerOptions[pathname]?.title;
document.title = routeTitle ? `${routeTitle} - ${defaultTitle}` : defaultTitle;
};
// for support old links with search params
const redirectSearchToHashParams = () => {
const { search, href } = window.location;
@@ -38,7 +30,6 @@ const AnomalyLayout: FC = () => {
if (newHref !== href) window.location.replace(newHref);
};
useEffect(setDocumentTitle, [pathname]);
useEffect(redirectSearchToHashParams, []);
return <section className="vm-container">

View File

@@ -14,7 +14,8 @@ const ControlsAnomalyLayout: FC<ControlsProps> = ({
displaySidebar,
isMobile,
headerSetup,
accountIds
accountIds,
closeModal,
}) => {
return (
@@ -28,7 +29,11 @@ const ControlsAnomalyLayout: FC<ControlsProps> = ({
{headerSetup?.stepControl && <StepConfigurator/>}
{headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
{headerSetup?.executionControls && <ExecutionControls/>}
{headerSetup?.executionControls && <ExecutionControls
tooltip={headerSetup?.executionControls?.tooltip}
useAutorefresh={headerSetup?.executionControls?.useAutorefresh}
closeModal={closeModal}
/>}
<GlobalSettings/>
{!displaySidebar && <ShortcutKeys/>}
</div>

View File

@@ -105,6 +105,7 @@ const Header: FC<HeaderProps> = ({ controlsComponent }) => {
controlsComponent={controlsComponent}
displaySidebar={displaySidebar}
isMobile={isMobile}
closeModal={() => {}}
/>
</header>;
};

View File

@@ -18,6 +18,7 @@ export interface ControlsProps {
isMobile?: boolean;
headerSetup?: RouterOptionsHeader;
accountIds?: string[];
closeModal: () => void;
}
const HeaderControls: FC<ControlsProps & HeaderProps> = ({
@@ -45,6 +46,7 @@ const HeaderControls: FC<ControlsProps & HeaderProps> = ({
isMobile={isMobile}
accountIds={accountIds}
headerSetup={headerSetup}
closeModal={handleCloseList}
/>
);

View File

@@ -14,7 +14,8 @@ const ControlsMainLayout: FC<ControlsProps> = ({
displaySidebar,
isMobile,
headerSetup,
accountIds
accountIds,
closeModal,
}) => {
return (
@@ -28,7 +29,11 @@ const ControlsMainLayout: FC<ControlsProps> = ({
{headerSetup?.stepControl && <StepConfigurator/>}
{headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
{headerSetup?.executionControls && <ExecutionControls/>}
{headerSetup?.executionControls && <ExecutionControls
tooltip={headerSetup?.executionControls?.tooltip}
useAutorefresh={headerSetup?.executionControls?.useAutorefresh}
closeModal={closeModal}
/>}
<GlobalSettings/>
{!displaySidebar && <ShortcutKeys/>}
</div>

View File

@@ -11,7 +11,6 @@ import { useFetchDashboards } from "../../pages/PredefinedPanels/hooks/useFetchD
import useDeviceDetect from "../../hooks/useDeviceDetect";
import ControlsMainLayout from "./ControlsMainLayout";
import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
import useFetchFlags from "../../hooks/useFetchFlags";
import useFetchAppConfig from "../../hooks/useFetchAppConfig";
const MainLayout: FC = () => {
@@ -23,7 +22,6 @@ const MainLayout: FC = () => {
useFetchDashboards();
useFetchDefaultTimezone();
useFetchAppConfig();
useFetchFlags();
const setDocumentTitle = () => {
const defaultTitle = "vmui";

View File

@@ -1,5 +1,5 @@
import { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
import { DownloadIcon } from "../../../components/Main/Icons";
import { DebugIcon } from "../../../components/Main/Icons";
import Button from "../../../components/Main/Button/Button";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import useBoolean from "../../../hooks/useBoolean";
@@ -217,17 +217,17 @@ const DownloadReport: FC<Props> = ({ fetchUrl, reportType = ReportType.QUERY_DAT
return (
<>
<Tooltip title={"Export query"}>
<Tooltip title={"Debug query"}>
<Button
variant="text"
startIcon={<DownloadIcon/>}
startIcon={<DebugIcon />}
onClick={toggleOpen}
ariaLabel="export query"
ariaLabel="Debug query"
/>
</Tooltip>
{openModal && (
<Modal
title={"Export query"}
title={"Debug query"}
onClose={handleClose}
isOpen={openModal}
>

View File

@@ -1,4 +1,4 @@
import { FC, useEffect, useState } from "preact/compat";
import { FC, useEffect, useState, useMemo, useRef, useCallback } from "preact/compat";
import QueryConfigurator from "./QueryConfigurator/QueryConfigurator";
import { useFetchQuery } from "../../hooks/useFetchQuery";
import { DisplayTypeSwitch } from "./DisplayTypeSwitch";
@@ -12,13 +12,17 @@ import Alert from "../../components/Main/Alert/Alert";
import classNames from "classnames";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import InstantQueryTip from "./InstantQueryTip/InstantQueryTip";
import { useRef } from "react";
import CustomPanelTraces from "./CustomPanelTraces/CustomPanelTraces";
import WarningLimitSeries from "./WarningLimitSeries/WarningLimitSeries";
import CustomPanelTabs from "./CustomPanelTabs";
import { DisplayType } from "../../types";
import DownloadReport from "./DownloadReport/DownloadReport";
import WarningHeatmapToLine from "./WarningHeatmapToLine/WarningHeatmapToLine";
import DownloadButton from "../../components/DownloadButton/DownloadButton";
import { downloadCSV, downloadJSON } from "../../utils/file";
import { convertMetricsDataToCSV } from "./utils";
type ExportFormats = "csv" | "json";
const CustomPanel: FC = () => {
useSetQueryParams();
@@ -55,6 +59,27 @@ const CustomPanel: FC = () => {
showAllSeries
});
const fileDownloaders = useMemo(() => {
const getFilename = (format: ExportFormats) => {
return `vmui_export_${query.join("_")}.${format}`;
};
return {
csv: async () => {
if(!liveData) return;
const csvData = convertMetricsDataToCSV(liveData);
downloadCSV(csvData, getFilename("csv"));
},
json: async () => {
downloadJSON(JSON.stringify(liveData), getFilename("json"));
},
};
}, [liveData, query]);
const onDownloadClick = useCallback((format?: ExportFormats) => {
format && fileDownloaders[format]();
}, [fileDownloaders]);
const showInstantQueryTip = !liveData?.length && (displayType !== DisplayType.chart);
const showError = !hideError && error;
@@ -110,7 +135,7 @@ const CustomPanel: FC = () => {
"vm-block_mobile": isMobile,
})}
>
{isLoading && <LineLoader />}
{isLoading && <LineLoader/>}
<div
className="vm-custom-panel-body-header"
ref={controlsRef}
@@ -118,7 +143,13 @@ const CustomPanel: FC = () => {
<div className="vm-custom-panel-body-header__tabs">
<DisplayTypeSwitch/>
</div>
{(graphData || liveData) && <DownloadReport fetchUrl={fetchUrl}/>}
{displayType === "table" && (
<DownloadButton
title={"Export query"}
onDownload={onDownloadClick}
downloadFormatOptions={["json", "csv"]}
/>)}
{(graphData || liveData) && displayType !== "code" && <DownloadReport fetchUrl={fetchUrl}/>}
</div>
<CustomPanelTabs
graphData={graphData}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { convertMetricsDataToCSV } from "./utils";
import { InstantMetricResult } from "../../api/types";
describe("convertMetricsDataToCSV", () => {
it("should return an empty string if headers are empty", () => {
const data: InstantMetricResult[] = [];
expect(convertMetricsDataToCSV(data)).toBe("");
});
it("should return a valid CSV string for single metric entry with value", () => {
const data: InstantMetricResult[] = [
{
value: [1623945600, "123"],
group: 0,
metric: {
header1: "123",
header2: "value2"
}
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2\n123,value2");
});
it("should return a valid CSV string for multiple metric entries with values", () => {
const data: InstantMetricResult[] = [
{
value: [1623945600, "123"],
group: 0,
metric: {
header1: "123",
header2: "value2"
}
},
{
value: [1623949200, "456"],
group: 0,
metric: {
header1: "456",
header2: "value4"
}
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2\n123,value2\n456,value4");
});
it("should handle metric entries with multiple values field", () => {
const data: InstantMetricResult[] = [
{
values: [[1623945600, "123"], [1623949200, "456"]],
group: 0,
metric: {
header1: "123-456",
header2: "values"
}
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2\n123-456,values");
});
it("should handle a combination of metric entries with value and values", () => {
const data: InstantMetricResult[] = [
{
value: [1623945600, "123"],
group: 0,
metric: {
header1: "123",
header2: "first"
}
},
{
values: [[1623949200, "456"], [1623952800, "789"]],
group: 0,
metric: {
header1: "456-789",
header2: "second"
}
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2\n123,first\n456-789,second");
});
});

View File

@@ -0,0 +1,18 @@
import { InstantMetricResult } from "../../api/types";
import { getColumns, MetricCategory } from "../../hooks/useSortedCategories";
import { formatValueToCSV } from "../../utils/csv";
const getHeaders = (data: InstantMetricResult[]): string => {
return getColumns(data).map(({ key }) => key).join(",");
};
const getRows = (data: InstantMetricResult[], headers: MetricCategory[]) => {
return data?.map(d => headers.map(c => formatValueToCSV(d.metric[c.key] || "-")).join(","));
};
export const convertMetricsDataToCSV = (data: InstantMetricResult[]): string => {
const headers = getHeaders(data);
if (!headers.length) return "";
const rows = getRows(data, getColumns(data));
return [headers, ...rows].join("\n");
};

View File

@@ -0,0 +1,62 @@
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import { useFetchItem } from "./hooks/useFetchItem";
import "./style.scss";
import { Alert as APIAlert } from "../../types";
import ItemHeader from "../../components/ExploreAlerts/ItemHeader";
import BaseAlert from "../../components/ExploreAlerts/BaseAlert";
import Modal from "../../components/Main/Modal/Modal";
interface ExploreAlertProps {
groupId: string;
id: string;
mode: string;
onClose: () => void;
}
const ExploreAlert = ({ groupId, id, mode, onClose }: ExploreAlertProps) => {
const {
item,
isLoading,
error,
} = useFetchItem<APIAlert>({ groupId, id, mode });
if (isLoading) return (
<Spinner />
);
if (error) return (
<Alert variant="error">{error}</Alert>
);
const noItemFound = `No alert with group ID=${groupId}, alert ID=${id} found!`;
const states = {
firing: 1,
};
return (
<Modal
className="vm-explore-alerts"
title={item ? (
<ItemHeader
entity="alert"
type="alerting"
groupId={item.group_id}
id={item.id}
name={item.name}
states={states}
onClose={onClose}
/>
) : "Alert not found"}
onClose={onClose}
>
<div className="vm-explore-alerts">
{item && (<BaseAlert item={item} />) || (
<Alert variant="info">{noItemFound}</Alert>
)}
</div>
</Modal>
);
};
export default ExploreAlert;

View File

@@ -0,0 +1,55 @@
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import { useFetchGroup } from "./hooks/useFetchGroup";
import "./style.scss";
import { Group as APIGroup } from "../../types";
import ItemHeader from "../../components/ExploreAlerts/ItemHeader";
import BaseGroup from "../../components/ExploreAlerts/BaseGroup";
import Modal from "../../components/Main/Modal/Modal";
interface ExploreGroupProps {
id: string;
onClose: () => void;
}
const ExploreGroup = ({ id, onClose }: ExploreGroupProps) => {
const {
group,
isLoading,
error,
} = useFetchGroup<APIGroup>({ id });
if (isLoading) return (
<Spinner />
);
if (error) return (
<Alert variant="error">{error}</Alert>
);
const noGroupFound = `No group ID=${id} found!`;
return (
<Modal
className="vm-explore-alerts"
title={group ? (
<ItemHeader
entity="group"
groupId={id}
name={group.name}
states={group.states}
onClose={onClose}
/>
) : "Rule not found"}
onClose={onClose}
>
<div className="vm-explore-alerts">
{group && (<BaseGroup group={group} />) || (
<Alert variant="info">{noGroupFound}</Alert>
)}
</div>
</Modal>
);
};
export default ExploreGroup;

View File

@@ -0,0 +1,142 @@
import { FC, useEffect, useState } from "preact/compat";
import { useLocation } from "react-router";
import { useNotifiersSetQueryParams as useSetQueryParams } from "./hooks/useSetQueryParams";
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import Accordion from "../../components/Main/Accordion/Accordion";
import { useFetchNotifiers } from "./hooks/useFetchNotifiers";
import "./style.scss";
import NotifiersHeader from "../../components/ExploreAlerts/NotifiersHeader";
import NotifierHeader from "../../components/ExploreAlerts/NotifierHeader";
import Target from "../../components/ExploreAlerts/Target";
import { Notifier as APINotifier, Target as APITarget } from "../../types";
import { getQueryStringValue } from "../../utils/query-string";
import { getChanges } from "./helpers";
import debounce from "lodash.debounce";
const defaultKindsStr = getQueryStringValue("kinds", "") as string;
const defaultKinds = defaultKindsStr.split("&").filter((rt) => rt) as string[];
const defaultSearchInput = getQueryStringValue("search", "") as string;
const ExploreNotifiers: FC = () => {
const {
notifiers,
isLoading,
error,
} = useFetchNotifiers();
const [searchInput, setSearchInput] = useState(defaultSearchInput);
const [kinds, setKinds] = useState(defaultKinds);
useSetQueryParams({
kinds: kinds.join("&"),
search: searchInput,
});
const location = useLocation();
const pageLoaded = !isLoading && !error && !!notifiers?.length;
const savedScrollTop = localStorage.getItem("scrollTop");
useEffect(() => {
if (!pageLoaded) return;
if (location.hash) {
const target = document.querySelector(location.hash);
if (target) {
let parent = target.closest("details");
while (parent) {
parent.open = true;
if (!parent?.parentElement) return;
parent = parent.parentElement.closest("details");
}
target.scrollIntoView();
}
} else {
if (savedScrollTop) {
window.scrollTo(0, parseInt(savedScrollTop));
}
const handleBeforeUnload = () => {
localStorage.setItem("scrollTop", (window.scrollY || 0).toString());
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}
}, [location, savedScrollTop, pageLoaded]);
const handleChangeSearch = (input: string) => {
if (!input) {
setSearchInput("");
} else {
setSearchInput(input);
}
};
const allKinds: Set<string> = new Set();
const filteredNotifiers: APINotifier[] = [];
notifiers.forEach((notifier) => {
const filteredTargets: APITarget[] = [];
const targets = notifier.targets || [];
targets.forEach((target) => {
allKinds.add(notifier.kind);
if (kinds?.length && !kinds.includes(notifier.kind)) return;
if (
searchInput &&
!target.address.toLowerCase().includes(searchInput.toLowerCase()) &&
!notifier.kind.toLowerCase().includes(searchInput.toLowerCase())
)
return;
filteredTargets.push(target);
});
if (filteredTargets.length) {
const n = Object.assign({}, notifier);
n.targets = filteredTargets;
filteredNotifiers.push(n);
}
});
const handleChangeKinds = (title: string) => {
setKinds(getChanges(title, kinds));
};
return (
<div className="vm-explore-alerts">
<NotifiersHeader
kinds={kinds}
allKinds={Array.from(allKinds)}
onChangeKinds={handleChangeKinds}
onChangeSearch={debounce(handleChangeSearch, 500)}
/>
{(isLoading && <Spinner />) || (error && <Alert variant="error">{error}</Alert>) || (
!filteredNotifiers.length && <Alert variant="info">No notifiers found!</Alert>
) || (
<div className="vm-explore-alerts-body">
{filteredNotifiers.map((notifier) => (
<div
key={notifier.kind}
className="vm-explore-alert-group vm-block vm-block_empty-padding"
>
<Accordion
key={`notifier-${notifier.kind}`}
id={`notifier-${notifier.kind}`}
title={<NotifierHeader notifier={notifier} />}
>
<div className="vm-explore-alerts-items">
{notifier.targets.map((target) => (
<Target
key={`target-${target.address}`}
target={target}
/>
))}
</div>
</Accordion>
</div>
))}
</div>
)}
</div>
);
};
export default ExploreNotifiers;

View File

@@ -0,0 +1,60 @@
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import { useFetchItem } from "./hooks/useFetchItem";
import "./style.scss";
import { Rule as APIRule } from "../../types";
import ItemHeader from "../../components/ExploreAlerts/ItemHeader";
import BaseRule from "../../components/ExploreAlerts/BaseRule";
import Modal from "../../components/Main/Modal/Modal";
import { getStates } from "./helpers";
interface ExploreRuleProps {
groupId: string;
id: string;
mode: string;
onClose: () => void;
}
const ExploreRule = ({ groupId, id, mode, onClose }: ExploreRuleProps) => {
const {
item,
isLoading,
error,
} = useFetchItem<APIRule>({ groupId, id, mode });
if (isLoading) return (
<Spinner />
);
if (error) return (
<Alert variant="error">{error}</Alert>
);
const noItemFound = `No rule with group ID=${groupId}, rule ID=${id} found!`;
return (
<Modal
className="vm-explore-alerts"
title={item ? (
<ItemHeader
entity="rule"
type={item.type}
groupId={item.group_id}
states={getStates(item)}
id={item.id}
name={item.name}
onClose={onClose}
/>
) : "Rule not found"}
onClose={onClose}
>
<div className="vm-explore-alerts">
{item && (<BaseRule item={item} />) || (
<Alert variant="info">{noItemFound}</Alert>
)}
</div>
</Modal>
);
};
export default ExploreRule;

View File

@@ -0,0 +1,206 @@
import { FC, useEffect, useMemo, useState, useCallback } from "preact/compat";
import { useNavigate, useLocation, useSearchParams } from "react-router";
import { useRulesSetQueryParams as useSetQueryParams } from "./hooks/useSetQueryParams";
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import Accordion from "../../components/Main/Accordion/Accordion";
import { useFetchGroups } from "./hooks/useFetchGroups";
import "./style.scss";
import RulesHeader from "../../components/ExploreAlerts/RulesHeader";
import GroupHeader from "../../components/ExploreAlerts/GroupHeader";
import Rule from "../../components/ExploreAlerts/Rule";
import ExploreRule from "../../pages/ExploreAlerts/ExploreRule";
import ExploreAlert from "../../pages/ExploreAlerts/ExploreAlert";
import ExploreGroup from "../../pages/ExploreAlerts/ExploreGroup";
import { getQueryStringValue } from "../../utils/query-string";
import { getStates, getChanges, filterGroups } from "./helpers";
import debounce from "lodash.debounce";
const defaultTypesStr = getQueryStringValue("types", "") as string;
const defaultTypes = defaultTypesStr.split("&").filter((rt) => rt) as string[];
const defaultStatesStr = getQueryStringValue("states", "") as string;
const defaultStates = defaultStatesStr.split("&").filter((s) => s) as string[];
const defaultSearchInput = getQueryStringValue("search", "") as string;
const ExploreRules: FC = () => {
const groupId = getQueryStringValue("group_id", "") as string;
const ruleId = getQueryStringValue("rule_id", "") as string;
const alertId = getQueryStringValue("alert_id", "") as string;
const [searchInput, setSearchInput] = useState(defaultSearchInput);
const [types, setTypes] = useState(defaultTypes);
const [states, setStates] = useState(defaultStates);
const [modalOpen, setModalOpen] = useState(true);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (!location.hash && groupId) {
setModalOpen(true);
} else {
setModalOpen(false);
}
}, [location.hash, groupId]);
useSetQueryParams({
types: types.join("&"),
states: states.join("&"),
search: searchInput,
group_id: groupId,
alert_id: alertId,
rule_id: ruleId,
});
const handleChangeSearch = useCallback((input: string) => {
if (!input) {
setSearchInput("");
} else {
setSearchInput(input);
}
}, [searchInput]);
const getModal = () => {
if (ruleId !== "") {
return (
<ExploreRule
groupId={groupId}
id={ruleId}
mode={ruleId !== "" ? "rule" : "alert"}
onClose={handleClose(`rule-${ruleId}`)}
/>
);
} else if (alertId !== "") {
return (
<ExploreAlert
groupId={groupId}
id={alertId}
mode={ruleId !== "" ? "rule" : "alert"}
onClose={handleClose(`alert-${alertId}`)}
/>
);
} else if (groupId !== "") {
return (
<ExploreGroup
id={groupId}
onClose={handleClose(`group-${groupId}`)}
/>
);
}
};
const handleChangeStates = useCallback((title: string) => {
setStates(getChanges(title, states));
}, [states]);
const handleChangeTypes = useCallback((title: string) => {
setTypes(getChanges(title, types));
}, [types]);
const noRuleFound = "No rules found!";
const handleClose = (id: string) => {
return () => {
const newParams = new URLSearchParams(searchParams);
newParams.delete("group_id");
newParams.delete("rule_id");
newParams.delete("alert_id");
setSearchParams(newParams);
setModalOpen(false);
navigate({
hash: `#${id}`,
});
};
};
const {
groups,
isLoading,
error,
} = useFetchGroups({ blockFetch: modalOpen });
const pageLoaded = !isLoading && !error && !!groups?.length;
const savedScrollTop = localStorage.getItem("scrollTop");
useEffect(() => {
if (!pageLoaded) return;
if (location.hash) {
const target = document.querySelector(location.hash);
if (target) {
let parent = target.closest("details");
while (parent) {
parent.open = true;
if (!parent?.parentElement) return;
parent = parent.parentElement.closest("details");
}
target.scrollIntoView();
}
} else {
if (savedScrollTop) {
window.scrollTo(0, parseInt(savedScrollTop));
}
const updateScrollPosition = () => {
localStorage.setItem("scrollTop", (window.scrollY || 0).toString());
};
window.addEventListener("scroll", updateScrollPosition);
return () => {
window.removeEventListener("scroll", updateScrollPosition);
};
}
}, [location, savedScrollTop, pageLoaded]);
const { filteredGroups, allTypes, allStates } = useMemo(
() => filterGroups(groups || [], types, states, searchInput),
[groups, types, states, searchInput]
);
return (
<>
{modalOpen && getModal()}
{(!modalOpen || !!allStates?.size) && (
<div className="vm-explore-alerts">
<RulesHeader
types={types}
allTypes={Array.from(allTypes)}
states={states}
allStates={Array.from(allStates)}
onChangeTypes={handleChangeTypes}
onChangeStates={handleChangeStates}
onChangeSearch={debounce(handleChangeSearch, 500)}
/>
{(isLoading && <Spinner />) || (error && <Alert variant="error">{error}</Alert>) || (
!filteredGroups.length && <Alert variant="info">{noRuleFound}</Alert>
) || (
<div className="vm-explore-alerts-body">
{filteredGroups.map((group) => (
<div
key={group.id}
className="vm-explore-alert-group vm-block vm-block_empty-padding"
>
<Accordion
key={`group-${group.id}`}
id={`group-${group.id}`}
title={<GroupHeader group={group} />}
>
<div className="vm-explore-alerts-items">
{group.rules.map((rule) => (
<Rule
key={`rule-${rule.id}`}
rule={rule}
states={getStates(rule)}
/>
))}
</div>
</Accordion>
</div>
))}
</div>
)}
</div>
)}
</>
);
};
export default ExploreRules;

View File

@@ -0,0 +1,88 @@
import { Rule, Group } from "../../types";
export const getChanges = (title: string, prevValues: string[]): string[] => {
if (title === "All") return [];
const newValues = new Set<string>(prevValues);
if (newValues.has(title)) {
newValues.delete(title);
} else {
newValues.add(title);
}
return Array.from(newValues);
};
export const getState = (rule: Rule) => {
let state = rule?.state || "ok";
if (rule?.health !== "ok") {
state = "unhealthy";
} else if (!rule?.lastSamples && !rule?.lastSeriesFetched) {
state = "no match";
}
return state;
};
export const getStates = (rule: Rule) => {
const output: Record<string, number> = {};
const alertsCount = rule?.alerts?.length || 0;
if (alertsCount > 0) {
rule.alerts.forEach((alert) => {
if (alert.state in output) {
output[alert.state] += 1;
} else {
output[alert.state] = 1;
}
});
} else {
output[getState(rule)] = 1;
}
return output;
};
export const filterGroups = (groups: Group[], types: string[], states: string[], searchInput: string) => {
const allTypes: Set<string> = new Set();
const allStates: Set<string> = new Set();
const filteredGroups: Group[] = [];
groups.forEach((group) => {
const filteredRules: Rule[] = [];
const statesPerGroup: Record<string, number> = {};
group.rules.forEach((rule) => {
const ruleType = rule.type.charAt(0).toUpperCase() + rule.type.slice(1);
allTypes.add(ruleType);
if (types?.length && !types.includes(ruleType)) return;
const state = getState(rule);
const stateName = state.charAt(0).toUpperCase() + state.slice(1);
allStates.add(stateName);
if (states?.length && !states.includes(stateName)) return;
if (
searchInput &&
!rule.name.toLowerCase().includes(searchInput.toLowerCase()) &&
!group.name.toLowerCase().includes(searchInput.toLowerCase()) &&
!group.file.toLowerCase().includes(searchInput.toLowerCase())
)
return;
filteredRules.push(rule);
if (state !== "no match" && state !== "unhealthy" && state !== "firing" && state !== "pending")
return;
const count = state === "firing" || state === "pending" ? rule?.alerts?.length : 1;
if (stateName in statesPerGroup) {
statesPerGroup[stateName] += count;
} else {
statesPerGroup[stateName] = count;
}
});
if (filteredRules.length) {
const g = Object.assign({}, group);
g.rules = filteredRules;
g.states = statesPerGroup;
filteredGroups.push(g);
}
});
return { filteredGroups, allTypes, allStates };
};

View File

@@ -0,0 +1,71 @@
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { getGroupUrl } from "../../../api/explore-alerts";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes } from "../../../types";
interface FetchGroupReturn<T> {
group?: T;
isLoading: boolean;
error?: ErrorTypes | string;
}
interface FetchGroupProps {
id: string;
}
export const useFetchGroup = <T>({
id,
}: FetchGroupProps): FetchGroupReturn<T> => {
const { serverUrl } = useAppState();
const { period } = useTimeState();
const [group, setGroup] = useState<T>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(
() => getGroupUrl(serverUrl, id),
[serverUrl, id],
);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
switch (response.headers.get("Content-Type")) {
case "application/json": {
const resp = await response.json();
if (response.ok) {
setGroup(resp as T);
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
break;
}
default: {
let err = await response.text();
if (err.startsWith("unsupported path requested")) {
err = `Failed to show group details. Request to ${fetchUrl} failed with error: ${err.trim()}.\nMake sure that vmalert is reachable at ${fetchUrl} and is of the same or higher version than vmselect`;
} else {
err = `${response.statusText}\r\n${err}`;
}
setError(err);
break;
}
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl, period]);
return { group, isLoading, error };
};

View File

@@ -0,0 +1,59 @@
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { getGroupsUrl } from "../../../api/explore-alerts";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes, Group } from "../../../types";
interface FetchGroupsReturn {
groups: Group[];
isLoading: boolean;
error?: ErrorTypes | string;
}
interface FetchGroupsProps {
blockFetch: boolean
}
export const useFetchGroups = ({ blockFetch }: FetchGroupsProps): FetchGroupsReturn => {
const { serverUrl } = useAppState();
const { period } = useTimeState();
const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(
() => getGroupsUrl(serverUrl),
[serverUrl],
);
const loaded = !!groups.length || !blockFetch;
useEffect(() => {
if (blockFetch) return;
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
if (response.ok) {
const data = (resp.data.groups || []) as Group[];
setGroups(data.sort((a, b) => a.name.localeCompare(b.name)));
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl, period, loaded]);
return { groups, isLoading, error };
};

View File

@@ -0,0 +1,61 @@
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { getItemUrl } from "../../../api/explore-alerts";
import { useAppState } from "../../../state/common/StateContext";
import { ErrorTypes } from "../../../types";
interface FetchItemReturn<T> {
item?: T;
isLoading: boolean;
error?: ErrorTypes | string;
}
interface FetchItemProps {
groupId: string;
id: string;
mode: string;
}
export const useFetchItem = <T>({
groupId,
id,
mode,
}: FetchItemProps): FetchItemReturn<T> => {
const { serverUrl } = useAppState();
const { period } = useTimeState();
const [item, setItem] = useState<T>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(
() => getItemUrl(serverUrl, groupId, id, mode),
[serverUrl, groupId, id, mode],
);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
if (response.ok) {
setItem(resp as T);
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl, period]);
return { item, isLoading, error };
};

View File

@@ -0,0 +1,49 @@
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { getNotifiersUrl } from "../../../api/explore-alerts";
import { useAppState } from "../../../state/common/StateContext";
import { Notifier, ErrorTypes } from "../../../types";
interface FetchNotifiersReturn {
notifiers: Notifier[];
isLoading: boolean;
error?: ErrorTypes | string;
}
export const useFetchNotifiers = (): FetchNotifiersReturn => {
const { serverUrl } = useAppState();
const { period } = useTimeState();
const [notifiers, setNotifiers] = useState<Notifier[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const fetchUrl = useMemo(() => getNotifiersUrl(serverUrl), [serverUrl]);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
if (response.ok) {
const data = (resp.data.notifiers || []) as Notifier[];
setNotifiers(data.sort((a, b) => a.kind.localeCompare(b.kind)));
setError(undefined);
} else {
setError(`${resp.errorType}\r\n${resp?.error}`);
}
} catch (e) {
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
};
fetchData().catch(console.error);
}, [fetchUrl, period]);
return { notifiers, isLoading, error };
};

View File

@@ -0,0 +1,68 @@
import { useEffect } from "react";
import { compactObject } from "../../../utils/object";
import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject";
interface rulesQueryProps {
types?: string;
states?: string;
search?: string;
rule_id: string;
group_id: string;
alert_id: string;
}
export const useRulesSetQueryParams = ({
types,
states,
search,
rule_id,
alert_id,
group_id,
}: rulesQueryProps) => {
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const setSearchParamsFromState = () => {
const params = compactObject({
types,
states,
search,
alert_id,
rule_id,
group_id,
});
setSearchParamsFromKeys(params);
};
useEffect(setSearchParamsFromState, [
types,
states,
search,
rule_id,
group_id,
alert_id,
]);
};
interface notifiersQueryProps {
kinds: string;
search: string;
}
export const useNotifiersSetQueryParams = ({
kinds,
search,
}: notifiersQueryProps) => {
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const setSearchParamsFromState = () => {
const params = compactObject({
kinds,
search,
});
setSearchParamsFromKeys(params);
};
useEffect(setSearchParamsFromState, [kinds, search]);
};

View File

@@ -0,0 +1,77 @@
@use "src/styles/variables" as *;
.vm-explore-alert-group {
width: 100%;
&:has(.vm-accordion-header_open) {
border: $border-divider;
border-radius: $border-radius-small;
}
}
.vm-explore-alerts.vm-modal {
align-items: center;
.vm-explore-rule-title {
display: flex;
align-items: center;
column-gap: $padding-tiny;
}
.vm-modal-content {
max-width: 90vw;
}
}
.vm-list-item-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: $padding-tiny;
}
.vm-explore-alerts-items {
flex-direction: column;
display: flex;
row-gap: 10px;
padding: 10px;
padding-right: 0;
}
.vm-explore-alerts-notifier {
flex-direction: column;
display: flex;
row-gap: 10px;
padding: $padding-tiny 0;
}
.vm-explore-alerts {
font-size: 12px;
.vm-modal-content-header__title {
max-width: unset;
}
.vm-accordion-header {
padding-right: 40px;
}
display: flex;
flex-direction: column;
align-items: stretch;
gap: $padding-medium;
max-width: calc(100vw - var(--scrollbar-width));
@media (max-width: 500px) {
gap: $padding-small;
}
&-body {
display: flex;
width: 100%;
font-size: 12px;
flex-direction: column;
align-items: stretch;
gap: $padding-medium;
@media (max-width: 500px) {
gap: $padding-small;
}
}
}

View File

@@ -1,13 +1,15 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
import { MetricBase, MetricResult, ExportMetricResult } from "../../../api/types";
import { ErrorTypes, SeriesLimits } from "../../../types";
import { ErrorTypes, SeriesLimits, TimeParams } from "../../../types";
import { useQueryState } from "../../../state/query/QueryStateContext";
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useAppState } from "../../../state/common/StateContext";
import { useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
import { isValidHttpUrl } from "../../../utils/url";
import { getExportDataUrl } from "../../../api/query-range";
import { getExportCSVDataUrl, getExportDataUrl, getExportJSONDataUrl } from "../../../api/query-range";
import { parseLineToJSON } from "../../../utils/json";
import { downloadCSV, downloadJSON } from "../../../utils/file";
import { useSnack } from "../../../contexts/Snackbar";
interface FetchQueryParams {
hideQuery?: number[];
@@ -16,6 +18,7 @@ interface FetchQueryParams {
interface FetchQueryReturn {
fetchUrl?: string[],
exportData: (format: ExportFormats) => void,
isLoading: boolean,
data?: MetricResult[],
error?: ErrorTypes | string,
@@ -25,11 +28,16 @@ interface FetchQueryReturn {
abortFetch: () => void
}
type ExportFormats = "csv" | "json";
type FormatDownloader = (serverUrl: string, query: string[], period: TimeParams, reduceMemUsage: boolean) => void;
type DownloadFileFormats = Record<ExportFormats, FormatDownloader>
export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams): FetchQueryReturn => {
const { query } = useQueryState();
const { period } = useTimeState();
const { displayType, reduceMemUsage, seriesLimits: stateSeriesLimits } = useCustomPanelState();
const { serverUrl } = useAppState();
const { showInfoMessage } = useSnack();
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<MetricResult[]>();
@@ -55,6 +63,35 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
}
}, [serverUrl, period, hideQuery, reduceMemUsage]);
const fileDownloaders: DownloadFileFormats = useMemo(() => {
const getFilename = (format: ExportFormats) => `vmui_export_${query.join("_")}_${period.start}_${period.end}.${format}`;
return {
csv: async () => {
const url = getExportCSVDataUrl(serverUrl, query, period, reduceMemUsage);
const response = await fetch(url);
try {
let text = await response.text();
text = "name,value,timestamp\n" + text;
downloadCSV(text, getFilename("csv"));
} catch (e) {
console.error(e);
showInfoMessage({ text: "Couldn't fetch data for CSV export. Please try again", type: "error" });
}
},
json: async () => {
const url = getExportJSONDataUrl(serverUrl, query, period, reduceMemUsage);
try {
const response = await fetch(url);
const text = await response.text();
downloadJSON(text, getFilename("json"));
} catch (e) {
console.error(e);
showInfoMessage({ text: "Couldn't fetch data for JSON export. Please try again", type: "error" });
}
}
};
}, [query, period, serverUrl, reduceMemUsage]);
const fetchData = useCallback(async ({ fetchUrl, stateSeriesLimits, showAllSeries }: {
fetchUrl: string[];
stateSeriesLimits: SeriesLimits;
@@ -144,6 +181,12 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
}
}, [displayType, hideQuery]);
const exportData = useCallback((format: ExportFormats) => {
if (error) return;
const updatedPeriod = { ...period };
fileDownloaders[format](serverUrl, query, updatedPeriod, reduceMemUsage);
}, [serverUrl, query, period, reduceMemUsage, error, fileDownloaders]);
const abortFetch = useCallback(() => {
abortControllerRef.current.abort();
setData([]);
@@ -167,5 +210,6 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
setQueryErrors,
warning,
abortFetch,
exportData
};
};

View File

@@ -1,4 +1,4 @@
import { FC, useState } from "preact/compat";
import { FC, useCallback, useState } from "preact/compat";
import LineLoader from "../../components/Main/LineLoader/LineLoader";
import { useCustomPanelState } from "../../state/customPanel/CustomPanelStateContext";
import { useQueryState } from "../../state/query/QueryStateContext";
@@ -17,7 +17,7 @@ import { DisplayType } from "../../types";
import Hyperlink from "../../components/Main/Hyperlink/Hyperlink";
import { CloseIcon } from "../../components/Main/Icons";
import Button from "../../components/Main/Button/Button";
import DownloadReport, { ReportType } from "../CustomPanel/DownloadReport/DownloadReport";
import DownloadButton from "../../components/DownloadButton/DownloadButton";
const RawSamplesLink = () => (
<Hyperlink
@@ -66,7 +66,7 @@ const RawQueryPage: FC = () => {
queryErrors,
setQueryErrors,
abortFetch,
fetchUrl,
exportData
} = useFetchExport({ hideQuery, showAllSeries });
const controlsRef = useRef<HTMLDivElement>(null);
@@ -85,6 +85,11 @@ const RawQueryPage: FC = () => {
setShowPageDescription(false);
};
const onExportClick = useCallback(async (format?: "csv" | "json") => {
if (!format) return;
exportData(format);
}, [exportData]);
return (
<div
className={classNames({
@@ -93,7 +98,7 @@ const RawQueryPage: FC = () => {
})}
>
<QueryConfigurator
label={"Time series selector"}
label="Time series selector"
queryErrors={!hideError ? queryErrors : []}
setQueryErrors={setQueryErrors}
setHideError={setHideError}
@@ -159,9 +164,10 @@ const RawQueryPage: FC = () => {
<DisplayTypeSwitch tabFilter={(tab) => (tab.value !== DisplayType.table)}/>
</div>
{data && (
<DownloadReport
fetchUrl={fetchUrl}
reportType={ReportType.RAW_DATA}
<DownloadButton
title={"Export query"}
downloadFormatOptions={["json", "csv"]}
onDownload={onExportClick}
/>
)}
</div>

View File

@@ -1,3 +1,5 @@
import { APP_TYPE, AppType } from "../constants/appType";
const router = {
home: "/",
metrics: "/metrics",
@@ -15,20 +17,27 @@ const router = {
rawQuery: "/raw-query",
downsamplingDebug: "/downsampling-filters-debug",
retentionDebug: "/retention-filters-debug",
rules: "/rules",
notifiers: "/notifiers",
};
export interface RouterOptionsHeader {
tenant?: boolean,
stepControl?: boolean,
timeSelector?: boolean,
executionControls?: boolean,
globalSettings?: boolean,
cardinalityDatePicker?: boolean
tenant?: boolean;
stepControl?: boolean;
timeSelector?: boolean;
executionControls?: ExecutionControlsProps;
globalSettings?: boolean;
cardinalityDatePicker?: boolean;
}
export interface RouterOptions {
title?: string,
header: RouterOptionsHeader
title?: string;
header: RouterOptionsHeader;
}
interface ExecutionControlsProps {
tooltip: string;
useAutorefresh: boolean;
}
const routerOptionsDefault = {
@@ -36,18 +45,33 @@ const routerOptionsDefault = {
tenant: true,
stepControl: true,
timeSelector: true,
executionControls: true,
executionControls: {
tooltip: "Refresh dashboard",
useAutorefresh: true,
}
},
};
const getDefaultOptions = (appType: AppType) => {
switch (appType) {
case AppType.vmanomaly:
return {
title: "Anomaly exploration",
...routerOptionsDefault,
};
default:
return {
title: "Query",
...routerOptionsDefault,
};
}
};
export const routerOptions: { [key: string]: RouterOptions } = {
[router.home]: {
title: "Query",
...routerOptionsDefault
},
[router.home]: getDefaultOptions(APP_TYPE),
[router.rawQuery]: {
title: "Raw query",
...routerOptionsDefault
...routerOptionsDefault,
},
[router.metrics]: {
title: "Explore Prometheus metrics",
@@ -55,65 +79,80 @@ export const routerOptions: { [key: string]: RouterOptions } = {
tenant: true,
stepControl: true,
timeSelector: true,
}
},
},
[router.cardinality]: {
title: "Explore cardinality",
header: {
tenant: true,
cardinalityDatePicker: true,
}
},
},
[router.topQueries]: {
title: "Top queries",
header: {
tenant: true,
}
},
},
[router.trace]: {
title: "Trace analyzer",
header: {}
header: {},
},
[router.queryAnalyzer]: {
title: "Query analyzer",
header: {}
header: {},
},
[router.dashboards]: {
title: "Dashboards",
...routerOptionsDefault,
},
[router.rules]: {
title: "Rules",
header: {
executionControls: {
tooltip: "Refresh alerts",
useAutorefresh: false,
}
},
},
[router.notifiers]: {
title: "Notifiers",
header: {
executionControls: {
tooltip: "Refresh notifiers",
useAutorefresh: false,
},
},
},
[router.withTemplate]: {
title: "WITH templates",
header: {}
header: {},
},
[router.relabel]: {
title: "Metric relabel debug",
header: {}
header: {},
},
[router.activeQueries]: {
title: "Active Queries",
header: {}
header: {},
},
[router.icons]: {
title: "Icons",
header: {}
},
[router.anomaly]: {
title: "Anomaly exploration",
...routerOptionsDefault
header: {},
},
[router.anomaly]: getDefaultOptions(AppType.vmanomaly),
[router.query]: {
title: "Query",
...routerOptionsDefault
...routerOptionsDefault,
},
[router.downsamplingDebug]: {
title: "Downsampling filters debug",
header: {}
header: {},
},
[router.retentionDebug]: {
title: "Retention filters debug",
header: {}
}
header: {},
},
};
export default router;

View File

@@ -1,5 +1,4 @@
import router, { routerOptions } from "./index";
import { getTenantIdFromUrl } from "../utils/tenants";
export enum NavigationItemType {
internalLink,
@@ -18,24 +17,9 @@ interface NavigationConfig {
serverUrl: string,
isEnterpriseLicense: boolean,
showPredefinedDashboards: boolean,
showAlertLink: boolean,
showAlerting: boolean,
}
/**
* Special case for alert link
*/
const getAlertLink = (url: string, showAlertLink: boolean) => {
// see more https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#vmalert
const isCluster = !!getTenantIdFromUrl(url);
const value = isCluster ? `${url}/vmalert` : url.replace(/\/prometheus$/, "/vmalert");
return {
label: "Alerts",
value,
type: NavigationItemType.externalLink,
hide: !showAlertLink,
};
};
/**
* Submenu for Tools tab
*/
@@ -58,21 +42,29 @@ const getExploreNav = () => [
{ value: router.activeQueries },
];
/**
* Submenu for Alerting tab
*/
const getAlertingNav = () => [
{ value: router.rules },
{ value: router.notifiers },
];
/**
* Default navigation menu
*/
export const getDefaultNavigation = ({
serverUrl,
isEnterpriseLicense,
showPredefinedDashboards,
showAlertLink,
showAlerting,
}: NavigationConfig): NavigationItem[] => [
{ value: router.home },
{ value: router.rawQuery },
{ label: "Explore", submenu: getExploreNav() },
{ label: "Tools", submenu: getToolsNav(isEnterpriseLicense) },
{ value: router.dashboards, hide: !showPredefinedDashboards },
getAlertLink(serverUrl, showAlertLink),
{ value: "Alerting", submenu: getAlertingNav(), hide: !showAlerting },
];
/**

Some files were not shown because too many files have changed in this diff Show More