mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-05-17 08:36:55 +03:00
Compare commits
40 Commits
weakpointe
...
vmagent-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec4c72e7a | ||
|
|
df7b752c7a | ||
|
|
6f74b139cc | ||
|
|
e49609cbc2 | ||
|
|
2e655a91bc | ||
|
|
1e927b2e53 | ||
|
|
21963a1cad | ||
|
|
87b291debe | ||
|
|
cce1cdcb6d | ||
|
|
03e003c828 | ||
|
|
ad9d11ba3f | ||
|
|
5c2ed99dab | ||
|
|
eaec80b7f3 | ||
|
|
d6ef8a807b | ||
|
|
c0318a84f0 | ||
|
|
5a056321af | ||
|
|
686289c02b | ||
|
|
9ae10247bb | ||
|
|
06ce3f1496 | ||
|
|
d0690ba15f | ||
|
|
483e00ffb9 | ||
|
|
06f969a4a7 | ||
|
|
9517f5cf1a | ||
|
|
e62e0685dc | ||
|
|
df92e617db | ||
|
|
7c0c8cc702 | ||
|
|
07291c1d62 | ||
|
|
7c0015b836 | ||
|
|
06e52a99fd | ||
|
|
f5840951a4 | ||
|
|
9ca5a8d0f4 | ||
|
|
894b22590d | ||
|
|
f85fd161e4 | ||
|
|
7d552dbd9a | ||
|
|
795c3deaee | ||
|
|
cb44353a36 | ||
|
|
7e05200c60 | ||
|
|
a2f033ce6c | ||
|
|
78b217d70c | ||
|
|
c9b23de9ce |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -83,6 +83,11 @@ var (
|
||||
"Excess series are logged and dropped. This can be useful for limiting series cardinality. See https://docs.victoriametrics.com/victoriametrics/vmagent/#cardinality-limiter")
|
||||
maxDailySeries = flag.Int("remoteWrite.maxDailySeries", 0, "The maximum number of unique series vmagent can send to remote storage systems during the last 24 hours. "+
|
||||
"Excess series are logged and dropped. This can be useful for limiting series churn rate. See https://docs.victoriametrics.com/victoriametrics/vmagent/#cardinality-limiter")
|
||||
maxHourlySeriesPerURL = flagutil.NewArrayInt("remoteWrite.urlMaxHourlySeries", 0, "The maximum number of unique series vmagent can send to remote storage systems during the last hour. "+
|
||||
"Excess series are logged and dropped. This can be useful for limiting series cardinality. See https://docs.victoriametrics.com/victoriametrics/vmagent/#cardinality-limiter")
|
||||
maxDailySeriesPerURL = flagutil.NewArrayInt("remoteWrite.urlMaxDailySeries", 0, "The maximum number of unique series vmagent can send to remote storage systems during the last 24 hours. "+
|
||||
"Excess series are logged and dropped. This can be useful for limiting series churn rate. See https://docs.victoriametrics.com/victoriametrics/vmagent/#cardinality-limiter")
|
||||
|
||||
maxIngestionRate = flag.Int("maxIngestionRate", 0, "The maximum number of samples vmagent can receive per second. Data ingestion is paused when the limit is exceeded. "+
|
||||
"By default there are no limits on samples ingestion rate. See also -remoteWrite.rateLimit")
|
||||
|
||||
@@ -836,6 +841,12 @@ type remoteWriteCtx struct {
|
||||
pss []*pendingSeries
|
||||
pssNextIdx atomic.Uint64
|
||||
|
||||
hourlySeriesLimiter *bloomfilter.Limiter
|
||||
hourlySeriesLimitRowsDropped *metrics.Counter
|
||||
|
||||
dailySeriesLimiter *bloomfilter.Limiter
|
||||
dailySeriesLimitRowsDropped *metrics.Counter
|
||||
|
||||
rowsPushedAfterRelabel *metrics.Counter
|
||||
rowsDroppedByRelabel *metrics.Counter
|
||||
|
||||
@@ -873,6 +884,30 @@ func newRemoteWriteCtx(argIdx int, remoteWriteURL *url.URL, maxInmemoryBlocks in
|
||||
return 0
|
||||
})
|
||||
|
||||
var hourlyLimiter *bloomfilter.Limiter
|
||||
maxHourlySeriesLimit := maxHourlySeriesPerURL.GetOptionalArg(argIdx)
|
||||
if maxHourlySeriesLimit > 0 {
|
||||
hourlyLimiter = bloomfilter.NewLimiter(maxHourlySeriesLimit, time.Hour)
|
||||
_ = metrics.NewGauge(fmt.Sprintf(`vmagent_remotewrite_hourly_series_limit_max_series{path=%q, url=%q}`, queuePath, sanitizedURL), func() float64 {
|
||||
return float64(hourlyLimiter.MaxItems())
|
||||
})
|
||||
_ = metrics.NewGauge(fmt.Sprintf(`vmagent_remotewrite_hourly_series_limit_current_series{path=%q, url=%q}`, queuePath, sanitizedURL), func() float64 {
|
||||
return float64(hourlyLimiter.CurrentItems())
|
||||
})
|
||||
}
|
||||
|
||||
var dailyLimiter *bloomfilter.Limiter
|
||||
maxDailySeriesLimit := maxDailySeriesPerURL.GetOptionalArg(argIdx)
|
||||
if maxHourlySeriesLimit > 0 {
|
||||
dailyLimiter = bloomfilter.NewLimiter(maxDailySeriesLimit, time.Hour)
|
||||
_ = metrics.NewGauge(fmt.Sprintf(`vmagent_remotewrite_daily_series_limit_max_series{path=%q, url=%q}`, queuePath, sanitizedURL), func() float64 {
|
||||
return float64(dailyLimiter.MaxItems())
|
||||
})
|
||||
_ = metrics.NewGauge(fmt.Sprintf(`vmagent_remotewrite_daily_series_limit_current_series{path=%q, url=%q}`, queuePath, sanitizedURL), func() float64 {
|
||||
return float64(dailyLimiter.CurrentItems())
|
||||
})
|
||||
}
|
||||
|
||||
var c *client
|
||||
switch remoteWriteURL.Scheme {
|
||||
case "http", "https":
|
||||
@@ -902,6 +937,12 @@ func newRemoteWriteCtx(argIdx int, remoteWriteURL *url.URL, maxInmemoryBlocks in
|
||||
c: c,
|
||||
pss: pss,
|
||||
|
||||
hourlySeriesLimiter: hourlyLimiter,
|
||||
hourlySeriesLimitRowsDropped: metrics.NewCounter(fmt.Sprintf(`vmagent_remotewrite_hourly_series_limit_rows_dropped_total{path=%q,url=%q}`, queuePath, sanitizedURL)),
|
||||
|
||||
dailySeriesLimiter: dailyLimiter,
|
||||
dailySeriesLimitRowsDropped: metrics.NewCounter(fmt.Sprintf(`vmagent_remotewrite_daily_series_limit_rows_dropped_total{path=%q,url=%q}`, queuePath, sanitizedURL)),
|
||||
|
||||
rowsPushedAfterRelabel: metrics.GetOrCreateCounter(fmt.Sprintf(`vmagent_remotewrite_rows_pushed_after_relabel_total{path=%q,url=%q}`, queuePath, sanitizedURL)),
|
||||
rowsDroppedByRelabel: metrics.GetOrCreateCounter(fmt.Sprintf(`vmagent_remotewrite_relabel_metrics_dropped_total{path=%q,url=%q}`, queuePath, sanitizedURL)),
|
||||
|
||||
@@ -937,6 +978,13 @@ func (rwctx *remoteWriteCtx) MustStop() {
|
||||
rwctx.fq.MustClose()
|
||||
rwctx.fq = nil
|
||||
|
||||
if rwctx.hourlySeriesLimiter != nil {
|
||||
rwctx.hourlySeriesLimiter.MustStop()
|
||||
}
|
||||
if rwctx.dailySeriesLimiter != nil {
|
||||
rwctx.dailySeriesLimiter.MustStop()
|
||||
}
|
||||
|
||||
rwctx.rowsPushedAfterRelabel = nil
|
||||
rwctx.rowsDroppedByRelabel = nil
|
||||
}
|
||||
@@ -1011,6 +1059,29 @@ func (rwctx *remoteWriteCtx) TryPushTimeSeries(tss []prompb.TimeSeries, forceDro
|
||||
return false
|
||||
}
|
||||
|
||||
func (rwctx *remoteWriteCtx) limitSeriesCardinality(tss []prompb.TimeSeries) []prompb.TimeSeries {
|
||||
if rwctx.hourlySeriesLimiter == nil && rwctx.dailySeriesLimiter == nil {
|
||||
return tss
|
||||
}
|
||||
dst := make([]prompb.TimeSeries, 0, len(tss))
|
||||
for i := range tss {
|
||||
labels := tss[i].Labels
|
||||
h := getLabelsHash(labels)
|
||||
if rwctx.hourlySeriesLimiter != nil && !rwctx.hourlySeriesLimiter.Add(h) {
|
||||
rwctx.hourlySeriesLimitRowsDropped.Add(len(tss[i].Samples))
|
||||
logSkippedSeries(labels, "-remoteWrite.maxHourlySeriesPerURL", rwctx.hourlySeriesLimiter.MaxItems())
|
||||
continue
|
||||
}
|
||||
if rwctx.dailySeriesLimiter != nil && !rwctx.dailySeriesLimiter.Add(h) {
|
||||
rwctx.dailySeriesLimitRowsDropped.Add(len(tss[i].Samples))
|
||||
logSkippedSeries(labels, "-remoteWrite.maxDailySeriesPerURL", rwctx.dailySeriesLimiter.MaxItems())
|
||||
continue
|
||||
}
|
||||
dst = append(dst, tss[i])
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
var matchIdxsPool bytesutil.ByteBufferPool
|
||||
|
||||
func dropAggregatedSeries(src []prompb.TimeSeries, matchIdxs []byte, dropInput bool) []prompb.TimeSeries {
|
||||
@@ -1068,6 +1139,9 @@ func (rwctx *remoteWriteCtx) tryPushTimeSeriesInternal(tss []prompb.TimeSeries)
|
||||
rctx.appendExtraLabels(tss, labelsGlobal)
|
||||
}
|
||||
|
||||
sortLabelsIfNeeded(tss)
|
||||
tss = rwctx.limitSeriesCardinality(tss)
|
||||
|
||||
pss := rwctx.pss
|
||||
idx := rwctx.pssNextIdx.Add(1) % uint64(len(pss))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 "" }
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1
app/vmselect/vmui/assets/index-B7vIex3g.css
Normal file
1
app/vmselect/vmui/assets/index-B7vIex3g.css
Normal file
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
205
app/vmselect/vmui/assets/index-SqjehVXD.js
Normal file
205
app/vmselect/vmui/assets/index-SqjehVXD.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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-SqjehVXD.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-B7vIex3g.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
489
app/vmui/packages/vmui/package-lock.json
generated
489
app/vmui/packages/vmui/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
|
||||
23
app/vmui/packages/vmui/src/api/explore-alerts.ts
Normal file
23
app/vmui/packages/vmui/src/api/explore-alerts.ts
Normal 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`;
|
||||
};
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(" "));
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
17
app/vmui/packages/vmui/src/constants/alerts.ts
Normal file
17
app/vmui/packages/vmui/src/constants/alerts.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -105,6 +105,7 @@ const Header: FC<HeaderProps> = ({ controlsComponent }) => {
|
||||
controlsComponent={controlsComponent}
|
||||
displaySidebar={displaySidebar}
|
||||
isMobile={isMobile}
|
||||
closeModal={() => {}}
|
||||
/>
|
||||
</header>;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
86
app/vmui/packages/vmui/src/pages/CustomPanel/utils.test.ts
Normal file
86
app/vmui/packages/vmui/src/pages/CustomPanel/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
18
app/vmui/packages/vmui/src/pages/CustomPanel/utils.ts
Normal file
18
app/vmui/packages/vmui/src/pages/CustomPanel/utils.ts
Normal 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");
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
206
app/vmui/packages/vmui/src/pages/ExploreAlerts/ExploreRules.tsx
Normal file
206
app/vmui/packages/vmui/src/pages/ExploreAlerts/ExploreRules.tsx
Normal 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;
|
||||
88
app/vmui/packages/vmui/src/pages/ExploreAlerts/helpers.ts
Normal file
88
app/vmui/packages/vmui/src/pages/ExploreAlerts/helpers.ts
Normal 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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
77
app/vmui/packages/vmui/src/pages/ExploreAlerts/style.scss
Normal file
77
app/vmui/packages/vmui/src/pages/ExploreAlerts/style.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,17 +9,17 @@ import { APP_TYPE, AppType } from "../constants/appType";
|
||||
const useNavigationMenu = () => {
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const { dashboardsSettings } = useDashboardsState();
|
||||
const { serverUrl, flags, appConfig } = useAppState();
|
||||
const { serverUrl, appConfig } = useAppState();
|
||||
const isEnterpriseLicense = appConfig.license?.type === "enterprise";
|
||||
const showAlertLink = Boolean(flags["vmalert.proxyURL"]);
|
||||
const showAlerting = appConfig?.vmalert?.enabled || false;
|
||||
const showPredefinedDashboards = Boolean(!appModeEnable && dashboardsSettings.length);
|
||||
|
||||
const navigationConfig = useMemo(() => ({
|
||||
serverUrl,
|
||||
isEnterpriseLicense,
|
||||
showAlertLink,
|
||||
showAlerting,
|
||||
showPredefinedDashboards
|
||||
}), [serverUrl, isEnterpriseLicense, showAlertLink, showPredefinedDashboards]);
|
||||
}), [serverUrl, isEnterpriseLicense, showAlerting, showPredefinedDashboards]);
|
||||
|
||||
|
||||
const menu = useMemo(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface AppState {
|
||||
tenantId: string;
|
||||
theme: Theme;
|
||||
isDarkTheme: boolean | null;
|
||||
flags: Record<string, string | null>;
|
||||
appConfig: AppConfig
|
||||
}
|
||||
|
||||
@@ -18,7 +17,6 @@ export type Action =
|
||||
| { type: "SET_SERVER", payload: string }
|
||||
| { type: "SET_THEME", payload: Theme }
|
||||
| { type: "SET_TENANT_ID", payload: string }
|
||||
| { type: "SET_FLAGS", payload: Record<string, string | null> }
|
||||
| { type: "SET_APP_CONFIG", payload: AppConfig }
|
||||
| { type: "SET_DARK_THEME" }
|
||||
|
||||
@@ -29,7 +27,6 @@ export const initialState: AppState = {
|
||||
tenantId,
|
||||
theme: (getFromStorage("THEME") || Theme.system) as Theme,
|
||||
isDarkTheme: null,
|
||||
flags: {},
|
||||
appConfig: {}
|
||||
};
|
||||
|
||||
@@ -56,11 +53,6 @@ export function reducer(state: AppState, action: Action): AppState {
|
||||
...state,
|
||||
isDarkTheme: isDarkTheme(state.theme)
|
||||
};
|
||||
case "SET_FLAGS":
|
||||
return {
|
||||
...state,
|
||||
flags: action.payload
|
||||
};
|
||||
case "SET_APP_CONFIG":
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
animation: vm-scale 150ms cubic-bezier(0.280, 0.840, 0.420, 1);
|
||||
}
|
||||
|
||||
span {
|
||||
.vm-list-item-inner {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user