From dd2e666a8d6b42426822b3eff667d252983875f8 Mon Sep 17 00:00:00 2001 From: akatsadimas Date: Thu, 7 Oct 2021 00:01:46 +0300 Subject: [PATCH 01/19] discovery/kubernetes: Warn user in case of endpoint over-capacity Signed-off-by: akatsadimas --- discovery/kubernetes/endpointslice.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/discovery/kubernetes/endpointslice.go b/discovery/kubernetes/endpointslice.go index 71d4e37761..4c5db2df29 100644 --- a/discovery/kubernetes/endpointslice.go +++ b/discovery/kubernetes/endpointslice.go @@ -322,6 +322,14 @@ func (e *EndpointSlice) buildEndpointSlice(eps *disv1beta1.EndpointSlice) *targe } } + v := eps.Labels[apiv1.EndpointsOverCapacity] + if v == "truncated" { + level.Warn(e.logger).Log("msg", "Number of endpoints in one Endpoints object truncated to 1000", "endpoint", eps.Name) + } + if v == "warning" { + level.Warn(e.logger).Log("msg", "Number of endpoints in one Endpoints object exceeds 1000", "endpoint", eps.Name) + } + // For all seen pods, check all container ports. If they were not covered // by one of the service endpoints, generate targets for them. for _, pe := range seenPods { From ee77a6212f22646445a4e9312734a5db713c9e72 Mon Sep 17 00:00:00 2001 From: akatsadimas Date: Fri, 8 Oct 2021 23:17:04 +0300 Subject: [PATCH 02/19] discovery/kubernetes: issue overcapacity warning for endpoint rather than endpointslice Signed-off-by: akatsadimas --- discovery/kubernetes/endpoints.go | 8 ++++++++ discovery/kubernetes/endpointslice.go | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/discovery/kubernetes/endpoints.go b/discovery/kubernetes/endpoints.go index 8fc158dd40..49e515a14e 100644 --- a/discovery/kubernetes/endpoints.go +++ b/discovery/kubernetes/endpoints.go @@ -308,6 +308,14 @@ func (e *Endpoints) buildEndpoints(eps *apiv1.Endpoints) *targetgroup.Group { } } + v := eps.Labels[apiv1.EndpointsOverCapacity] + if v == "truncated" { + level.Warn(e.logger).Log("msg", "Number of endpoints in one Endpoints object exceeds 1000 and has been truncated, please use \"role: endpointslice\" instead", "endpoint", eps.Name) + } + if v == "warning" { + level.Warn(e.logger).Log("msg", "Number of endpoints in one Endpoints object exceeds 1000, please use \"role: endpointslice\" instead", "endpoint", eps.Name) + } + // For all seen pods, check all container ports. If they were not covered // by one of the service endpoints, generate targets for them. for _, pe := range seenPods { diff --git a/discovery/kubernetes/endpointslice.go b/discovery/kubernetes/endpointslice.go index 4c5db2df29..71d4e37761 100644 --- a/discovery/kubernetes/endpointslice.go +++ b/discovery/kubernetes/endpointslice.go @@ -322,14 +322,6 @@ func (e *EndpointSlice) buildEndpointSlice(eps *disv1beta1.EndpointSlice) *targe } } - v := eps.Labels[apiv1.EndpointsOverCapacity] - if v == "truncated" { - level.Warn(e.logger).Log("msg", "Number of endpoints in one Endpoints object truncated to 1000", "endpoint", eps.Name) - } - if v == "warning" { - level.Warn(e.logger).Log("msg", "Number of endpoints in one Endpoints object exceeds 1000", "endpoint", eps.Name) - } - // For all seen pods, check all container ports. If they were not covered // by one of the service endpoints, generate targets for them. for _, pe := range seenPods { From d5afe0a577866c4d2ab13e809fe236b0d3d45778 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Wed, 13 Oct 2021 14:14:32 +0200 Subject: [PATCH 03/19] TSDB: Use a dedicated head chunk reference type (#9501) * Use dedicated Ref type Throughout the code base, there are reference types masked as regular integers. Let's use dedicated types. They are equivalent, but clearer semantically. This also makes it trivial to find where they are used, and from uses, find the centralized docs. Signed-off-by: Dieter Plaetinck * postpone some work until after possible return Signed-off-by: Dieter Plaetinck * clarify Signed-off-by: Dieter Plaetinck * rename feedback Signed-off-by: Dieter Plaetinck * skip header is up to caller Signed-off-by: Dieter Plaetinck --- tsdb/chunks/head_chunks.go | 58 ++++++++++++++++++--------------- tsdb/chunks/head_chunks_test.go | 23 ++++++------- tsdb/head.go | 5 +-- tsdb/head_test.go | 2 +- 4 files changed, 47 insertions(+), 41 deletions(-) diff --git a/tsdb/chunks/head_chunks.go b/tsdb/chunks/head_chunks.go index 1443e0bd49..f4ac61fcc8 100644 --- a/tsdb/chunks/head_chunks.go +++ b/tsdb/chunks/head_chunks.go @@ -70,6 +70,22 @@ const ( DefaultWriteBufferSize = 4 * 1024 * 1024 // 4 MiB. ) +// ChunkDiskMapperRef represents the location of a head chunk on disk. +// The upper 4 bytes hold the index of the head chunk file and +// the lower 4 bytes hold the byte offset in the head chunk file where the chunk starts. +type ChunkDiskMapperRef uint64 + +func newChunkDiskMapperRef(seq, offset uint64) ChunkDiskMapperRef { + return ChunkDiskMapperRef((seq << 32) | offset) +} + +func (ref ChunkDiskMapperRef) Unpack() (sgmIndex, chkStart int) { + sgmIndex = int(ref >> 32) + chkStart = int((ref << 32) >> 32) + return sgmIndex, chkStart + +} + // CorruptionErr is an error that's returned when corruption is encountered. type CorruptionErr struct { Dir string @@ -272,7 +288,7 @@ func repairLastChunkFile(files map[int]string) (_ map[int]string, returnErr erro // WriteChunk writes the chunk to the disk. // The returned chunk ref is the reference from where the chunk encoding starts for the chunk. -func (cdm *ChunkDiskMapper) WriteChunk(seriesRef uint64, mint, maxt int64, chk chunkenc.Chunk) (chkRef uint64, err error) { +func (cdm *ChunkDiskMapper) WriteChunk(seriesRef uint64, mint, maxt int64, chk chunkenc.Chunk) (chkRef ChunkDiskMapperRef, err error) { cdm.writePathMtx.Lock() defer cdm.writePathMtx.Unlock() @@ -297,9 +313,7 @@ func (cdm *ChunkDiskMapper) WriteChunk(seriesRef uint64, mint, maxt int64, chk c cdm.crc32.Reset() bytesWritten := 0 - // The upper 4 bytes are for the head chunk file index and - // the lower 4 bytes are for the head chunk file offset where to start reading this chunk. - chkRef = chunkRef(uint64(cdm.curFileSequence), uint64(cdm.curFileSize())) + chkRef = newChunkDiskMapperRef(uint64(cdm.curFileSequence), uint64(cdm.curFileSize())) binary.BigEndian.PutUint64(cdm.byteBuf[bytesWritten:], seriesRef) bytesWritten += SeriesRefSize @@ -339,10 +353,6 @@ func (cdm *ChunkDiskMapper) WriteChunk(seriesRef uint64, mint, maxt int64, chk c return chkRef, nil } -func chunkRef(seq, offset uint64) (chunkRef uint64) { - return (seq << 32) | offset -} - // shouldCutNewFile decides the cutting of a new file based on time and size retention. // Size retention: because depending on the system architecture, there is a limit on how big of a file we can m-map. // Time retention: so that we can delete old chunks with some time guarantee in low load environments. @@ -456,28 +466,22 @@ func (cdm *ChunkDiskMapper) flushBuffer() error { } // Chunk returns a chunk from a given reference. -func (cdm *ChunkDiskMapper) Chunk(ref uint64) (chunkenc.Chunk, error) { +func (cdm *ChunkDiskMapper) Chunk(ref ChunkDiskMapperRef) (chunkenc.Chunk, error) { cdm.readPathMtx.RLock() // We hold this read lock for the entire duration because if the Close() // is called, the data in the byte slice will get corrupted as the mmapped // file will be closed. defer cdm.readPathMtx.RUnlock() - var ( - // Get the upper 4 bytes. - // These contain the head chunk file index. - sgmIndex = int(ref >> 32) - // Get the lower 4 bytes. - // These contain the head chunk file offset where the chunk starts. - // We skip the series ref and the mint/maxt beforehand. - chkStart = int((ref<<32)>>32) + SeriesRefSize + (2 * MintMaxtSize) - chkCRC32 = newCRC32() - ) - if cdm.closed { return nil, ErrChunkDiskMapperClosed } + sgmIndex, chkStart := ref.Unpack() + // We skip the series ref and the mint/maxt beforehand. + chkStart += SeriesRefSize + (2 * MintMaxtSize) + chkCRC32 := newCRC32() + // If it is the current open file, then the chunks can be in the buffer too. if sgmIndex == cdm.curFileSequence { chunk := cdm.chunkBuffer.get(ref) @@ -578,7 +582,7 @@ func (cdm *ChunkDiskMapper) Chunk(ref uint64) (chunkenc.Chunk, error) { // and runs the provided function on each chunk. It returns on the first error encountered. // NOTE: This method needs to be called at least once after creating ChunkDiskMapper // to set the maxt of all the file. -func (cdm *ChunkDiskMapper) IterateAllChunks(f func(seriesRef, chunkRef uint64, mint, maxt int64, numSamples uint16) error) (err error) { +func (cdm *ChunkDiskMapper) IterateAllChunks(f func(seriesRef uint64, chunkRef ChunkDiskMapperRef, mint, maxt int64, numSamples uint16) error) (err error) { cdm.writePathMtx.Lock() defer cdm.writePathMtx.Unlock() @@ -623,7 +627,7 @@ func (cdm *ChunkDiskMapper) IterateAllChunks(f func(seriesRef, chunkRef uint64, } } chkCRC32.Reset() - chunkRef := chunkRef(uint64(segID), uint64(idx)) + chunkRef := newChunkDiskMapperRef(uint64(segID), uint64(idx)) startIdx := idx seriesRef := binary.BigEndian.Uint64(mmapFile.byteSlice.Range(idx, idx+SeriesRefSize)) @@ -826,19 +830,19 @@ const inBufferShards = 128 // 128 is a randomly chosen number. // chunkBuffer is a thread safe buffer for chunks. type chunkBuffer struct { - inBufferChunks [inBufferShards]map[uint64]chunkenc.Chunk + inBufferChunks [inBufferShards]map[ChunkDiskMapperRef]chunkenc.Chunk inBufferChunksMtxs [inBufferShards]sync.RWMutex } func newChunkBuffer() *chunkBuffer { cb := &chunkBuffer{} for i := 0; i < inBufferShards; i++ { - cb.inBufferChunks[i] = make(map[uint64]chunkenc.Chunk) + cb.inBufferChunks[i] = make(map[ChunkDiskMapperRef]chunkenc.Chunk) } return cb } -func (cb *chunkBuffer) put(ref uint64, chk chunkenc.Chunk) { +func (cb *chunkBuffer) put(ref ChunkDiskMapperRef, chk chunkenc.Chunk) { shardIdx := ref % inBufferShards cb.inBufferChunksMtxs[shardIdx].Lock() @@ -846,7 +850,7 @@ func (cb *chunkBuffer) put(ref uint64, chk chunkenc.Chunk) { cb.inBufferChunksMtxs[shardIdx].Unlock() } -func (cb *chunkBuffer) get(ref uint64) chunkenc.Chunk { +func (cb *chunkBuffer) get(ref ChunkDiskMapperRef) chunkenc.Chunk { shardIdx := ref % inBufferShards cb.inBufferChunksMtxs[shardIdx].RLock() @@ -858,7 +862,7 @@ func (cb *chunkBuffer) get(ref uint64) chunkenc.Chunk { func (cb *chunkBuffer) clear() { for i := 0; i < inBufferShards; i++ { cb.inBufferChunksMtxs[i].Lock() - cb.inBufferChunks[i] = make(map[uint64]chunkenc.Chunk) + cb.inBufferChunks[i] = make(map[ChunkDiskMapperRef]chunkenc.Chunk) cb.inBufferChunksMtxs[i].Unlock() } } diff --git a/tsdb/chunks/head_chunks_test.go b/tsdb/chunks/head_chunks_test.go index 3519439003..f1aa13cecb 100644 --- a/tsdb/chunks/head_chunks_test.go +++ b/tsdb/chunks/head_chunks_test.go @@ -38,10 +38,11 @@ func TestChunkDiskMapper_WriteChunk_Chunk_IterateChunks(t *testing.T) { chkCRC32 := newCRC32() type expectedDataType struct { - seriesRef, chunkRef uint64 - mint, maxt int64 - numSamples uint16 - chunk chunkenc.Chunk + seriesRef uint64 + chunkRef ChunkDiskMapperRef + mint, maxt int64 + numSamples uint16 + chunk chunkenc.Chunk } expectedData := []expectedDataType{} @@ -69,7 +70,7 @@ func TestChunkDiskMapper_WriteChunk_Chunk_IterateChunks(t *testing.T) { // Calculating expected bytes written on disk for first file. firstFileName = hrw.curFile.Name() - require.Equal(t, chunkRef(1, nextChunkOffset), chkRef) + require.Equal(t, newChunkDiskMapperRef(1, nextChunkOffset), chkRef) bytesWritten := 0 chkCRC32.Reset() @@ -132,7 +133,7 @@ func TestChunkDiskMapper_WriteChunk_Chunk_IterateChunks(t *testing.T) { require.NoError(t, err) idx := 0 - require.NoError(t, hrw.IterateAllChunks(func(seriesRef, chunkRef uint64, mint, maxt int64, numSamples uint16) error { + require.NoError(t, hrw.IterateAllChunks(func(seriesRef uint64, chunkRef ChunkDiskMapperRef, mint, maxt int64, numSamples uint16) error { t.Helper() expData := expectedData[idx] @@ -220,7 +221,7 @@ func TestChunkDiskMapper_Truncate(t *testing.T) { require.NoError(t, err) require.False(t, hrw.fileMaxtSet) - require.NoError(t, hrw.IterateAllChunks(func(_, _ uint64, _, _ int64, _ uint16) error { return nil })) + require.NoError(t, hrw.IterateAllChunks(func(_ uint64, _ ChunkDiskMapperRef, _, _ int64, _ uint16) error { return nil })) require.True(t, hrw.fileMaxtSet) verifyFiles([]int{3, 4, 5, 6, 7, 8}) @@ -334,7 +335,7 @@ func TestHeadReadWriter_TruncateAfterFailedIterateChunks(t *testing.T) { require.NoError(t, err) // Forcefully failing IterateAllChunks. - require.Error(t, hrw.IterateAllChunks(func(_, _ uint64, _, _ int64, _ uint16) error { + require.Error(t, hrw.IterateAllChunks(func(_ uint64, _ ChunkDiskMapperRef, _, _ int64, _ uint16) error { return errors.New("random error") })) @@ -390,7 +391,7 @@ func TestHeadReadWriter_ReadRepairOnEmptyLastFile(t *testing.T) { hrw, err = NewChunkDiskMapper(dir, chunkenc.NewPool(), DefaultWriteBufferSize) require.NoError(t, err) require.False(t, hrw.fileMaxtSet) - require.NoError(t, hrw.IterateAllChunks(func(_, _ uint64, _, _ int64, _ uint16) error { return nil })) + require.NoError(t, hrw.IterateAllChunks(func(_ uint64, _ ChunkDiskMapperRef, _, _ int64, _ uint16) error { return nil })) require.True(t, hrw.fileMaxtSet) // Removed from memory. @@ -421,7 +422,7 @@ func testChunkDiskMapper(t *testing.T) *ChunkDiskMapper { hrw, err := NewChunkDiskMapper(tmpdir, chunkenc.NewPool(), DefaultWriteBufferSize) require.NoError(t, err) require.False(t, hrw.fileMaxtSet) - require.NoError(t, hrw.IterateAllChunks(func(_, _ uint64, _, _ int64, _ uint16) error { return nil })) + require.NoError(t, hrw.IterateAllChunks(func(_ uint64, _ ChunkDiskMapperRef, _, _ int64, _ uint16) error { return nil })) require.True(t, hrw.fileMaxtSet) return hrw } @@ -437,7 +438,7 @@ func randomChunk(t *testing.T) chunkenc.Chunk { return chunk } -func createChunk(t *testing.T, idx int, hrw *ChunkDiskMapper) (seriesRef uint64, chunkRef uint64, mint, maxt int64, chunk chunkenc.Chunk) { +func createChunk(t *testing.T, idx int, hrw *ChunkDiskMapper) (seriesRef uint64, chunkRef ChunkDiskMapperRef, mint, maxt int64, chunk chunkenc.Chunk) { var err error seriesRef = uint64(rand.Int63()) mint = int64((idx)*1000 + 1) diff --git a/tsdb/head.go b/tsdb/head.go index cd0f9bd166..80edf794e0 100644 --- a/tsdb/head.go +++ b/tsdb/head.go @@ -605,7 +605,7 @@ func (h *Head) Init(minValidTime int64) error { func (h *Head) loadMmappedChunks(refSeries map[uint64]*memSeries) (map[uint64][]*mmappedChunk, error) { mmappedChunks := map[uint64][]*mmappedChunk{} - if err := h.chunkDiskMapper.IterateAllChunks(func(seriesRef, chunkRef uint64, mint, maxt int64, numSamples uint16) error { + if err := h.chunkDiskMapper.IterateAllChunks(func(seriesRef uint64, chunkRef chunks.ChunkDiskMapperRef, mint, maxt int64, numSamples uint16) error { if maxt < h.minValidTime.Load() { return nil } @@ -1563,8 +1563,9 @@ func overlapsClosedInterval(mint1, maxt1, mint2, maxt2 int64) bool { return mint1 <= maxt2 && mint2 <= maxt1 } +// mappedChunks describes chunk data on disk that can be mmapped type mmappedChunk struct { - ref uint64 + ref chunks.ChunkDiskMapperRef numSamples uint16 minTime, maxTime int64 } diff --git a/tsdb/head_test.go b/tsdb/head_test.go index e404e94f60..6043927eac 100644 --- a/tsdb/head_test.go +++ b/tsdb/head_test.go @@ -63,7 +63,7 @@ func newTestHead(t testing.TB, chunkRange int64, compressWAL bool) (*Head, *wal. h, err := NewHead(nil, nil, wlog, opts, nil) require.NoError(t, err) - require.NoError(t, h.chunkDiskMapper.IterateAllChunks(func(_, _ uint64, _, _ int64, _ uint16) error { return nil })) + require.NoError(t, h.chunkDiskMapper.IterateAllChunks(func(_ uint64, _ chunks.ChunkDiskMapperRef, _, _ int64, _ uint16) error { return nil })) t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) From e261eccb35ee5ce4f024ab079c848e39660066ed Mon Sep 17 00:00:00 2001 From: tclayton-newr <87031785+tclayton-newr@users.noreply.github.com> Date: Wed, 13 Oct 2021 18:12:54 -0400 Subject: [PATCH 04/19] added doc for changed metric name in remote write (#9480) * added doc for changed metric name in remote write Signed-off-by: Tyler Clayton --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00919b0572..940e6694d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,7 @@ This vulnerability has been reported by Aaron Devaney from MDSec. ## 2.27.0 / 2021-05-12 +* [CHANGE] Remote write: Metric `prometheus_remote_storage_samples_bytes_total` renamed to `prometheus_remote_storage_bytes_total`. #8296 * [FEATURE] Promtool: Retroactive rule evaluation functionality. #7675 * [FEATURE] Configuration: Environment variable expansion for external labels. Behind `--enable-feature=expand-external-labels` flag. #8649 * [FEATURE] TSDB: Add a flag(`--storage.tsdb.max-block-chunk-segment-size`) to control the max chunks file size of the blocks for small Prometheus instances. #8478 From fdbc40a9efcc8197a94f23f0e479b0b56e52d424 Mon Sep 17 00:00:00 2001 From: Ben Ye Date: Thu, 14 Oct 2021 02:19:00 -0700 Subject: [PATCH 05/19] Expose NewChainSampleIterator func (#9475) * expose NewChainSampleIterator func Signed-off-by: Ben Ye * add comment Signed-off-by: Ben Ye * update comments Signed-off-by: Ben Ye --- storage/merge.go | 7 +++++-- storage/merge_test.go | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/storage/merge.go b/storage/merge.go index bf81fcc6dd..2a68ad96a2 100644 --- a/storage/merge.go +++ b/storage/merge.go @@ -431,7 +431,7 @@ func ChainedSeriesMerge(series ...Series) Series { for _, s := range series { iterators = append(iterators, s.Iterator()) } - return newChainSampleIterator(iterators) + return NewChainSampleIterator(iterators) }, } } @@ -447,7 +447,10 @@ type chainSampleIterator struct { lastt int64 } -func newChainSampleIterator(iterators []chunkenc.Iterator) chunkenc.Iterator { +// NewChainSampleIterator returns a single iterator that iterates over the samples from the given iterators in a sorted +// fashion. If samples overlap, one sample from overlapped ones is kept (randomly) and all others with the same +// timestamp are dropped. +func NewChainSampleIterator(iterators []chunkenc.Iterator) chunkenc.Iterator { return &chainSampleIterator{ iterators: iterators, h: nil, diff --git a/storage/merge_test.go b/storage/merge_test.go index d44ffce7c2..23eab0f70d 100644 --- a/storage/merge_test.go +++ b/storage/merge_test.go @@ -631,7 +631,7 @@ func TestChainSampleIterator(t *testing.T) { expected: []tsdbutil.Sample{sample{0, 0}, sample{1, 1}, sample{2, 2}, sample{3, 3}}, }, } { - merged := newChainSampleIterator(tc.input) + merged := NewChainSampleIterator(tc.input) actual, err := ExpandSamples(merged, nil) require.NoError(t, err) require.Equal(t, tc.expected, actual) @@ -677,7 +677,7 @@ func TestChainSampleIteratorSeek(t *testing.T) { expected: []tsdbutil.Sample{sample{0, 0}, sample{1, 1}, sample{2, 2}, sample{3, 3}}, }, } { - merged := newChainSampleIterator(tc.input) + merged := NewChainSampleIterator(tc.input) actual := []tsdbutil.Sample{} if merged.Seek(tc.seek) { t, v := merged.At() From 55f9147b440d621066c32a3978c5360272592e33 Mon Sep 17 00:00:00 2001 From: ziollek Date: Fri, 15 Oct 2021 16:03:11 +0200 Subject: [PATCH 06/19] Add atan2 to scalar operators - issue #9485 (#9515) * Add atan2 to scalar operators Signed-off-by: Tomasz Ziolkowski --- promql/engine.go | 2 ++ promql/testdata/operators.test | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/promql/engine.go b/promql/engine.go index e5dbcd2d77..5d530f34a7 100644 --- a/promql/engine.go +++ b/promql/engine.go @@ -2085,6 +2085,8 @@ func scalarBinop(op parser.ItemType, lhs, rhs float64) float64 { return btos(lhs >= rhs) case parser.LTE: return btos(lhs <= rhs) + case parser.ATAN2: + return math.Atan2(lhs, rhs) } panic(errors.Errorf("operator %q not allowed for Scalar operations", op)) } diff --git a/promql/testdata/operators.test b/promql/testdata/operators.test index a6072eef31..7056213c9e 100644 --- a/promql/testdata/operators.test +++ b/promql/testdata/operators.test @@ -481,3 +481,9 @@ eval instant at 5m trigy atan2 trigx eval instant at 5m trigy atan2 trigNaN trigy{} NaN + +eval instant at 5m 10 atan2 20 + 0.4636476090008061 + +eval instant at 5m 10 atan2 NaN + NaN From 4e1dacf2d1ffc4848a19aebd243ddd1e9e4da6e0 Mon Sep 17 00:00:00 2001 From: Howie Date: Sat, 16 Oct 2021 02:24:55 +0800 Subject: [PATCH 07/19] fix issue #9432(uses reference to loop iterator variable ) (#9483) --- pkg/rulefmt/rulefmt.go | 2 +- pkg/rulefmt/rulefmt_test.go | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/pkg/rulefmt/rulefmt.go b/pkg/rulefmt/rulefmt.go index 62cd6de092..13fd07c225 100644 --- a/pkg/rulefmt/rulefmt.go +++ b/pkg/rulefmt/rulefmt.go @@ -83,7 +83,7 @@ func (g *RuleGroups) Validate(node ruleGroups) (errs []error) { set[g.Name] = struct{}{} for i, r := range g.Rules { - for _, node := range r.Validate() { + for _, node := range g.Rules[i].Validate() { var ruleName yaml.Node if r.Alert.Value != "" { ruleName = r.Alert diff --git a/pkg/rulefmt/rulefmt_test.go b/pkg/rulefmt/rulefmt_test.go index 6f5ce51ed7..719c01cbd5 100644 --- a/pkg/rulefmt/rulefmt_test.go +++ b/pkg/rulefmt/rulefmt_test.go @@ -156,5 +156,31 @@ groups: passed := (tst.shouldPass && len(errs) == 0) || (!tst.shouldPass && len(errs) > 0) require.True(t, passed, "Rule validation failed, rule=\n"+tst.ruleString) } - +} + +func TestUniqueErrorNodes(t *testing.T) { + group := ` +groups: +- name: example + rules: + - alert: InstanceDown + expr: up ===== 0 + for: 5m + labels: + severity: "page" + annotations: + summary: "Instance {{ $labels.instance }} down" + - alert: InstanceUp + expr: up ===== 1 + for: 5m + labels: + severity: "page" + annotations: + summary: "Instance {{ $labels.instance }} up" +` + _, errs := Parse([]byte(group)) + require.Len(t, errs, 2, "Expected two errors") + err0 := errs[0].(*Error).Err.node + err1 := errs[1].(*Error).Err.node + require.NotEqual(t, err0, err1, "Error nodes should not be the same") } From c890ea407f0cc3a100330833a83e5d1e0cd34490 Mon Sep 17 00:00:00 2001 From: Shirley Leu Date: Fri, 15 Oct 2021 20:31:03 +0200 Subject: [PATCH 08/19] Resolve conflicts between multiple exported label prefixes (#9479) Resolve conflicts between multiple exported label prefixes Signed-off-by: Shirley Leu --- scrape/scrape.go | 41 +++++++++++++++++++++-- scrape/scrape_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/scrape/scrape.go b/scrape/scrape.go index f3622cf2ea..ae2c84d8df 100644 --- a/scrape/scrape.go +++ b/scrape/scrape.go @@ -24,6 +24,7 @@ import ( "math" "net/http" "reflect" + "sort" "strconv" "sync" "time" @@ -648,15 +649,19 @@ func mutateSampleLabels(lset labels.Labels, target *Target, honor bool, rc []*re } } } else { + var conflictingExposedLabels labels.Labels for _, l := range target.Labels() { - // existingValue will be empty if l.Name doesn't exist. existingValue := lset.Get(l.Name) if existingValue != "" { - lb.Set(model.ExportedLabelPrefix+l.Name, existingValue) + conflictingExposedLabels = append(conflictingExposedLabels, labels.Label{Name: l.Name, Value: existingValue}) } // It is now safe to set the target label. lb.Set(l.Name, l.Value) } + + if len(conflictingExposedLabels) > 0 { + resolveConflictingExposedLabels(lb, conflictingExposedLabels) + } } res := lb.Labels() @@ -668,6 +673,38 @@ func mutateSampleLabels(lset labels.Labels, target *Target, honor bool, rc []*re return res } +func resolveConflictingExposedLabels(lb *labels.Builder, conflictingExposedLabels labels.Labels) { + sort.SliceStable(conflictingExposedLabels, func(i, j int) bool { + return len(conflictingExposedLabels[i].Name) < len(conflictingExposedLabels[j].Name) + }) + + allLabelNames := map[string]struct{}{} + for _, v := range lb.Labels() { + allLabelNames[v.Name] = struct{}{} + } + + resolved := createNewLabels(allLabelNames, conflictingExposedLabels, nil) + for _, l := range resolved { + lb.Set(l.Name, l.Value) + } +} + +func createNewLabels(existingNames map[string]struct{}, conflictingLabels, resolvedLabels labels.Labels) labels.Labels { + for i := 0; i < len(conflictingLabels); i++ { + newName := model.ExportedLabelPrefix + conflictingLabels[i].Name + if _, ok := existingNames[newName]; !ok { + resolvedLabels = append(resolvedLabels, labels.Label{Name: newName, Value: conflictingLabels[i].Value}) + conflictingLabels = append(conflictingLabels[:i], conflictingLabels[i+1:]...) + i-- + existingNames[newName] = struct{}{} + } else { + conflictingLabels[i] = labels.Label{Name: newName, Value: conflictingLabels[i].Value} + return createNewLabels(existingNames, conflictingLabels, resolvedLabels) + } + } + return resolvedLabels +} + func mutateReportSampleLabels(lset labels.Labels, target *Target) labels.Labels { lb := labels.NewBuilder(lset) diff --git a/scrape/scrape_test.go b/scrape/scrape_test.go index 9bff279c3d..1e0c68d6d5 100644 --- a/scrape/scrape_test.go +++ b/scrape/scrape_test.go @@ -1379,6 +1379,81 @@ func TestScrapeLoopAppend(t *testing.T) { } } +func TestScrapeLoopAppendForConflictingPrefixedLabels(t *testing.T) { + testcases := map[string]struct { + targetLabels []string + exposedLabels string + expected []string + }{ + "One target label collides with existing label": { + targetLabels: []string{"foo", "2"}, + exposedLabels: `metric{foo="1"} 0`, + expected: []string{"__name__", "metric", "exported_foo", "1", "foo", "2"}, + }, + + "One target label collides with existing label, plus target label already with prefix 'exported'": { + targetLabels: []string{"foo", "2", "exported_foo", "3"}, + exposedLabels: `metric{foo="1"} 0`, + expected: []string{"__name__", "metric", "exported_exported_foo", "1", "exported_foo", "3", "foo", "2"}, + }, + "One target label collides with existing label, plus existing label already with prefix 'exported": { + targetLabels: []string{"foo", "3"}, + exposedLabels: `metric{foo="1" exported_foo="2"} 0`, + expected: []string{"__name__", "metric", "exported_exported_foo", "1", "exported_foo", "2", "foo", "3"}, + }, + "One target label collides with existing label, both already with prefix 'exported'": { + targetLabels: []string{"exported_foo", "2"}, + exposedLabels: `metric{exported_foo="1"} 0`, + expected: []string{"__name__", "metric", "exported_exported_foo", "1", "exported_foo", "2"}, + }, + "Two target labels collide with existing labels, both with and without prefix 'exported'": { + targetLabels: []string{"foo", "3", "exported_foo", "4"}, + exposedLabels: `metric{foo="1" exported_foo="2"} 0`, + expected: []string{"__name__", "metric", "exported_exported_foo", "1", "exported_exported_exported_foo", + "2", "exported_foo", "4", "foo", "3"}, + }, + "Extreme example": { + targetLabels: []string{"foo", "0", "exported_exported_foo", "1", "exported_exported_exported_foo", "2"}, + exposedLabels: `metric{foo="3" exported_foo="4" exported_exported_exported_foo="5"} 0`, + expected: []string{ + "__name__", "metric", + "exported_exported_exported_exported_exported_foo", "5", + "exported_exported_exported_exported_foo", "3", + "exported_exported_exported_foo", "2", + "exported_exported_foo", "1", + "exported_foo", "4", + "foo", "0", + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + app := &collectResultAppender{} + sl := newScrapeLoop(context.Background(), nil, nil, nil, + func(l labels.Labels) labels.Labels { + return mutateSampleLabels(l, &Target{labels: labels.FromStrings(tc.targetLabels...)}, false, nil) + }, + nil, + func(ctx context.Context) storage.Appender { return app }, nil, 0, true, 0, nil, 0, 0, false, + ) + slApp := sl.appender(context.Background()) + _, _, _, err := sl.append(slApp, []byte(tc.exposedLabels), "", time.Date(2000, 1, 1, 1, 0, 0, 0, time.UTC)) + require.NoError(t, err) + + require.NoError(t, slApp.Commit()) + + require.Equal(t, []sample{ + { + metric: labels.FromStrings(tc.expected...), + t: timestamp.FromTime(time.Date(2000, 1, 1, 1, 0, 0, 0, time.UTC)), + v: 0, + }, + }, app.result) + }) + } +} + func TestScrapeLoopAppendCacheEntryButErrNotFound(t *testing.T) { // collectResultAppender's AddFast always returns ErrNotFound if we don't give it a next. app := &collectResultAppender{} From b8d953a5a079bf285b0aa4e557fb1adef2422703 Mon Sep 17 00:00:00 2001 From: beorn7 Date: Fri, 15 Oct 2021 21:56:48 +0200 Subject: [PATCH 09/19] scrape: Avoid creating a label map during conflict resolution This also avoids the recursive function call. I think it is quite readable. And much less code. Signed-off-by: beorn7 --- scrape/scrape.go | 46 +++++++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/scrape/scrape.go b/scrape/scrape.go index ae2c84d8df..626ffc11f5 100644 --- a/scrape/scrape.go +++ b/scrape/scrape.go @@ -641,16 +641,17 @@ func verifyLabelLimits(lset labels.Labels, limits *labelLimits) error { func mutateSampleLabels(lset labels.Labels, target *Target, honor bool, rc []*relabel.Config) labels.Labels { lb := labels.NewBuilder(lset) + targetLabels := target.Labels() if honor { - for _, l := range target.Labels() { + for _, l := range targetLabels { if !lset.Has(l.Name) { lb.Set(l.Name, l.Value) } } } else { var conflictingExposedLabels labels.Labels - for _, l := range target.Labels() { + for _, l := range targetLabels { existingValue := lset.Get(l.Name) if existingValue != "" { conflictingExposedLabels = append(conflictingExposedLabels, labels.Label{Name: l.Name, Value: existingValue}) @@ -660,7 +661,7 @@ func mutateSampleLabels(lset labels.Labels, target *Target, honor bool, rc []*re } if len(conflictingExposedLabels) > 0 { - resolveConflictingExposedLabels(lb, conflictingExposedLabels) + resolveConflictingExposedLabels(lb, lset, targetLabels, conflictingExposedLabels) } } @@ -673,36 +674,27 @@ func mutateSampleLabels(lset labels.Labels, target *Target, honor bool, rc []*re return res } -func resolveConflictingExposedLabels(lb *labels.Builder, conflictingExposedLabels labels.Labels) { +func resolveConflictingExposedLabels(lb *labels.Builder, exposedLabels, targetLabels, conflictingExposedLabels labels.Labels) { sort.SliceStable(conflictingExposedLabels, func(i, j int) bool { return len(conflictingExposedLabels[i].Name) < len(conflictingExposedLabels[j].Name) }) - allLabelNames := map[string]struct{}{} - for _, v := range lb.Labels() { - allLabelNames[v.Name] = struct{}{} - } - - resolved := createNewLabels(allLabelNames, conflictingExposedLabels, nil) - for _, l := range resolved { - lb.Set(l.Name, l.Value) - } -} - -func createNewLabels(existingNames map[string]struct{}, conflictingLabels, resolvedLabels labels.Labels) labels.Labels { - for i := 0; i < len(conflictingLabels); i++ { - newName := model.ExportedLabelPrefix + conflictingLabels[i].Name - if _, ok := existingNames[newName]; !ok { - resolvedLabels = append(resolvedLabels, labels.Label{Name: newName, Value: conflictingLabels[i].Value}) - conflictingLabels = append(conflictingLabels[:i], conflictingLabels[i+1:]...) - i-- - existingNames[newName] = struct{}{} - } else { - conflictingLabels[i] = labels.Label{Name: newName, Value: conflictingLabels[i].Value} - return createNewLabels(existingNames, conflictingLabels, resolvedLabels) + for i, l := range conflictingExposedLabels { + newName := l.Name + for { + newName = model.ExportedLabelPrefix + newName + if !exposedLabels.Has(newName) && + !targetLabels.Has(newName) && + !conflictingExposedLabels[:i].Has(newName) { + conflictingExposedLabels[i].Name = newName + break + } } } - return resolvedLabels + + for _, l := range conflictingExposedLabels { + lb.Set(l.Name, l.Value) + } } func mutateReportSampleLabels(lset labels.Labels, target *Target) labels.Labels { From 4414351576ac27754d9eec71c271171d5c020677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Szulik?= Date: Sat, 16 Oct 2021 00:41:53 +0200 Subject: [PATCH 10/19] web api: Delete unnecessary conversion. (#9517) --- web/api/v1/api.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/api/v1/api.go b/web/api/v1/api.go index 9ef2ad47ea..26ce55c8b3 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -311,8 +311,8 @@ func (api *API) Register(r *route.Router) { r.Get("/status/flags", wrap(api.serveFlags)) r.Get("/status/tsdb", wrap(api.serveTSDBStatus)) r.Get("/status/walreplay", api.serveWALReplayStatus) - r.Post("/read", api.ready(http.HandlerFunc(api.remoteRead))) - r.Post("/write", api.ready(http.HandlerFunc(api.remoteWrite))) + r.Post("/read", api.ready(api.remoteRead)) + r.Post("/write", api.ready(api.remoteWrite)) r.Get("/alerts", wrap(api.alerts)) r.Get("/rules", wrap(api.rules)) From a18224d02dedec50ff5ce0a3e76e114e20817b94 Mon Sep 17 00:00:00 2001 From: Julien Pivotto Date: Sun, 17 Oct 2021 11:46:38 +0200 Subject: [PATCH 11/19] make aggregations deterministic (#9459) * Add deterministic test for aggregations Signed-off-by: Julien Pivotto * Make aggregations deterministic Signed-off-by: Julien Pivotto * Increase testing Signed-off-by: Julien Pivotto --- promql/engine.go | 9 +++++++-- promql/testdata/aggregators.test | 10 ++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/promql/engine.go b/promql/engine.go index 5d530f34a7..bd3d836a31 100644 --- a/promql/engine.go +++ b/promql/engine.go @@ -2138,6 +2138,7 @@ type groupedAggregation struct { func (ev *evaluator) aggregation(op parser.ItemType, grouping []string, without bool, param interface{}, vec Vector, seriesHelper []EvalSeriesHelper, enh *EvalNodeHelper) Vector { result := map[uint64]*groupedAggregation{} + orderedResult := []*groupedAggregation{} var k int64 if op == parser.TOPK || op == parser.BOTTOMK { f := param.(float64) @@ -2206,12 +2207,16 @@ func (ev *evaluator) aggregation(op parser.ItemType, grouping []string, without } else { m = metric.WithLabels(grouping...) } - result[groupingKey] = &groupedAggregation{ + newAgg := &groupedAggregation{ labels: m, value: s.V, mean: s.V, groupCount: 1, } + + result[groupingKey] = newAgg + orderedResult = append(orderedResult, newAgg) + inputVecLen := int64(len(vec)) resultSize := k if k > inputVecLen { @@ -2333,7 +2338,7 @@ func (ev *evaluator) aggregation(op parser.ItemType, grouping []string, without } // Construct the result Vector from the aggregated groups. - for _, aggr := range result { + for _, aggr := range orderedResult { switch op { case parser.AVG: aggr.value = aggr.mean diff --git a/promql/testdata/aggregators.test b/promql/testdata/aggregators.test index cda2e7f4e0..aaf731f358 100644 --- a/promql/testdata/aggregators.test +++ b/promql/testdata/aggregators.test @@ -497,3 +497,13 @@ eval instant at 1m avg(data{test="-big"}) eval instant at 1m avg(data{test="bigzero"}) {} 0 + +clear + +# Test that aggregations are deterministic. +load 10s + up{job="prometheus"} 1 + up{job="prometheus2"} 1 + +eval instant at 1m count(topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without())) + {} 1 From f8372bc6b91c9ed39c177c1a73b021459f5b2f47 Mon Sep 17 00:00:00 2001 From: Julien Pivotto Date: Thu, 30 Sep 2021 23:15:36 +0200 Subject: [PATCH 12/19] backfill: Apply rule labels after query labels Fix #9419 Signed-off-by: Julien Pivotto --- cmd/promtool/rules.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/promtool/rules.go b/cmd/promtool/rules.go index 7140d8aa9b..642cc1ed0d 100644 --- a/cmd/promtool/rules.go +++ b/cmd/promtool/rules.go @@ -147,12 +147,18 @@ func (importer *ruleImporter) importRule(ctx context.Context, ruleExpr, ruleName matrix = val.(model.Matrix) for _, sample := range matrix { - lb := labels.NewBuilder(ruleLabels) + lb := labels.NewBuilder(labels.Labels{}) for name, value := range sample.Metric { lb.Set(string(name), string(value)) } + // Setting the rule labels after the output of the query, + // so they can override query output. + for _, l := range ruleLabels { + lb.Set(l.Name, l.Value) + } + lb.Set(labels.MetricName, ruleName) for _, value := range sample.Values { From 3da87d2f39b6a83a7b4f5946cb770259877dcb52 Mon Sep 17 00:00:00 2001 From: jessicagreben Date: Sat, 16 Oct 2021 08:06:05 -0700 Subject: [PATCH 13/19] add unit test to check label rule labels override Signed-off-by: jessicagreben --- cmd/promtool/rules_test.go | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/cmd/promtool/rules_test.go b/cmd/promtool/rules_test.go index 0c5d85aca1..f1f3b756e3 100644 --- a/cmd/promtool/rules_test.go +++ b/cmd/promtool/rules_test.go @@ -207,3 +207,67 @@ func createMultiRuleTestFiles(path string) error { ` return ioutil.WriteFile(path, []byte(recordingRules), 0777) } + +// TestBackfillLabels confirms that the labels in the rule file override the labels from the metrics +// received from Prometheus Query API, including the __name__ label. +func TestBackfillLabels(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "backfilldata") + require.NoError(t, err) + defer func() { + require.NoError(t, os.RemoveAll(tmpDir)) + }() + ctx := context.Background() + + start := time.Date(2009, time.November, 10, 6, 34, 0, 0, time.UTC) + mockAPISamples := []*model.SampleStream{ + { + Metric: model.Metric{"name1": "override", "__name__": "override"}, + Values: []model.SamplePair{{Timestamp: model.TimeFromUnixNano(start.UnixNano()), Value: 123}}, + }, + } + ruleImporter, err := newTestRuleImporter(ctx, start, tmpDir, mockAPISamples) + require.NoError(t, err) + + path := filepath.Join(tmpDir, "test.file") + recordingRules := `groups: +- name: group0 + rules: + - record: rule1 + expr: ruleExpr + labels: + name1: val1 +` + require.NoError(t, ioutil.WriteFile(path, []byte(recordingRules), 0777)) + errs := ruleImporter.loadGroups(ctx, []string{path}) + for _, err := range errs { + require.NoError(t, err) + } + + errs = ruleImporter.importAll(ctx) + for _, err := range errs { + require.NoError(t, err) + } + + opts := tsdb.DefaultOptions() + opts.AllowOverlappingBlocks = true + db, err := tsdb.Open(tmpDir, nil, nil, opts, nil) + require.NoError(t, err) + + q, err := db.Querier(context.Background(), math.MinInt64, math.MaxInt64) + require.NoError(t, err) + + t.Run("correct-labels", func(t *testing.T) { + selectedSeries := q.Select(false, nil, labels.MustNewMatcher(labels.MatchRegexp, "", ".*")) + for selectedSeries.Next() { + series := selectedSeries.At() + expectedLabels := labels.Labels{ + labels.Label{Name: "__name__", Value: "rule1"}, + labels.Label{Name: "name1", Value: "val1"}, + } + require.Equal(t, expectedLabels, series.Labels()) + } + require.NoError(t, selectedSeries.Err()) + require.NoError(t, q.Close()) + require.NoError(t, db.Close()) + }) +} From 60d099088692b6b5bf4b8f4e13f76bd3921da99e Mon Sep 17 00:00:00 2001 From: jessicagreben Date: Sun, 17 Oct 2021 08:24:31 -0700 Subject: [PATCH 14/19] add more explicit label values Signed-off-by: jessicagreben --- cmd/promtool/rules_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/promtool/rules_test.go b/cmd/promtool/rules_test.go index f1f3b756e3..c81caaa164 100644 --- a/cmd/promtool/rules_test.go +++ b/cmd/promtool/rules_test.go @@ -221,7 +221,7 @@ func TestBackfillLabels(t *testing.T) { start := time.Date(2009, time.November, 10, 6, 34, 0, 0, time.UTC) mockAPISamples := []*model.SampleStream{ { - Metric: model.Metric{"name1": "override", "__name__": "override"}, + Metric: model.Metric{"name1": "override-me", "__name__": "override-me-too"}, Values: []model.SamplePair{{Timestamp: model.TimeFromUnixNano(start.UnixNano()), Value: 123}}, }, } @@ -232,10 +232,10 @@ func TestBackfillLabels(t *testing.T) { recordingRules := `groups: - name: group0 rules: - - record: rule1 + - record: rulename expr: ruleExpr labels: - name1: val1 + name1: value-from-rule ` require.NoError(t, ioutil.WriteFile(path, []byte(recordingRules), 0777)) errs := ruleImporter.loadGroups(ctx, []string{path}) @@ -261,8 +261,8 @@ func TestBackfillLabels(t *testing.T) { for selectedSeries.Next() { series := selectedSeries.At() expectedLabels := labels.Labels{ - labels.Label{Name: "__name__", Value: "rule1"}, - labels.Label{Name: "name1", Value: "val1"}, + labels.Label{Name: "__name__", Value: "rulename"}, + labels.Label{Name: "name1", Value: "value-from-rule"}, } require.Equal(t, expectedLabels, series.Labels()) } From 08011925a10c14f05cce6f3204cb9a2d68d5851b Mon Sep 17 00:00:00 2001 From: Augustin Husson Date: Mon, 18 Oct 2021 12:47:31 +0200 Subject: [PATCH 15/19] update documentation around react-app (#9476) * update documentation around react-app and how to upgrade the npm dependencies Signed-off-by: Augustin Husson * wording around caution to take when updating the deps Signed-off-by: Augustin Husson * fixing the npm version to be used and explain where you should perform the npm install command Signed-off-by: Augustin Husson * simplify what is required to build prometheus from the source Signed-off-by: Augustin Husson * aligned period and removed redondant word installed Signed-off-by: Augustin Husson * set nodeJS version to be used at 16 Signed-off-by: Augustin Husson * describe manuel steps to update a dependency for the react-app Signed-off-by: Augustin Husson * rewording of the manuel step to update the dependencies Signed-off-by: Augustin Husson --- README.md | 8 +-- RELEASE.md | 21 ++----- web/ui/README.md | 116 ++++++++++++++++++++++++++++++++++--- web/ui/react-app/README.md | 83 -------------------------- 4 files changed, 116 insertions(+), 112 deletions(-) delete mode 100755 web/ui/react-app/README.md diff --git a/README.md b/README.md index a919604255..4a1aadce56 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,10 @@ Prometheus will now be reachable at http://localhost:9090/. ### Building from source -To build Prometheus from source code, first ensure that you have a working -Go environment with [version 1.14 or greater installed](https://golang.org/doc/install). -You also need [Node.js](https://nodejs.org/) and [npm](https://www.npmjs.com/) -installed in order to build the frontend assets. +To build Prometheus from source code, You need: +* Go [version 1.14 or greater](https://golang.org/doc/install). +* NodeJS [version 16 or greater](https://nodejs.org/). +* npm [version 7 or greater](https://www.npmjs.com/). You can directly use the `go` tool to download and install the `prometheus` and `promtool` binaries into your `GOPATH`: diff --git a/RELEASE.md b/RELEASE.md index 948a664279..ff89dad1e7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -95,24 +95,13 @@ git commit -m "Update dependencies" #### Updating React dependencies -Either upgrade the dependencies within their existing version constraints as specified in the `package.json` file (see https://docs.npmjs.com/files/package.json#dependencies): +The React application recently moved to a monorepo system with multiple internal npm packages. Dependency upgrades are +quite sensitive for the time being and should be done manually with caution. -``` -cd web/ui/react-app -npm update -git add package.json package-lock.json -``` +When you want to update a dependency, you have to go to every internal npm package where the dependency is used and +manually change the version. Once you have taken care of that, you need to go back to `web/ui` and run `npm install` -Or alternatively, update all dependencies to their latest major versions. This is potentially more disruptive and will require more follow-up fixes, but should be done from time to time (use your best judgement): - -``` -cd web/ui/react-app -npx npm-check-updates -u -npm install -git add package.json package-lock.json -``` - -You can find more details on managing npm dependencies and updates [in this blog post](https://www.carlrippon.com/upgrading-npm-dependencies/). +**NOTE**: We are researching ways to automate and improve this. ### 1. Prepare your release diff --git a/web/ui/README.md b/web/ui/README.md index ec7fa27d83..3441d8f26c 100644 --- a/web/ui/README.md +++ b/web/ui/README.md @@ -1,12 +1,110 @@ -The `ui` directory contains static files and templates used in the web UI. For -easier distribution they are statically compiled into the Prometheus binary -using the vfsgen library (c.f. Makefile). +## Overview -During development it is more convenient to always use the files on disk to -directly see changes without recompiling. -To make this work, remove the `builtinassets` build tag in the `flags` entry -in `.promu.yml`, and then `make build` (or build Prometheus using +The `ui` directory contains static files and templates used in the web UI. For easier distribution they are statically +compiled into the Prometheus binary using the vfsgen library (c.f. Makefile). + +During development it is more convenient to always use the files on disk to directly see changes without recompiling. To +make this work, remove the `builtinassets` build tag in the `flags` entry in `.promu.yml`, and then `make build` (or +build Prometheus using `go build ./cmd/prometheus`). -This will serve all files from your local filesystem. -This is for development purposes only. +This will serve all files from your local filesystem. This is for development purposes only. + +## React-app + +### Introduction + +The react application is a monorepo composed by multiple different npm packages. The main one is `react-app` which +contains the code of the react application. + +Then you have different npm packages located in the folder `modules`. These packages are supposed to be used by the +react-app and also by others consumers (like Thanos) + +### Pre-requisite + +To be able to build the react application you need: + +* npm >= v7 +* node >= v16 + +### Installing npm dependencies + +The React UI depends on a large number of [npm](https://www.npmjs.com/) packages. These are not checked in, so you will +need to move to the directory `web/ui` and then download and install them locally via the npm package manager: + + npm install + +npm consults the `package.json` and `package-lock.json` files for dependencies to install. It creates a `node_modules` +directory with all installed dependencies. + +**NOTE**: Do not run `npm install` in the `react-app` folder or in any sub folder of the `module` directory. + +### Upgrading npm dependencies + +As it is a monorepo, when upgrading a dependency, you have to upgrade it in every packages that composed this monorepo ( +aka, in all sub folder of `module` and in `react-app`) + +Then you have to run the command `npm install` in `web/ui` and not in a sub folder / sub package. It won't simply work. + +### Running a local development server + +You can start a development server for the React UI outside of a running Prometheus server by running: + + npm start + +This will open a browser window with the React app running on http://localhost:3000/. The page will reload if you make +edits to the source code. You will also see any lint errors in the console. + +**NOTE**: It will reload only if you change the code in `react-app` folder. Any code changes in the folder `module` is +not considered by the command `npm start`. In order to see the changes in the react-app you will have to +run `npm run build:module` + +Due to a `"proxy": "http://localhost:9090"` setting in the `package.json` file, any API requests from the React UI are +proxied to `localhost` on port `9090` by the development server. This allows you to run a normal Prometheus server to +handle API requests, while iterating separately on the UI. + + [browser] ----> [localhost:3000 (dev server)] --(proxy API requests)--> [localhost:9090 (Prometheus)] + +### Running tests + +To run the test for the react-app and for all modules, you can simply run: + +```bash +npm test +``` + +if you want to run the test only for a specific module, you need to go to the folder of the module and run +again `npm test`. + +For example, in case you only want to run the test of the react-app, go to `web/ui/react-app` and run `npm test` + +To generate an HTML-based test coverage report, run: + + CI=true npm test:coverage + +This creates a `coverage` subdirectory with the generated report. Open `coverage/lcov-report/index.html` in the browser +to view it. + +The `CI=true` environment variable prevents the tests from being run in interactive / watching mode. + +See the [Create React App documentation](https://create-react-app.dev/docs/running-tests/) for more information about +running tests. + +### Building the app for production + +To build a production-optimized version of the React app to a `build` subdirectory, run: + + npm run build + +**NOTE:** You will likely not need to do this directly. Instead, this is taken care of by the `build` target in the main +Prometheus `Makefile` when building the full binary. + +### Integration into Prometheus + +To build a Prometheus binary that includes a compiled-in version of the production build of the React app, change to the +root of the repository and run: + + make build + +This installs dependencies via npm, builds a production build of the React app, and then finally compiles in all web +assets into the Prometheus binary. diff --git a/web/ui/react-app/README.md b/web/ui/react-app/README.md deleted file mode 100755 index 9fbe167413..0000000000 --- a/web/ui/react-app/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Working with the React UI - -This file explains how to work with the React-based Prometheus UI. - -## Introduction - -The [React-based](https://reactjs.org/) Prometheus UI was bootstrapped using [Create React App](https://github.com/facebook/create-react-app), a popular toolkit for generating React application setups. You can find general information about Create React App on [their documentation site](https://create-react-app.dev/). - -Instead of plain JavaScript, we use [TypeScript](https://www.typescriptlang.org/) to ensure typed code. - -## Development environment - -To work with the React UI code, you will need to have the following tools installed: - -* The [Node.js](https://nodejs.org/) JavaScript runtime. -* The [npm](https://www.npmjs.com/) package manager. Once you installed Node, npm should already be available. -* *Recommended:* An editor with TypeScript, React, and [ESLint](https://eslint.org/) linting support. See e.g. [Create React App's editor setup instructions](https://create-react-app.dev/docs/setting-up-your-editor/). If you are not sure which editor to use, we recommend using [Visual Studio Code](https://code.visualstudio.com/docs/languages/typescript). Make sure that [the editor uses the project's TypeScript version rather than its own](https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-the-workspace-version-of-typescript). - -**NOTE**: When using Visual Studio Code, be sure to open the `web/ui/react-app` directory in the editor instead of the root of the repository. This way, the right ESLint and TypeScript configuration will be picked up from the React workspace. - -## Installing npm dependencies - -The React UI depends on a large number of [npm](https://www.npmjs.com/) packages. These are not checked in, so you will need to download and install them locally via the npm package manager: - - npm install - -npm consults the `package.json` and `package-lock.json` files for dependencies to install. It creates a `node_modules` directory with all installed dependencies. - -**NOTE**: Remember to change directory to `web/ui/react-app` before running this command and the following commands. - -## Running a local development server - -You can start a development server for the React UI outside of a running Prometheus server by running: - - npm start - -This will open a browser window with the React app running on http://localhost:3000/. The page will reload if you make edits to the source code. You will also see any lint errors in the console. - -Due to a `"proxy": "http://localhost:9090"` setting in the `package.json` file, any API requests from the React UI are proxied to `localhost` on port `9090` by the development server. This allows you to run a normal Prometheus server to handle API requests, while iterating separately on the UI. - - [browser] ----> [localhost:3000 (dev server)] --(proxy API requests)--> [localhost:9090 (Prometheus)] - -## Running tests - -Create React App uses the [Jest](https://jestjs.io/) framework for running tests. To run tests in interactive watch mode: - - npm test - -To generate an HTML-based test coverage report, run: - - CI=true npm test --coverage - -This creates a `coverage` subdirectory with the generated report. Open `coverage/lcov-report/index.html` in the browser to view it. - -The `CI=true` environment variable prevents the tests from being run in interactive / watching mode. - -See the [Create React App documentation](https://create-react-app.dev/docs/running-tests/) for more information about running tests. - -## Linting - -We define linting rules for the [ESLint](https://eslint.org/) linter. We recommend integrating automated linting and fixing into your editor (e.g. upon save), but you can also run the linter separately from the command-line. - -To detect and automatically fix lint errors, run: - - npm run lint - -This is also available via the `react-app-lint-fix` target in the main Prometheus `Makefile`. - -## Building the app for production - -To build a production-optimized version of the React app to a `build` subdirectory, run: - - npm run build - -**NOTE:** You will likely not need to do this directly. Instead, this is taken care of by the `build` target in the main Prometheus `Makefile` when building the full binary. - -## Integration into Prometheus - -To build a Prometheus binary that includes a compiled-in version of the production build of the React app, change to the root of the repository and run: - - make build - -This installs dependencies via npm, builds a production build of the React app, and then finally compiles in all web assets into the Prometheus binary. From 703d9bcd56e69b0c686be335295acc66948ab3f1 Mon Sep 17 00:00:00 2001 From: Augustin Husson Date: Mon, 18 Oct 2021 16:41:32 +0200 Subject: [PATCH 16/19] prepare the changelog for a next release of codemirror-promql (#9492) Signed-off-by: Augustin Husson --- web/ui/module/codemirror-promql/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web/ui/module/codemirror-promql/CHANGELOG.md b/web/ui/module/codemirror-promql/CHANGELOG.md index 7b1624213a..6c00850181 100644 --- a/web/ui/module/codemirror-promql/CHANGELOG.md +++ b/web/ui/module/codemirror-promql/CHANGELOG.md @@ -1,3 +1,14 @@ +0.18.0 / 2021-10-20 +=================== + +* **[Feature]**: Allow overriding the API prefix used to contact a remote Prometheus. +* **[Feature]**: Add linter and autocompletion support for trigonometric functions (like `sin`, `cos`) +* **[BreakingChange]**: The lib is now exposed under the `dist` folder. When importing `codemirror-promql`, it means you +will need to add `dist` in the import. For example `import { newCompleteStrategy } from 'codemirror-promql/cjs/complete';` +becomes `import { newCompleteStrategy } from 'codemirror-promql/dist/cjs/complete';` +* **[BreakingChange]**: lezer-promql has been migrated into codemirror-promql in the `grammar` folder +* **[BreakingChange]**: Support last version of Codemirror.next (v0.19.0). + 0.17.0 / 2021-08-10 =================== From a4ad2909878a4aad20ddb9e24425cdf8e565008b Mon Sep 17 00:00:00 2001 From: Augustin Husson Date: Mon, 18 Oct 2021 17:22:23 +0200 Subject: [PATCH 17/19] remove old promql editor (#9452) * remove old promql editor Signed-off-by: Augustin Husson * rename CMExpression by Expression Signed-off-by: Augustin Husson --- .../pages/graph/CMExpressionInput.test.tsx | 69 --- .../src/pages/graph/CMExpressionInput.tsx | 249 ---------- .../src/pages/graph/ExpressionInput.test.tsx | 229 +-------- .../src/pages/graph/ExpressionInput.tsx | 440 +++++++++--------- .../react-app/src/pages/graph/Panel.test.tsx | 12 - web/ui/react-app/src/pages/graph/Panel.tsx | 36 +- .../src/pages/graph/PanelList.test.tsx | 1 - .../react-app/src/pages/graph/PanelList.tsx | 49 +- 8 files changed, 263 insertions(+), 822 deletions(-) delete mode 100644 web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx delete mode 100644 web/ui/react-app/src/pages/graph/CMExpressionInput.tsx diff --git a/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx b/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx deleted file mode 100644 index 9d46b9e2c4..0000000000 --- a/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import * as React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import CMExpressionInput from './CMExpressionInput'; -import { Button, InputGroup, InputGroupAddon } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; - -describe('CMExpressionInput', () => { - const expressionInputProps = { - value: 'node_cpu', - queryHistory: [], - metricNames: [], - executeQuery: (): void => { - // Do nothing. - }, - onExpressionChange: (): void => { - // Do nothing. - }, - loading: false, - enableAutocomplete: true, - enableHighlighting: true, - enableLinter: true, - }; - - let expressionInput: ReactWrapper; - beforeEach(() => { - expressionInput = mount(); - }); - - it('renders an InputGroup', () => { - const inputGroup = expressionInput.find(InputGroup); - expect(inputGroup.prop('className')).toEqual('expression-input'); - }); - - it('renders a search icon when it is not loading', () => { - const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'prepend'); - const icon = addon.find(FontAwesomeIcon); - expect(icon.prop('icon')).toEqual(faSearch); - }); - - it('renders a loading icon when it is loading', () => { - const expressionInput = mount(); - const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'prepend'); - const icon = addon.find(FontAwesomeIcon); - expect(icon.prop('icon')).toEqual(faSpinner); - expect(icon.prop('spin')).toBe(true); - }); - - it('renders a CodeMirror expression input', () => { - const input = expressionInput.find('div.cm-expression-input'); - expect(input.text()).toContain('node_cpu'); - }); - - it('renders an execute button', () => { - const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'append'); - const button = addon.find(Button).find('.execute-btn').first(); - expect(button.prop('color')).toEqual('primary'); - expect(button.text()).toEqual('Execute'); - }); - - it('executes the query when clicking the execute button', () => { - const spyExecuteQuery = jest.fn(); - const props = { ...expressionInputProps, executeQuery: spyExecuteQuery }; - const wrapper = mount(); - const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn')); - btn.simulate('click'); - expect(spyExecuteQuery).toHaveBeenCalledTimes(1); - }); -}); diff --git a/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx b/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx deleted file mode 100644 index d85feb2635..0000000000 --- a/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React, { FC, useState, useEffect, useRef } from 'react'; -import { Button, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; - -import { EditorView, highlightSpecialChars, keymap, ViewUpdate, placeholder } from '@codemirror/view'; -import { EditorState, Prec, Compartment } from '@codemirror/state'; -import { indentOnInput, syntaxTree } from '@codemirror/language'; -import { history, historyKeymap } from '@codemirror/history'; -import { defaultKeymap, insertNewlineAndIndent } from '@codemirror/commands'; -import { bracketMatching } from '@codemirror/matchbrackets'; -import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; -import { highlightSelectionMatches } from '@codemirror/search'; -import { commentKeymap } from '@codemirror/comment'; -import { lintKeymap } from '@codemirror/lint'; -import { autocompletion, completionKeymap, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import { baseTheme, lightTheme, darkTheme, promqlHighlighter } from './CMTheme'; - -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons'; -import MetricsExplorer from './MetricsExplorer'; -import { usePathPrefix } from '../../contexts/PathPrefixContext'; -import { useTheme } from '../../contexts/ThemeContext'; -import { CompleteStrategy, PromQLExtension } from 'codemirror-promql'; -import { newCompleteStrategy } from 'codemirror-promql/dist/cjs/complete'; - -const promqlExtension = new PromQLExtension(); - -interface CMExpressionInputProps { - value: string; - onExpressionChange: (expr: string) => void; - queryHistory: string[]; - metricNames: string[]; - executeQuery: () => void; - loading: boolean; - enableAutocomplete: boolean; - enableHighlighting: boolean; - enableLinter: boolean; -} - -const dynamicConfigCompartment = new Compartment(); - -// Autocompletion strategy that wraps the main one and enriches -// it with past query items. -export class HistoryCompleteStrategy implements CompleteStrategy { - private complete: CompleteStrategy; - private queryHistory: string[]; - constructor(complete: CompleteStrategy, queryHistory: string[]) { - this.complete = complete; - this.queryHistory = queryHistory; - } - - promQL(context: CompletionContext): Promise | CompletionResult | null { - return Promise.resolve(this.complete.promQL(context)).then((res) => { - const { state, pos } = context; - const tree = syntaxTree(state).resolve(pos, -1); - const start = res != null ? res.from : tree.from; - - if (start !== 0) { - return res; - } - - const historyItems: CompletionResult = { - from: start, - to: pos, - options: this.queryHistory.map((q) => ({ - label: q.length < 80 ? q : q.slice(0, 76).concat('...'), - detail: 'past query', - apply: q, - info: q.length < 80 ? undefined : q, - })), - span: /^[a-zA-Z0-9_:]+$/, - }; - - if (res !== null) { - historyItems.options = historyItems.options.concat(res.options); - } - return historyItems; - }); - } -} - -const CMExpressionInput: FC = ({ - value, - onExpressionChange, - queryHistory, - metricNames, - executeQuery, - loading, - enableAutocomplete, - enableHighlighting, - enableLinter, -}) => { - const containerRef = useRef(null); - const viewRef = useRef(null); - const [showMetricsExplorer, setShowMetricsExplorer] = useState(false); - const pathPrefix = usePathPrefix(); - const { theme } = useTheme(); - - // (Re)initialize editor based on settings / setting changes. - useEffect(() => { - // Build the dynamic part of the config. - promqlExtension - .activateCompletion(enableAutocomplete) - .activateLinter(enableLinter) - .setComplete({ - completeStrategy: new HistoryCompleteStrategy( - newCompleteStrategy({ - remote: { url: pathPrefix, cache: { initialMetricList: metricNames } }, - }), - queryHistory - ), - }); - const dynamicConfig = [ - enableHighlighting ? promqlHighlighter : [], - promqlExtension.asExtension(), - theme === 'dark' ? darkTheme : lightTheme, - ]; - - // Create or reconfigure the editor. - const view = viewRef.current; - if (view === null) { - // If the editor does not exist yet, create it. - if (!containerRef.current) { - throw new Error('expected CodeMirror container element to exist'); - } - - const startState = EditorState.create({ - doc: value, - extensions: [ - baseTheme, - highlightSpecialChars(), - history(), - EditorState.allowMultipleSelections.of(true), - indentOnInput(), - bracketMatching(), - closeBrackets(), - autocompletion(), - highlightSelectionMatches(), - EditorView.lineWrapping, - keymap.of([ - ...closeBracketsKeymap, - ...defaultKeymap, - ...historyKeymap, - ...commentKeymap, - ...completionKeymap, - ...lintKeymap, - ]), - placeholder('Expression (press Shift+Enter for newlines)'), - dynamicConfigCompartment.of(dynamicConfig), - // This keymap is added without precedence so that closing the autocomplete dropdown - // via Escape works without blurring the editor. - keymap.of([ - { - key: 'Escape', - run: (v: EditorView): boolean => { - v.contentDOM.blur(); - return false; - }, - }, - ]), - Prec.override( - keymap.of([ - { - key: 'Enter', - run: (v: EditorView): boolean => { - executeQuery(); - return true; - }, - }, - { - key: 'Shift-Enter', - run: insertNewlineAndIndent, - }, - ]) - ), - EditorView.updateListener.of((update: ViewUpdate): void => { - onExpressionChange(update.state.doc.toString()); - }), - ], - }); - - const view = new EditorView({ - state: startState, - parent: containerRef.current, - }); - - viewRef.current = view; - - view.focus(); - } else { - // The editor already exists, just reconfigure the dynamically configured parts. - view.dispatch( - view.state.update({ - effects: dynamicConfigCompartment.reconfigure(dynamicConfig), - }) - ); - } - // "value" is only used in the initial render, so we don't want to - // re-run this effect every time that "value" changes. - // - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enableAutocomplete, enableHighlighting, enableLinter, executeQuery, onExpressionChange, queryHistory, theme]); - - const insertAtCursor = (value: string) => { - const view = viewRef.current; - if (view === null) { - return; - } - const { from, to } = view.state.selection.ranges[0]; - view.dispatch( - view.state.update({ - changes: { from, to, insert: value }, - }) - ); - }; - - return ( - <> - - - - {loading ? : } - - -
- - - - - - - - - ); -}; - -export default CMExpressionInput; diff --git a/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx b/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx index f78a2eb7df..29a98c308e 100644 --- a/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx +++ b/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx @@ -1,26 +1,15 @@ import * as React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import ExpressionInput from './ExpressionInput'; -import Downshift from 'downshift'; -import { Button, InputGroup, InputGroupAddon, Input } from 'reactstrap'; +import { Button, InputGroup, InputGroupAddon } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; -const getKeyEvent = (key: string): React.KeyboardEvent => - ({ - key, - nativeEvent: {}, - preventDefault: () => { - // Do nothing. - }, - } as React.KeyboardEvent); - describe('ExpressionInput', () => { - const metricNames = ['instance:node_cpu_utilisation:rate1m', 'node_cpu_guest_seconds_total', 'node_cpu_seconds_total']; const expressionInputProps = { value: 'node_cpu', queryHistory: [], - metricNames, + metricNames: [], executeQuery: (): void => { // Do nothing. }, @@ -29,6 +18,8 @@ describe('ExpressionInput', () => { }, loading: false, enableAutocomplete: true, + enableHighlighting: true, + enableLinter: true, }; let expressionInput: ReactWrapper; @@ -36,11 +27,6 @@ describe('ExpressionInput', () => { expressionInput = mount(); }); - it('renders a downshift component', () => { - const downshift = expressionInput.find(Downshift); - expect(downshift).toHaveLength(1); - }); - it('renders an InputGroup', () => { const inputGroup = expressionInput.find(InputGroup); expect(inputGroup.prop('className')).toEqual('expression-input'); @@ -60,205 +46,24 @@ describe('ExpressionInput', () => { expect(icon.prop('spin')).toBe(true); }); - it('renders an Input', () => { - const input = expressionInput.find(Input); - expect(input.prop('style')).toEqual({ height: 0 }); - expect(input.prop('autoFocus')).toEqual(true); - expect(input.prop('type')).toEqual('textarea'); - expect(input.prop('rows')).toEqual('1'); - expect(input.prop('placeholder')).toEqual('Expression (press Shift+Enter for newlines)'); - expect(input.prop('value')).toEqual('node_cpu'); + it('renders a CodeMirror expression input', () => { + const input = expressionInput.find('div.cm-expression-input'); + expect(input.text()).toContain('node_cpu'); }); - describe('when autosuggest is closed', () => { - it('prevents Downshift default on Home, End, Arrows', () => { - const downshift = expressionInput.find(Downshift); - const input = downshift.find(Input); - downshift.setState({ isOpen: false }); - ['Home', 'End', 'ArrowUp', 'ArrowDown'].forEach((key) => { - const event = getKeyEvent(key); - input.simulate('keydown', event); - const nativeEvent = event.nativeEvent as any; - expect(nativeEvent.preventDownshiftDefault).toBe(true); - }); - }); - - it('does not render an autosuggest', () => { - const downshift = expressionInput.find(Downshift); - downshift.setState({ isOpen: false }); - const ul = downshift.find('ul'); - expect(ul).toHaveLength(0); - }); - }); - - describe('handleInput', () => { - it('should call setState', () => { - const instance: any = expressionInput.instance(); - const stateSpy = jest.spyOn(instance, 'setState'); - instance.handleInput(); - expect(stateSpy).toHaveBeenCalled(); - }); - it('should call onExpressionChange', () => { - const spyOnExpressionChange = jest.fn(); - const props = { ...expressionInputProps, onExpressionChange: spyOnExpressionChange }; - const wrapper = mount(); - const input = wrapper.find(Input); - input.simulate('input', { target: { value: 'prometheus_engine_' } }); - expect(spyOnExpressionChange).toHaveBeenCalledTimes(1); - }); - }); - - describe('onSelect', () => { - it('should call setState with selected value', () => { - const instance: any = expressionInput.instance(); - const stateSpy = jest.spyOn(instance, 'setState'); - instance.setValue('foo'); - expect(stateSpy).toHaveBeenCalledWith({ height: 'auto' }, expect.anything()); - }); - }); - - describe('onClick', () => { - it('executes the query', () => { - const spyExecuteQuery = jest.fn(); - const props = { ...expressionInputProps, executeQuery: spyExecuteQuery }; - const wrapper = mount(); - const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn')); - btn.simulate('click'); - expect(spyExecuteQuery).toHaveBeenCalledTimes(1); - }); - }); - - describe('handleKeyPress', () => { - it('should call executeQuery on Enter key pressed', () => { - const spyExecuteQuery = jest.fn(); - const props = { ...expressionInputProps, executeQuery: spyExecuteQuery }; - const input = mount(); - const instance: any = input.instance(); - instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter' }); - expect(spyExecuteQuery).toHaveBeenCalled(); - }); - it('should NOT call executeQuery on Enter + Shift', () => { - const spyExecuteQuery = jest.fn(); - const props = { ...expressionInputProps, executeQuery: spyExecuteQuery }; - const input = mount(); - const instance: any = input.instance(); - instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter', shiftKey: true }); - expect(spyExecuteQuery).not.toHaveBeenCalled(); - }); - }); - - describe('getSearchMatches', () => { - it('should return matched value', () => { - const instance: any = expressionInput.instance(); - expect(instance.getSearchMatches('foo', ['barfoobaz', 'bazasdbaz'])).toHaveLength(1); - }); - it('should return empty array if no match found', () => { - const instance: any = expressionInput.instance(); - expect(instance.getSearchMatches('foo', ['barbaz', 'bazasdbaz'])).toHaveLength(0); - }); - }); - - describe('createAutocompleteSection', () => { - const props = { - ...expressionInputProps, - metricNames: ['foo', 'bar', 'baz'], - }; - - it('should close menu if no matches found', () => { - const input = mount(); - const instance: any = input.instance(); - const spyCloseMenu = jest.fn(); - instance.createAutocompleteSection({ inputValue: 'qqqqqq', closeMenu: spyCloseMenu }); - setTimeout(() => { - expect(spyCloseMenu).toHaveBeenCalled(); - }); - }); - it('should not render list if inputValue not exist', () => { - const input = mount(); - const instance: any = input.instance(); - const spyCloseMenu = jest.fn(); - instance.createAutocompleteSection({ closeMenu: spyCloseMenu }); - setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled()); - }); - it('should not render list if enableAutocomplete is false', () => { - const input = mount(); - const instance: any = input.instance(); - const spyCloseMenu = jest.fn(); - instance.createAutocompleteSection({ closeMenu: spyCloseMenu }); - setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled()); - }); - it('should render autosuggest-dropdown', () => { - const input = mount(); - const instance: any = input.instance(); - const spyGetMenuProps = jest.fn(); - const sections = instance.createAutocompleteSection({ - inputValue: 'foo', - highlightedIndex: 0, - getMenuProps: spyGetMenuProps, - getItemProps: jest.fn, - }); - expect(sections.props.className).toEqual('autosuggest-dropdown'); - }); - }); - - describe('when downshift is open', () => { - it('closes the menu on "Enter"', () => { - const downshift = expressionInput.find(Downshift); - const input = downshift.find(Input); - downshift.setState({ isOpen: true }); - const event = getKeyEvent('Enter'); - input.simulate('keydown', event); - expect(downshift.state('isOpen')).toBe(false); - }); - - it('should blur input on escape', () => { - const downshift = expressionInput.find(Downshift); - const instance: any = expressionInput.instance(); - const spyBlur = jest.spyOn(instance.exprInputRef.current, 'blur'); - const input = downshift.find(Input); - downshift.setState({ isOpen: false }); - const event = getKeyEvent('Escape'); - input.simulate('keydown', event); - expect(spyBlur).toHaveBeenCalled(); - }); - - it('noops on ArrowUp or ArrowDown', () => { - const downshift = expressionInput.find(Downshift); - const input = downshift.find(Input); - downshift.setState({ isOpen: true }); - ['ArrowUp', 'ArrowDown'].forEach((key) => { - const event = getKeyEvent(key); - input.simulate('keydown', event); - const nativeEvent = event.nativeEvent as any; - expect(nativeEvent.preventDownshiftDefault).toBeUndefined(); - }); - }); - - it('does not render an autosuggest if there are no matches', () => { - const downshift = expressionInput.find(Downshift); - downshift.setState({ isOpen: true }); - const ul = downshift.find('ul'); - expect(ul).toHaveLength(0); - }); - - it('renders an autosuggest if there are matches', () => { - const downshift = expressionInput.find(Downshift); - downshift.setState({ isOpen: true }); - setTimeout(() => { - const ul = downshift.find('ul'); - expect(ul.prop('className')).toEqual('card list-group'); - const items = ul.find('li'); - expect(items.map((item) => item.text()).join(', ')).toEqual( - 'node_cpu_guest_seconds_total, node_cpu_seconds_total, instance:node_cpu_utilisation:rate1m' - ); - }); - }); - }); - - it('renders an execute Button', () => { + it('renders an execute button', () => { const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'append'); const button = addon.find(Button).find('.execute-btn').first(); expect(button.prop('color')).toEqual('primary'); expect(button.text()).toEqual('Execute'); }); + + it('executes the query when clicking the execute button', () => { + const spyExecuteQuery = jest.fn(); + const props = { ...expressionInputProps, executeQuery: spyExecuteQuery }; + const wrapper = mount(); + const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn')); + btn.simulate('click'); + expect(spyExecuteQuery).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/ui/react-app/src/pages/graph/ExpressionInput.tsx b/web/ui/react-app/src/pages/graph/ExpressionInput.tsx index 5fde8ea312..f45a5315dd 100644 --- a/web/ui/react-app/src/pages/graph/ExpressionInput.tsx +++ b/web/ui/react-app/src/pages/graph/ExpressionInput.tsx @@ -1,15 +1,30 @@ -import React, { Component } from 'react'; -import { Button, Input, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; +import React, { FC, useState, useEffect, useRef } from 'react'; +import { Button, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; -import Downshift, { ControllerStateAndHelpers } from 'downshift'; -import sanitizeHTML from 'sanitize-html'; +import { EditorView, highlightSpecialChars, keymap, ViewUpdate, placeholder } from '@codemirror/view'; +import { EditorState, Prec, Compartment } from '@codemirror/state'; +import { indentOnInput, syntaxTree } from '@codemirror/language'; +import { history, historyKeymap } from '@codemirror/history'; +import { defaultKeymap, insertNewlineAndIndent } from '@codemirror/commands'; +import { bracketMatching } from '@codemirror/matchbrackets'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; +import { highlightSelectionMatches } from '@codemirror/search'; +import { commentKeymap } from '@codemirror/comment'; +import { lintKeymap } from '@codemirror/lint'; +import { autocompletion, completionKeymap, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { baseTheme, lightTheme, darkTheme, promqlHighlighter } from './CMTheme'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faGlobeEurope, faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons'; import MetricsExplorer from './MetricsExplorer'; -import { Fuzzy, FuzzyResult } from '@nexucis/fuzzy'; +import { usePathPrefix } from '../../contexts/PathPrefixContext'; +import { useTheme } from '../../contexts/ThemeContext'; +import { CompleteStrategy, PromQLExtension } from 'codemirror-promql'; +import { newCompleteStrategy } from 'codemirror-promql/dist/cjs/complete'; -interface ExpressionInputProps { +const promqlExtension = new PromQLExtension(); + +interface CMExpressionInputProps { value: string; onExpressionChange: (expr: string) => void; queryHistory: string[]; @@ -17,235 +32,218 @@ interface ExpressionInputProps { executeQuery: () => void; loading: boolean; enableAutocomplete: boolean; + enableHighlighting: boolean; + enableLinter: boolean; } -interface ExpressionInputState { - height: number | string; - showMetricsExplorer: boolean; +const dynamicConfigCompartment = new Compartment(); + +// Autocompletion strategy that wraps the main one and enriches +// it with past query items. +export class HistoryCompleteStrategy implements CompleteStrategy { + private complete: CompleteStrategy; + private queryHistory: string[]; + constructor(complete: CompleteStrategy, queryHistory: string[]) { + this.complete = complete; + this.queryHistory = queryHistory; + } + + promQL(context: CompletionContext): Promise | CompletionResult | null { + return Promise.resolve(this.complete.promQL(context)).then((res) => { + const { state, pos } = context; + const tree = syntaxTree(state).resolve(pos, -1); + const start = res != null ? res.from : tree.from; + + if (start !== 0) { + return res; + } + + const historyItems: CompletionResult = { + from: start, + to: pos, + options: this.queryHistory.map((q) => ({ + label: q.length < 80 ? q : q.slice(0, 76).concat('...'), + detail: 'past query', + apply: q, + info: q.length < 80 ? undefined : q, + })), + span: /^[a-zA-Z0-9_:]+$/, + }; + + if (res !== null) { + historyItems.options = historyItems.options.concat(res.options); + } + return historyItems; + }); + } } -const fuz = new Fuzzy({ pre: '', post: '', shouldSort: true }); +const ExpressionInput: FC = ({ + value, + onExpressionChange, + queryHistory, + metricNames, + executeQuery, + loading, + enableAutocomplete, + enableHighlighting, + enableLinter, +}) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const [showMetricsExplorer, setShowMetricsExplorer] = useState(false); + const pathPrefix = usePathPrefix(); + const { theme } = useTheme(); -class ExpressionInput extends Component { - private exprInputRef = React.createRef(); + // (Re)initialize editor based on settings / setting changes. + useEffect(() => { + // Build the dynamic part of the config. + promqlExtension + .activateCompletion(enableAutocomplete) + .activateLinter(enableLinter) + .setComplete({ + completeStrategy: new HistoryCompleteStrategy( + newCompleteStrategy({ + remote: { url: pathPrefix, cache: { initialMetricList: metricNames } }, + }), + queryHistory + ), + }); + const dynamicConfig = [ + enableHighlighting ? promqlHighlighter : [], + promqlExtension.asExtension(), + theme === 'dark' ? darkTheme : lightTheme, + ]; - constructor(props: ExpressionInputProps) { - super(props); - this.state = { - height: 'auto', - showMetricsExplorer: false, - }; - } + // Create or reconfigure the editor. + const view = viewRef.current; + if (view === null) { + // If the editor does not exist yet, create it. + if (!containerRef.current) { + throw new Error('expected CodeMirror container element to exist'); + } - componentDidMount(): void { - this.setHeight(); - } + const startState = EditorState.create({ + doc: value, + extensions: [ + baseTheme, + highlightSpecialChars(), + history(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + highlightSelectionMatches(), + EditorView.lineWrapping, + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...commentKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + placeholder('Expression (press Shift+Enter for newlines)'), + dynamicConfigCompartment.of(dynamicConfig), + // This keymap is added without precedence so that closing the autocomplete dropdown + // via Escape works without blurring the editor. + keymap.of([ + { + key: 'Escape', + run: (v: EditorView): boolean => { + v.contentDOM.blur(); + return false; + }, + }, + ]), + Prec.override( + keymap.of([ + { + key: 'Enter', + run: (v: EditorView): boolean => { + executeQuery(); + return true; + }, + }, + { + key: 'Shift-Enter', + run: insertNewlineAndIndent, + }, + ]) + ), + EditorView.updateListener.of((update: ViewUpdate): void => { + onExpressionChange(update.state.doc.toString()); + }), + ], + }); - setHeight = (): void => { - if (this.exprInputRef.current) { - const { offsetHeight, clientHeight, scrollHeight } = this.exprInputRef.current; - const offset = offsetHeight - clientHeight; // Needed in order for the height to be more accurate. - this.setState({ height: scrollHeight + offset }); - } - }; + const view = new EditorView({ + state: startState, + parent: containerRef.current, + }); - handleInput = (): void => { - if (this.exprInputRef.current) { - this.setValue(this.exprInputRef.current.value); - } - }; + viewRef.current = view; - setValue = (value: string): void => { - const { onExpressionChange } = this.props; - onExpressionChange(value); - this.setState({ height: 'auto' }, this.setHeight); - }; - - componentDidUpdate(prevProps: ExpressionInputProps): void { - const { value } = this.props; - if (value !== prevProps.value) { - this.setValue(value); - } - } - - handleKeyPress = (event: React.KeyboardEvent): void => { - const { executeQuery } = this.props; - if (event.key === 'Enter' && !event.shiftKey) { - executeQuery(); - event.preventDefault(); - } - }; - - getSearchMatches = (input: string, expressions: string[]): FuzzyResult[] => { - return fuz.filter(input.replace(/ /g, ''), expressions); - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createAutocompleteSection = (downshift: ControllerStateAndHelpers): JSX.Element | null => { - const { inputValue = '', closeMenu, highlightedIndex } = downshift; - const autocompleteSections = { - 'Query History': this.props.queryHistory, - 'Metric Names': this.props.metricNames, - }; - let index = 0; - const sections = - inputValue?.length && this.props.enableAutocomplete - ? Object.entries(autocompleteSections).reduce((acc, [title, items]) => { - const matches = this.getSearchMatches(inputValue, items); - return !matches.length - ? acc - : [ - ...acc, -
    -
  • {title}
  • - {matches - .slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is slow. - .map((result: FuzzyResult) => { - const itemProps = downshift.getItemProps({ - key: result.original, - index, - item: result.original, - style: { - backgroundColor: highlightedIndex === index++ ? 'lightgray' : 'white', - }, - }); - return ( -
  • - ); - })} -
, - ]; - }, [] as JSX.Element[]) - : []; - - if (!sections.length) { - // This is ugly but is needed in order to sync state updates. - // This way we force downshift to wait React render call to complete before closeMenu to be triggered. - setTimeout(closeMenu); - return null; - } - - return ( -
- {sections} -
- ); - }; - - openMetricsExplorer = (): void => { - this.setState({ - showMetricsExplorer: true, - }); - }; - - updateShowMetricsExplorer = (show: boolean): void => { - this.setState({ - showMetricsExplorer: show, - }); - }; - - insertAtCursor = (value: string): void => { - if (!this.exprInputRef.current) return; - - const startPosition = this.exprInputRef.current.selectionStart; - const endPosition = this.exprInputRef.current.selectionEnd; - - const previousValue = this.exprInputRef.current.value; - let newValue: string; - if (startPosition && endPosition) { - newValue = - previousValue.substring(0, startPosition) + value + previousValue.substring(endPosition, previousValue.length); + view.focus(); } else { - newValue = previousValue + value; + // The editor already exists, just reconfigure the dynamically configured parts. + view.dispatch( + view.state.update({ + effects: dynamicConfigCompartment.reconfigure(dynamicConfig), + }) + ); } + // "value" is only used in the initial render, so we don't want to + // re-run this effect every time that "value" changes. + // + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableAutocomplete, enableHighlighting, enableLinter, executeQuery, onExpressionChange, queryHistory, theme]); - this.setValue(newValue); + const insertAtCursor = (value: string) => { + const view = viewRef.current; + if (view === null) { + return; + } + const { from, to } = view.state.selection.ranges[0]; + view.dispatch( + view.state.update({ + changes: { from, to, insert: value }, + }) + ); }; - render(): JSX.Element { - const { executeQuery, value } = this.props; - const { height } = this.state; - return ( - <> - - {(downshift) => ( -
- - - - {this.props.loading ? : } - - - { - switch (event.key) { - case 'Home': - case 'End': - // We want to be able to jump to the beginning/end of the input field. - // By default, Downshift otherwise jumps to the first/last suggestion item instead. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.nativeEvent as any).preventDownshiftDefault = true; - break; - case 'ArrowUp': - case 'ArrowDown': - if (!downshift.isOpen) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.nativeEvent as any).preventDownshiftDefault = true; - } - break; - case 'Enter': - downshift.closeMenu(); - break; - case 'Escape': - if (!downshift.isOpen && this.exprInputRef.current) { - this.exprInputRef.current.blur(); - } - break; - default: - } - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any)} - value={value} - /> - - - - - - - - {downshift.isOpen && this.createAutocompleteSection(downshift)} -
- )} -
+ return ( + <> + + + + {loading ? : } + + +
+ + + + + - - - ); - } -} + + + ); +}; export default ExpressionInput; diff --git a/web/ui/react-app/src/pages/graph/Panel.test.tsx b/web/ui/react-app/src/pages/graph/Panel.test.tsx index 2d702b6fbf..328a8967f2 100644 --- a/web/ui/react-app/src/pages/graph/Panel.test.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { mount, shallow } from 'enzyme'; import Panel, { PanelOptions, PanelType } from './Panel'; -import ExpressionInput from './ExpressionInput'; import GraphControls from './GraphControls'; import { NavLink, TabPane } from 'reactstrap'; import TimeInput from './TimeInput'; @@ -38,17 +37,6 @@ const defaultProps = { describe('Panel', () => { const panel = shallow(); - it('renders an ExpressionInput', () => { - const input = panel.find(ExpressionInput); - expect(input.prop('value')).toEqual('prometheus_engine'); - expect(input.prop('metricNames')).toEqual([ - 'prometheus_engine_queries', - 'prometheus_engine_queries_concurrent_max', - 'prometheus_engine_query_duration_seconds', - ]); - expect(input.prop('queryHistory')).toEqual([]); - }); - it('renders NavLinks', () => { const results: PanelOptions[] = []; const onOptionsChanged = (opts: PanelOptions): void => { diff --git a/web/ui/react-app/src/pages/graph/Panel.tsx b/web/ui/react-app/src/pages/graph/Panel.tsx index 1dca06ad04..1cb0c1fdea 100644 --- a/web/ui/react-app/src/pages/graph/Panel.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.tsx @@ -5,7 +5,6 @@ import { Alert, Button, Col, Nav, NavItem, NavLink, Row, TabContent, TabPane } f import moment from 'moment-timezone'; import ExpressionInput from './ExpressionInput'; -import CMExpressionInput from './CMExpressionInput'; import GraphControls from './GraphControls'; import { GraphTabContent } from './GraphTabContent'; import DataTable from './DataTable'; @@ -24,7 +23,6 @@ interface PanelProps { removePanel: () => void; onExecuteQuery: (query: string) => void; pathPrefix: string; - useExperimentalEditor: boolean; enableAutocomplete: boolean; enableHighlighting: boolean; enableLinter: boolean; @@ -272,29 +270,17 @@ class Panel extends Component {
- {this.props.useExperimentalEditor ? ( - - ) : ( - - )} + diff --git a/web/ui/react-app/src/pages/graph/PanelList.test.tsx b/web/ui/react-app/src/pages/graph/PanelList.test.tsx index 196ac3404e..e7f75f1ed2 100755 --- a/web/ui/react-app/src/pages/graph/PanelList.test.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.test.tsx @@ -11,7 +11,6 @@ describe('PanelList', () => { { id: 'use-local-time-checkbox', label: 'Use local time', default: false }, { id: 'query-history-checkbox', label: 'Enable query history', default: false }, { id: 'autocomplete-checkbox', label: 'Enable autocomplete', default: true }, - { id: 'use-experimental-editor-checkbox', label: 'Use experimental editor', default: true }, { id: 'highlighting-checkbox', label: 'Enable highlighting', default: true }, { id: 'linter-checkbox', label: 'Enable linter', default: true }, ].forEach((cb, idx) => { diff --git a/web/ui/react-app/src/pages/graph/PanelList.tsx b/web/ui/react-app/src/pages/graph/PanelList.tsx index 026e123298..e7fb7ceb07 100644 --- a/web/ui/react-app/src/pages/graph/PanelList.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.tsx @@ -20,7 +20,6 @@ interface PanelListContentProps { panels: PanelMeta[]; metrics: string[]; useLocalTime: boolean; - useExperimentalEditor: boolean; queryHistoryEnabled: boolean; enableAutocomplete: boolean; enableHighlighting: boolean; @@ -30,7 +29,6 @@ interface PanelListContentProps { export const PanelListContent: FC = ({ metrics = [], useLocalTime, - useExperimentalEditor, queryHistoryEnabled, enableAutocomplete, enableHighlighting, @@ -105,7 +103,6 @@ export const PanelListContent: FC = ({ ) ) } - useExperimentalEditor={useExperimentalEditor} useLocalTime={useLocalTime} metricNames={metrics} pastQueries={queryHistoryEnabled ? historyItems : []} @@ -123,7 +120,6 @@ export const PanelListContent: FC = ({ const PanelList: FC = () => { const [delta, setDelta] = useState(0); - const [useExperimentalEditor, setUseExperimentalEditor] = useLocalStorage('use-new-editor', true); const [useLocalTime, setUseLocalTime] = useLocalStorage('use-local-time', false); const [enableQueryHistory, setEnableQueryHistory] = useLocalStorage('enable-query-history', false); const [enableAutocomplete, setEnableAutocomplete] = useLocalStorage('enable-metric-autocomplete', true); @@ -180,34 +176,22 @@ const PanelList: FC = () => { Enable autocomplete
-
- setUseExperimentalEditor(target.checked)} - defaultChecked={useExperimentalEditor} - > - Use experimental editor - - setEnableHighlighting(target.checked)} - defaultChecked={enableHighlighting} - disabled={!useExperimentalEditor} - > - Enable highlighting - - setEnableLinter(target.checked)} - defaultChecked={enableLinter} - disabled={!useExperimentalEditor} - > - Enable linter - -
+ setEnableHighlighting(target.checked)} + defaultChecked={enableHighlighting} + > + Enable highlighting + + setEnableLinter(target.checked)} + defaultChecked={enableLinter} + > + Enable linter +
{(delta > 30 || timeErr) && ( @@ -227,7 +211,6 @@ const PanelList: FC = () => { panels={decodePanelOptionsFromQueryString(window.location.search)} useLocalTime={useLocalTime} metrics={metricsRes.data} - useExperimentalEditor={useExperimentalEditor} queryHistoryEnabled={enableQueryHistory} enableAutocomplete={enableAutocomplete} enableHighlighting={enableHighlighting} From cda2dbbef67e2e98e059c4d25d840032d63badb7 Mon Sep 17 00:00:00 2001 From: Witek Bedyk Date: Tue, 19 Oct 2021 01:00:44 +0200 Subject: [PATCH 18/19] Add Uyuni service discovery (#8190) * Add Uyuni service discovery Signed-off-by: Witek Bedyk Co-authored-by: Joao Cavalheiro Co-authored-by: Marcelo Chiaradia Co-authored-by: Stefano Torresi Co-authored-by: Julien Pivotto --- config/config_test.go | 23 +- config/testdata/conf.good.yml | 6 + discovery/install/install.go | 1 + discovery/uyuni/uyuni.go | 341 ++++++++++++++++++++ docs/configuration/configuration.md | 81 +++++ documentation/examples/prometheus-uyuni.yml | 36 +++ go.mod | 1 + go.sum | 2 + 8 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 discovery/uyuni/uyuni.go create mode 100644 documentation/examples/prometheus-uyuni.yml diff --git a/config/config_test.go b/config/config_test.go index 6db46a2b59..3055de74d8 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -49,6 +49,7 @@ import ( "github.com/prometheus/prometheus/discovery/scaleway" "github.com/prometheus/prometheus/discovery/targetgroup" "github.com/prometheus/prometheus/discovery/triton" + "github.com/prometheus/prometheus/discovery/uyuni" "github.com/prometheus/prometheus/discovery/xds" "github.com/prometheus/prometheus/discovery/zookeeper" "github.com/prometheus/prometheus/pkg/labels" @@ -934,6 +935,26 @@ var expectedConf = &Config{ }, }, }, + { + JobName: "uyuni", + + HonorTimestamps: true, + ScrapeInterval: model.Duration(15 * time.Second), + ScrapeTimeout: DefaultGlobalConfig.ScrapeTimeout, + HTTPClientConfig: config.HTTPClientConfig{FollowRedirects: true}, + MetricsPath: DefaultScrapeConfig.MetricsPath, + Scheme: DefaultScrapeConfig.Scheme, + ServiceDiscoveryConfigs: discovery.Configs{ + &uyuni.SDConfig{ + Server: kubernetesSDHostURL(), + Username: "gopher", + Password: "hole", + Entitlement: "monitoring_entitled", + Separator: ",", + RefreshInterval: model.Duration(60 * time.Second), + }, + }, + }, }, AlertingConfig: AlertingConfig{ AlertmanagerConfigs: []*AlertmanagerConfig{ @@ -1018,7 +1039,7 @@ func TestElideSecrets(t *testing.T) { yamlConfig := string(config) matches := secretRe.FindAllStringIndex(yamlConfig, -1) - require.Equal(t, 15, len(matches), "wrong number of secret matches found") + require.Equal(t, 16, len(matches), "wrong number of secret matches found") require.NotContains(t, yamlConfig, "mysecret", "yaml marshal reveals authentication credentials.") } diff --git a/config/testdata/conf.good.yml b/config/testdata/conf.good.yml index a439bd0a0f..cdd0c0b306 100644 --- a/config/testdata/conf.good.yml +++ b/config/testdata/conf.good.yml @@ -349,6 +349,12 @@ scrape_configs: - authorization: credentials: abcdef + - job_name: uyuni + uyuni_sd_configs: + - server: https://localhost:1234 + username: gopher + password: hole + alerting: alertmanagers: - scheme: https diff --git a/discovery/install/install.go b/discovery/install/install.go index 88cf67ca7d..e16b348f6b 100644 --- a/discovery/install/install.go +++ b/discovery/install/install.go @@ -34,6 +34,7 @@ import ( _ "github.com/prometheus/prometheus/discovery/puppetdb" // register puppetdb _ "github.com/prometheus/prometheus/discovery/scaleway" // register scaleway _ "github.com/prometheus/prometheus/discovery/triton" // register triton + _ "github.com/prometheus/prometheus/discovery/uyuni" // register uyuni _ "github.com/prometheus/prometheus/discovery/xds" // register xds _ "github.com/prometheus/prometheus/discovery/zookeeper" // register zookeeper ) diff --git a/discovery/uyuni/uyuni.go b/discovery/uyuni/uyuni.go new file mode 100644 index 0000000000..080f17a8c2 --- /dev/null +++ b/discovery/uyuni/uyuni.go @@ -0,0 +1,341 @@ +// Copyright 2020 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package uyuni + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/kolo/xmlrpc" + "github.com/pkg/errors" + "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + + "github.com/prometheus/prometheus/discovery" + "github.com/prometheus/prometheus/discovery/refresh" + "github.com/prometheus/prometheus/discovery/targetgroup" +) + +const ( + uyuniXMLRPCAPIPath = "/rpc/api" + + uyuniMetaLabelPrefix = model.MetaLabelPrefix + "uyuni_" + uyuniLabelMinionHostname = uyuniMetaLabelPrefix + "minion_hostname" + uyuniLabelPrimaryFQDN = uyuniMetaLabelPrefix + "primary_fqdn" + uyuniLablelSystemID = uyuniMetaLabelPrefix + "system_id" + uyuniLablelGroups = uyuniMetaLabelPrefix + "groups" + uyuniLablelEndpointName = uyuniMetaLabelPrefix + "endpoint_name" + uyuniLablelExporter = uyuniMetaLabelPrefix + "exporter" + uyuniLabelProxyModule = uyuniMetaLabelPrefix + "proxy_module" + uyuniLabelMetricsPath = uyuniMetaLabelPrefix + "metrics_path" + uyuniLabelScheme = uyuniMetaLabelPrefix + "scheme" +) + +// DefaultSDConfig is the default Uyuni SD configuration. +var DefaultSDConfig = SDConfig{ + Entitlement: "monitoring_entitled", + Separator: ",", + RefreshInterval: model.Duration(1 * time.Minute), +} + +func init() { + discovery.RegisterConfig(&SDConfig{}) +} + +// SDConfig is the configuration for Uyuni based service discovery. +type SDConfig struct { + Server config.URL `yaml:"server"` + Username string `yaml:"username"` + Password config.Secret `yaml:"password"` + HTTPClientConfig config.HTTPClientConfig `yaml:",inline"` + Entitlement string `yaml:"entitlement,omitempty"` + Separator string `yaml:"separator,omitempty"` + RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"` +} + +// Uyuni API Response structures +type systemGroupID struct { + GroupID int `xmlrpc:"id"` + GroupName string `xmlrpc:"name"` +} + +type networkInfo struct { + SystemID int `xmlrpc:"system_id"` + Hostname string `xmlrpc:"hostname"` + PrimaryFQDN string `xmlrpc:"primary_fqdn"` + IP string `xmlrpc:"ip"` +} + +type endpointInfo struct { + SystemID int `xmlrpc:"system_id"` + EndpointName string `xmlrpc:"endpoint_name"` + Port int `xmlrpc:"port"` + Path string `xmlrpc:"path"` + Module string `xmlrpc:"module"` + ExporterName string `xmlrpc:"exporter_name"` + TLSEnabled bool `xmlrpc:"tls_enabled"` +} + +// Discovery periodically performs Uyuni API requests. It implements the Discoverer interface. +type Discovery struct { + *refresh.Discovery + apiURL *url.URL + roundTripper http.RoundTripper + username string + password string + entitlement string + separator string + interval time.Duration + logger log.Logger +} + +// Name returns the name of the Config. +func (*SDConfig) Name() string { return "uyuni" } + +// NewDiscoverer returns a Discoverer for the Config. +func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewDiscovery(c, opts.Logger) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *SDConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultSDConfig + type plain SDConfig + err := unmarshal((*plain)(c)) + + if err != nil { + return err + } + if c.Server.URL == nil { + return errors.New("Uyuni SD configuration requires server host") + } + + _, err = url.Parse(c.Server.String()) + if err != nil { + return errors.Wrap(err, "Uyuni Server URL is not valid") + } + + if c.Username == "" { + return errors.New("Uyuni SD configuration requires a username") + } + if c.Password == "" { + return errors.New("Uyuni SD configuration requires a password") + } + return nil +} + +// Attempt to login in Uyuni Server and get an auth token +func login(rpcclient *xmlrpc.Client, user string, pass string) (string, error) { + var result string + err := rpcclient.Call("auth.login", []interface{}{user, pass}, &result) + return result, err +} + +// Logout from Uyuni API +func logout(rpcclient *xmlrpc.Client, token string) error { + return rpcclient.Call("auth.logout", token, nil) +} + +// Get the system groups information of monitored clients +func getSystemGroupsInfoOfMonitoredClients(rpcclient *xmlrpc.Client, token string, entitlement string) (map[int][]systemGroupID, error) { + var systemGroupsInfos []struct { + SystemID int `xmlrpc:"id"` + SystemGroups []systemGroupID `xmlrpc:"system_groups"` + } + + err := rpcclient.Call("system.listSystemGroupsForSystemsWithEntitlement", []interface{}{token, entitlement}, &systemGroupsInfos) + if err != nil { + return nil, err + } + + result := make(map[int][]systemGroupID) + for _, systemGroupsInfo := range systemGroupsInfos { + result[systemGroupsInfo.SystemID] = systemGroupsInfo.SystemGroups + } + return result, nil +} + +// GetSystemNetworkInfo lists client FQDNs. +func getNetworkInformationForSystems(rpcclient *xmlrpc.Client, token string, systemIDs []int) (map[int]networkInfo, error) { + var networkInfos []networkInfo + err := rpcclient.Call("system.getNetworkForSystems", []interface{}{token, systemIDs}, &networkInfos) + if err != nil { + return nil, err + } + + result := make(map[int]networkInfo) + for _, networkInfo := range networkInfos { + result[networkInfo.SystemID] = networkInfo + } + return result, nil +} + +// Get endpoints information for given systems +func getEndpointInfoForSystems( + rpcclient *xmlrpc.Client, + token string, + systemIDs []int, +) ([]endpointInfo, error) { + var endpointInfos []endpointInfo + err := rpcclient.Call( + "system.monitoring.listEndpoints", + []interface{}{token, systemIDs}, &endpointInfos) + if err != nil { + return nil, err + } + return endpointInfos, err +} + +// NewDiscovery returns a uyuni discovery for the given configuration. +func NewDiscovery(conf *SDConfig, logger log.Logger) (*Discovery, error) { + var apiURL *url.URL + *apiURL = *conf.Server.URL + apiURL.Path = path.Join(apiURL.Path, uyuniXMLRPCAPIPath) + + rt, err := config.NewRoundTripperFromConfig(conf.HTTPClientConfig, "uyuni_sd", config.WithHTTP2Disabled()) + if err != nil { + return nil, err + } + + d := &Discovery{ + apiURL: apiURL, + roundTripper: rt, + username: conf.Username, + password: string(conf.Password), + entitlement: conf.Entitlement, + separator: conf.Separator, + interval: time.Duration(conf.RefreshInterval), + logger: logger, + } + + d.Discovery = refresh.NewDiscovery( + logger, + "uyuni", + time.Duration(conf.RefreshInterval), + d.refresh, + ) + return d, nil +} + +func (d *Discovery) getEndpointLabels( + endpoint endpointInfo, + systemGroupIDs []systemGroupID, + networkInfo networkInfo, +) model.LabelSet { + + var addr, scheme string + managedGroupNames := getSystemGroupNames(systemGroupIDs) + addr = fmt.Sprintf("%s:%d", networkInfo.Hostname, endpoint.Port) + if endpoint.TLSEnabled { + scheme = "https" + } else { + scheme = "http" + } + + result := model.LabelSet{ + model.AddressLabel: model.LabelValue(addr), + uyuniLabelMinionHostname: model.LabelValue(networkInfo.Hostname), + uyuniLabelPrimaryFQDN: model.LabelValue(networkInfo.PrimaryFQDN), + uyuniLablelSystemID: model.LabelValue(fmt.Sprintf("%d", endpoint.SystemID)), + uyuniLablelGroups: model.LabelValue(strings.Join(managedGroupNames, d.separator)), + uyuniLablelEndpointName: model.LabelValue(endpoint.EndpointName), + uyuniLablelExporter: model.LabelValue(endpoint.ExporterName), + uyuniLabelProxyModule: model.LabelValue(endpoint.Module), + uyuniLabelMetricsPath: model.LabelValue(endpoint.Path), + uyuniLabelScheme: model.LabelValue(scheme), + } + + return result +} + +func getSystemGroupNames(systemGroupsIDs []systemGroupID) []string { + managedGroupNames := make([]string, 0, len(systemGroupsIDs)) + for _, systemGroupInfo := range systemGroupsIDs { + managedGroupNames = append(managedGroupNames, systemGroupInfo.GroupName) + } + + return managedGroupNames +} + +func (d *Discovery) getTargetsForSystems( + rpcClient *xmlrpc.Client, + token string, + entitlement string, +) ([]model.LabelSet, error) { + + result := make([]model.LabelSet, 0) + + systemGroupIDsBySystemID, err := getSystemGroupsInfoOfMonitoredClients(rpcClient, token, entitlement) + if err != nil { + return nil, errors.Wrap(err, "unable to get the managed system groups information of monitored clients") + } + + systemIDs := make([]int, 0, len(systemGroupIDsBySystemID)) + for systemID := range systemGroupIDsBySystemID { + systemIDs = append(systemIDs, systemID) + } + + endpointInfos, err := getEndpointInfoForSystems(rpcClient, token, systemIDs) + if err != nil { + return nil, errors.Wrap(err, "unable to get endpoints information") + } + + networkInfoBySystemID, err := getNetworkInformationForSystems(rpcClient, token, systemIDs) + if err != nil { + return nil, errors.Wrap(err, "unable to get the systems network information") + } + + for _, endpoint := range endpointInfos { + systemID := endpoint.SystemID + labels := d.getEndpointLabels( + endpoint, + systemGroupIDsBySystemID[systemID], + networkInfoBySystemID[systemID]) + result = append(result, labels) + } + + return result, nil +} + +func (d *Discovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { + rpcClient, err := xmlrpc.NewClient(d.apiURL.String(), d.roundTripper) + if err != nil { + return nil, err + } + defer rpcClient.Close() + + token, err := login(rpcClient, d.username, d.password) + if err != nil { + return nil, errors.Wrap(err, "unable to login to Uyuni API") + } + defer func() { + if err := logout(rpcClient, token); err != nil { + level.Debug(d.logger).Log("msg", "Failed to log out from Uyuni API", "err", err) + } + }() + + targetsForSystems, err := d.getTargetsForSystems(rpcClient, token, d.entitlement) + if err != nil { + return nil, err + } + + return []*targetgroup.Group{{Targets: targetsForSystems, Source: d.apiURL.String()}}, nil +} diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 4e25521109..6b451b595c 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -288,6 +288,10 @@ serverset_sd_configs: triton_sd_configs: [ - ... ] +# List of Uyuni service discovery configurations. +uyuni_sd_configs: + [ - ... ] + # List of labeled statically configured targets for this job. static_configs: [ - ... ] @@ -2256,6 +2260,79 @@ tls_config: [ ] ``` +### `` + +Uyuni SD configurations allow retrieving scrape targets from managed systems +via [Uyuni](https://www.uyuni-project.org/) API. + +The following meta labels are available on targets during [relabeling](#relabel_config): + +* `__meta_uyuni_endpoint_name`: the name of the application endpoint +* `__meta_uyuni_exporter`: the exporter exposing metrics for the target +* `__meta_uyuni_groups`: the system groups of the target +* `__meta_uyuni_metrics_path`: metrics path for the target +* `__meta_uyuni_minion_hostname`: hostname of the Uyuni client +* `__meta_uyuni_primary_fqdn`: primary FQDN of the Uyuni client +* `__meta_uyuni_proxy_module`: the module name if _Exporter Exporter_ proxy is + configured for the target +* `__meta_uyuni_scheme`: the protocol scheme used for requests +* `__meta_uyuni_system_id`: the system ID of the client + +See below for the configuration options for Uyuni discovery: + +```yaml +# The URL to connect to the Uyuni server. +server: + +# Credentials are used to authenticate the requests to Uyuni API. +username: +password: + +# The entitlement string to filter eligible systems. +[ entitlement: | default = monitoring_entitled ] + +# The string by which Uyuni group names are joined into the groups label. +[ separator: | default = , ] + +# Refresh interval to re-read the managed targets list. +[ refresh_interval: | default = 60s ] + +# Optional HTTP basic authentication information, currently not supported by Uyuni. +basic_auth: + [ username: ] + [ password: ] + [ password_file: ] + +# Optional `Authorization` header configuration, currently not supported by Uyuni. +authorization: + # Sets the authentication type. + [ type: | default: Bearer ] + # Sets the credentials. It is mutually exclusive with + # `credentials_file`. + [ credentials: ] + # Sets the credentials to the credentials read from the configured file. + # It is mutually exclusive with `credentials`. + [ credentials_file: ] + +# Optional OAuth 2.0 configuration, currently not supported by Uyuni. +# Cannot be used at the same time as basic_auth or authorization. +oauth2: + [ ] + +# Optional proxy URL. + [ proxy_url: ] + +# Configure whether HTTP requests follow HTTP 3xx redirects. + [ follow_redirects: | default = true ] + +# TLS configuration. +tls_config: + [ ] +``` + +See [the Prometheus uyuni-sd configuration file](/documentation/examples/prometheus-uyuni.yml) +for a practical example on how to set up Uyuni Prometheus configuration. + ### `` A `static_config` allows specifying a list of targets and a common label set @@ -2518,6 +2595,10 @@ serverset_sd_configs: triton_sd_configs: [ - ... ] +# List of Uyuni service discovery configurations. +uyuni_sd_configs: + [ - ... ] + # List of labeled statically configured Alertmanagers. static_configs: [ - ... ] diff --git a/documentation/examples/prometheus-uyuni.yml b/documentation/examples/prometheus-uyuni.yml new file mode 100644 index 0000000000..dd0d76916b --- /dev/null +++ b/documentation/examples/prometheus-uyuni.yml @@ -0,0 +1,36 @@ +# A example scrape configuration for running Prometheus with Uyuni. + +scrape_configs: + + # Make Prometheus scrape itself for metrics. + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # Discover Uyuni managed targets to scrape. + - job_name: 'uyuni' + + # Scrape Uyuni itself to discover new services. + uyuni_sd_configs: + - server: http://uyuni-project.org + username: gopher + password: hole + relabel_configs: + - source_labels: [__meta_uyuni_exporter] + target_label: exporter + - source_labels: [__meta_uyuni_groups] + target_label: groups + - source_labels: [__meta_uyuni_minion_hostname] + target_label: hostname + - source_labels: [__meta_uyuni_primary_fqdn] + regex: (.+) + target_label: hostname + - source_labels: [hostname, __address__] + regex: (.*);.*:(.*) + replacement: ${1}:${2} + target_label: __address__ + - source_labels: [__meta_uyuni_metrics_path] + regex: (.+) + target_label: __metrics_path__ + - source_labels: [__meta_uyuni_proxy_module] + target_label: __param_module diff --git a/go.mod b/go.mod index f03bcb8a5b..3fb313c399 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/hetznercloud/hcloud-go v1.32.0 github.com/influxdata/influxdb v1.9.3 github.com/json-iterator/go v1.1.11 + github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b github.com/linode/linodego v0.32.0 github.com/miekg/dns v1.1.43 github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect diff --git a/go.sum b/go.sum index b1fc5326cd..742048ffc3 100644 --- a/go.sum +++ b/go.sum @@ -886,6 +886,8 @@ github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b h1:iNjcivnc6lhbvJA3LD622NPrUponluJrBWPIwGG/3Bg= +github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= From c812ee794eb1bbf589c80246f779b03ddea32005 Mon Sep 17 00:00:00 2001 From: Julien Pivotto Date: Tue, 19 Oct 2021 13:13:56 +0200 Subject: [PATCH 19/19] PromQL: Comment flaky test (#9545) Signed-off-by: Julien Pivotto --- promql/testdata/aggregators.test | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/promql/testdata/aggregators.test b/promql/testdata/aggregators.test index aaf731f358..220c5edce1 100644 --- a/promql/testdata/aggregators.test +++ b/promql/testdata/aggregators.test @@ -501,9 +501,10 @@ eval instant at 1m avg(data{test="bigzero"}) clear # Test that aggregations are deterministic. -load 10s - up{job="prometheus"} 1 - up{job="prometheus2"} 1 - -eval instant at 1m count(topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without())) - {} 1 +# Commented because it is flaky in range mode. +#load 10s +# up{job="prometheus"} 1 +# up{job="prometheus2"} 1 +# +#eval instant at 1m count(topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without()) == topk(1,max(up) without())) +# {} 1