mirror of https://github.com/prometheus/prometheus
Browse Source
Conflicts: cmd/prometheus/main.go docs/command-line/prometheus.md docs/feature_flags.md web/ui/build_ui.sh web/web.go Resolved by dropping the UTF-8 feature flag and adding the `auto-reload-config` feature flag. For the new web ui pick all changes from `main`.pull/14879/head
Jan Fajerski
2 months ago
188 changed files with 53523 additions and 16504 deletions
@ -0,0 +1,193 @@
|
||||
// Copyright 2024 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 main |
||||
|
||||
import ( |
||||
"bytes" |
||||
"fmt" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"net/url" |
||||
"os" |
||||
"os/exec" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
"go.uber.org/atomic" |
||||
|
||||
"github.com/prometheus/prometheus/util/testutil" |
||||
) |
||||
|
||||
func TestScrapeFailureLogFile(t *testing.T) { |
||||
if testing.Short() { |
||||
t.Skip("skipping test in short mode.") |
||||
} |
||||
|
||||
// Tracks the number of requests made to the mock server.
|
||||
var requestCount atomic.Int32 |
||||
|
||||
// Starts a server that always returns HTTP 500 errors.
|
||||
mockServerAddress := startGarbageServer(t, &requestCount) |
||||
|
||||
// Create a temporary directory for Prometheus configuration and logs.
|
||||
tempDir := t.TempDir() |
||||
|
||||
// Define file paths for the scrape failure log and Prometheus configuration.
|
||||
// Like other files, the scrape failure log file should be relative to the
|
||||
// config file. Therefore, we split the name we put in the file and the full
|
||||
// path used to check the content of the file.
|
||||
scrapeFailureLogFileName := "scrape_failure.log" |
||||
scrapeFailureLogFile := filepath.Join(tempDir, scrapeFailureLogFileName) |
||||
promConfigFile := filepath.Join(tempDir, "prometheus.yml") |
||||
|
||||
// Step 1: Set up an initial Prometheus configuration that globally
|
||||
// specifies a scrape failure log file.
|
||||
promConfig := fmt.Sprintf(` |
||||
global: |
||||
scrape_interval: 500ms |
||||
scrape_failure_log_file: %s |
||||
|
||||
scrape_configs: |
||||
- job_name: 'test_job' |
||||
static_configs: |
||||
- targets: ['%s'] |
||||
`, scrapeFailureLogFileName, mockServerAddress) |
||||
|
||||
err := os.WriteFile(promConfigFile, []byte(promConfig), 0o644) |
||||
require.NoError(t, err, "Failed to write Prometheus configuration file") |
||||
|
||||
// Start Prometheus with the generated configuration and a random port, enabling the lifecycle API.
|
||||
port := testutil.RandomUnprivilegedPort(t) |
||||
params := []string{ |
||||
"-test.main", |
||||
"--config.file=" + promConfigFile, |
||||
"--storage.tsdb.path=" + filepath.Join(tempDir, "data"), |
||||
fmt.Sprintf("--web.listen-address=127.0.0.1:%d", port), |
||||
"--web.enable-lifecycle", |
||||
} |
||||
prometheusProcess := exec.Command(promPath, params...) |
||||
prometheusProcess.Stdout = os.Stdout |
||||
prometheusProcess.Stderr = os.Stderr |
||||
|
||||
err = prometheusProcess.Start() |
||||
require.NoError(t, err, "Failed to start Prometheus") |
||||
defer prometheusProcess.Process.Kill() |
||||
|
||||
// Wait until the mock server receives at least two requests from Prometheus.
|
||||
require.Eventually(t, func() bool { |
||||
return requestCount.Load() >= 2 |
||||
}, 30*time.Second, 500*time.Millisecond, "Expected at least two requests to the mock server") |
||||
|
||||
// Verify that the scrape failures have been logged to the specified file.
|
||||
content, err := os.ReadFile(scrapeFailureLogFile) |
||||
require.NoError(t, err, "Failed to read scrape failure log") |
||||
require.Contains(t, string(content), "server returned HTTP status 500 Internal Server Error", "Expected scrape failure log entry not found") |
||||
|
||||
// Step 2: Update the Prometheus configuration to remove the scrape failure
|
||||
// log file setting.
|
||||
promConfig = fmt.Sprintf(` |
||||
global: |
||||
scrape_interval: 1s |
||||
|
||||
scrape_configs: |
||||
- job_name: 'test_job' |
||||
static_configs: |
||||
- targets: ['%s'] |
||||
`, mockServerAddress) |
||||
|
||||
err = os.WriteFile(promConfigFile, []byte(promConfig), 0o644) |
||||
require.NoError(t, err, "Failed to update Prometheus configuration file") |
||||
|
||||
// Reload Prometheus with the updated configuration.
|
||||
reloadPrometheus(t, port) |
||||
|
||||
// Count the number of lines in the scrape failure log file before any
|
||||
// further requests.
|
||||
preReloadLogLineCount := countLinesInFile(scrapeFailureLogFile) |
||||
|
||||
// Wait for at least two more requests to the mock server to ensure
|
||||
// Prometheus continues scraping.
|
||||
requestsBeforeReload := requestCount.Load() |
||||
require.Eventually(t, func() bool { |
||||
return requestCount.Load() >= requestsBeforeReload+2 |
||||
}, 30*time.Second, 500*time.Millisecond, "Expected two more requests to the mock server after configuration reload") |
||||
|
||||
// Ensure that no new lines were added to the scrape failure log file after
|
||||
// the configuration change.
|
||||
require.Equal(t, preReloadLogLineCount, countLinesInFile(scrapeFailureLogFile), "No new lines should be added to the scrape failure log file after removing the log setting") |
||||
|
||||
// Step 3: Re-add the scrape failure log file setting, but this time under
|
||||
// scrape_configs, and reload Prometheus.
|
||||
promConfig = fmt.Sprintf(` |
||||
global: |
||||
scrape_interval: 1s |
||||
|
||||
scrape_configs: |
||||
- job_name: 'test_job' |
||||
scrape_failure_log_file: %s |
||||
static_configs: |
||||
- targets: ['%s'] |
||||
`, scrapeFailureLogFileName, mockServerAddress) |
||||
|
||||
err = os.WriteFile(promConfigFile, []byte(promConfig), 0o644) |
||||
require.NoError(t, err, "Failed to update Prometheus configuration file") |
||||
|
||||
// Reload Prometheus with the updated configuration.
|
||||
reloadPrometheus(t, port) |
||||
|
||||
// Wait for at least two more requests to the mock server and verify that
|
||||
// new log entries are created.
|
||||
postReloadLogLineCount := countLinesInFile(scrapeFailureLogFile) |
||||
requestsBeforeReAddingLog := requestCount.Load() |
||||
require.Eventually(t, func() bool { |
||||
return requestCount.Load() >= requestsBeforeReAddingLog+2 |
||||
}, 30*time.Second, 500*time.Millisecond, "Expected two additional requests after re-adding the log setting") |
||||
|
||||
// Confirm that new lines were added to the scrape failure log file.
|
||||
require.Greater(t, countLinesInFile(scrapeFailureLogFile), postReloadLogLineCount, "New lines should be added to the scrape failure log file after re-adding the log setting") |
||||
} |
||||
|
||||
// reloadPrometheus sends a reload request to the Prometheus server to apply
|
||||
// updated configurations.
|
||||
func reloadPrometheus(t *testing.T, port int) { |
||||
resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/-/reload", port), "", nil) |
||||
require.NoError(t, err, "Failed to reload Prometheus") |
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Unexpected status code when reloading Prometheus") |
||||
} |
||||
|
||||
// startGarbageServer sets up a mock server that returns a 500 Internal Server Error
|
||||
// for all requests. It also increments the request count each time it's hit.
|
||||
func startGarbageServer(t *testing.T, requestCount *atomic.Int32) string { |
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
requestCount.Inc() |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
})) |
||||
t.Cleanup(server.Close) |
||||
|
||||
parsedURL, err := url.Parse(server.URL) |
||||
require.NoError(t, err, "Failed to parse mock server URL") |
||||
|
||||
return parsedURL.Host |
||||
} |
||||
|
||||
// countLinesInFile counts and returns the number of lines in the specified file.
|
||||
func countLinesInFile(filePath string) int { |
||||
data, err := os.ReadFile(filePath) |
||||
if err != nil { |
||||
return 0 // Return 0 if the file doesn't exist or can't be read.
|
||||
} |
||||
return bytes.Count(data, []byte{'\n'}) |
||||
} |
@ -0,0 +1,92 @@
|
||||
// Copyright 2024 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 config |
||||
|
||||
import ( |
||||
"crypto/sha256" |
||||
"encoding/hex" |
||||
"fmt" |
||||
"os" |
||||
"path/filepath" |
||||
|
||||
"gopkg.in/yaml.v2" |
||||
) |
||||
|
||||
type ExternalFilesConfig struct { |
||||
RuleFiles []string `yaml:"rule_files"` |
||||
ScrapeConfigFiles []string `yaml:"scrape_config_files"` |
||||
} |
||||
|
||||
// GenerateChecksum generates a checksum of the YAML file and the files it references.
|
||||
func GenerateChecksum(yamlFilePath string) (string, error) { |
||||
hash := sha256.New() |
||||
|
||||
yamlContent, err := os.ReadFile(yamlFilePath) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error reading YAML file: %w", err) |
||||
} |
||||
_, err = hash.Write(yamlContent) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error writing YAML file to hash: %w", err) |
||||
} |
||||
|
||||
var config ExternalFilesConfig |
||||
if err := yaml.Unmarshal(yamlContent, &config); err != nil { |
||||
return "", fmt.Errorf("error unmarshalling YAML: %w", err) |
||||
} |
||||
|
||||
dir := filepath.Dir(yamlFilePath) |
||||
|
||||
for i, file := range config.RuleFiles { |
||||
config.RuleFiles[i] = filepath.Join(dir, file) |
||||
} |
||||
for i, file := range config.ScrapeConfigFiles { |
||||
config.ScrapeConfigFiles[i] = filepath.Join(dir, file) |
||||
} |
||||
|
||||
files := map[string][]string{ |
||||
"r": config.RuleFiles, // "r" for rule files
|
||||
"s": config.ScrapeConfigFiles, // "s" for scrape config files
|
||||
} |
||||
|
||||
for _, prefix := range []string{"r", "s"} { |
||||
for _, pattern := range files[prefix] { |
||||
matchingFiles, err := filepath.Glob(pattern) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error finding files with pattern %q: %w", pattern, err) |
||||
} |
||||
|
||||
for _, file := range matchingFiles { |
||||
// Write prefix to the hash ("r" or "s") followed by \0, then
|
||||
// the file path.
|
||||
_, err = hash.Write([]byte(prefix + "\x00" + file + "\x00")) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error writing %q path to hash: %w", file, err) |
||||
} |
||||
|
||||
// Read and hash the content of the file.
|
||||
content, err := os.ReadFile(file) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error reading file %s: %w", file, err) |
||||
} |
||||
_, err = hash.Write(append(content, []byte("\x00")...)) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error writing %q content to hash: %w", file, err) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil |
||||
} |
@ -0,0 +1,222 @@
|
||||
// Copyright 2024 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 config |
||||
|
||||
import ( |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestGenerateChecksum(t *testing.T) { |
||||
tmpDir := t.TempDir() |
||||
|
||||
// Define paths for the temporary files.
|
||||
yamlFilePath := filepath.Join(tmpDir, "test.yml") |
||||
ruleFilePath := filepath.Join(tmpDir, "rule_file.yml") |
||||
scrapeConfigFilePath := filepath.Join(tmpDir, "scrape_config.yml") |
||||
|
||||
// Define initial and modified content for the files.
|
||||
originalRuleContent := "groups:\n- name: example\n rules:\n - alert: ExampleAlert" |
||||
modifiedRuleContent := "groups:\n- name: example\n rules:\n - alert: ModifiedAlert" |
||||
|
||||
originalScrapeConfigContent := "scrape_configs:\n- job_name: example" |
||||
modifiedScrapeConfigContent := "scrape_configs:\n- job_name: modified_example" |
||||
|
||||
// Define YAML content referencing the rule and scrape config files.
|
||||
yamlContent := ` |
||||
rule_files: |
||||
- rule_file.yml |
||||
scrape_config_files: |
||||
- scrape_config.yml |
||||
` |
||||
|
||||
// Write initial content to files.
|
||||
require.NoError(t, os.WriteFile(ruleFilePath, []byte(originalRuleContent), 0o644)) |
||||
require.NoError(t, os.WriteFile(scrapeConfigFilePath, []byte(originalScrapeConfigContent), 0o644)) |
||||
require.NoError(t, os.WriteFile(yamlFilePath, []byte(yamlContent), 0o644)) |
||||
|
||||
// Generate the original checksum.
|
||||
originalChecksum := calculateChecksum(t, yamlFilePath) |
||||
|
||||
t.Run("Rule File Change", func(t *testing.T) { |
||||
// Modify the rule file.
|
||||
require.NoError(t, os.WriteFile(ruleFilePath, []byte(modifiedRuleContent), 0o644)) |
||||
|
||||
// Checksum should change.
|
||||
modifiedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, modifiedChecksum) |
||||
|
||||
// Revert the rule file.
|
||||
require.NoError(t, os.WriteFile(ruleFilePath, []byte(originalRuleContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Scrape Config Change", func(t *testing.T) { |
||||
// Modify the scrape config file.
|
||||
require.NoError(t, os.WriteFile(scrapeConfigFilePath, []byte(modifiedScrapeConfigContent), 0o644)) |
||||
|
||||
// Checksum should change.
|
||||
modifiedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, modifiedChecksum) |
||||
|
||||
// Revert the scrape config file.
|
||||
require.NoError(t, os.WriteFile(scrapeConfigFilePath, []byte(originalScrapeConfigContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Rule File Deletion", func(t *testing.T) { |
||||
// Delete the rule file.
|
||||
require.NoError(t, os.Remove(ruleFilePath)) |
||||
|
||||
// Checksum should change.
|
||||
deletedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, deletedChecksum) |
||||
|
||||
// Restore the rule file.
|
||||
require.NoError(t, os.WriteFile(ruleFilePath, []byte(originalRuleContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Scrape Config Deletion", func(t *testing.T) { |
||||
// Delete the scrape config file.
|
||||
require.NoError(t, os.Remove(scrapeConfigFilePath)) |
||||
|
||||
// Checksum should change.
|
||||
deletedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, deletedChecksum) |
||||
|
||||
// Restore the scrape config file.
|
||||
require.NoError(t, os.WriteFile(scrapeConfigFilePath, []byte(originalScrapeConfigContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Main File Change", func(t *testing.T) { |
||||
// Modify the main YAML file.
|
||||
modifiedYamlContent := ` |
||||
global: |
||||
scrape_interval: 3s |
||||
rule_files: |
||||
- rule_file.yml |
||||
scrape_config_files: |
||||
- scrape_config.yml |
||||
` |
||||
require.NoError(t, os.WriteFile(yamlFilePath, []byte(modifiedYamlContent), 0o644)) |
||||
|
||||
// Checksum should change.
|
||||
modifiedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, modifiedChecksum) |
||||
|
||||
// Revert the main YAML file.
|
||||
require.NoError(t, os.WriteFile(yamlFilePath, []byte(yamlContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Rule File Removed from YAML Config", func(t *testing.T) { |
||||
// Modify the YAML content to remove the rule file.
|
||||
modifiedYamlContent := ` |
||||
scrape_config_files: |
||||
- scrape_config.yml |
||||
` |
||||
require.NoError(t, os.WriteFile(yamlFilePath, []byte(modifiedYamlContent), 0o644)) |
||||
|
||||
// Checksum should change.
|
||||
modifiedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, modifiedChecksum) |
||||
|
||||
// Revert the YAML content.
|
||||
require.NoError(t, os.WriteFile(yamlFilePath, []byte(yamlContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Scrape Config Removed from YAML Config", func(t *testing.T) { |
||||
// Modify the YAML content to remove the scrape config file.
|
||||
modifiedYamlContent := ` |
||||
rule_files: |
||||
- rule_file.yml |
||||
` |
||||
require.NoError(t, os.WriteFile(yamlFilePath, []byte(modifiedYamlContent), 0o644)) |
||||
|
||||
// Checksum should change.
|
||||
modifiedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, modifiedChecksum) |
||||
|
||||
// Revert the YAML content.
|
||||
require.NoError(t, os.WriteFile(yamlFilePath, []byte(yamlContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Empty Rule File", func(t *testing.T) { |
||||
// Write an empty rule file.
|
||||
require.NoError(t, os.WriteFile(ruleFilePath, []byte(""), 0o644)) |
||||
|
||||
// Checksum should change.
|
||||
emptyChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, emptyChecksum) |
||||
|
||||
// Restore the rule file.
|
||||
require.NoError(t, os.WriteFile(ruleFilePath, []byte(originalRuleContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
|
||||
t.Run("Empty Scrape Config File", func(t *testing.T) { |
||||
// Write an empty scrape config file.
|
||||
require.NoError(t, os.WriteFile(scrapeConfigFilePath, []byte(""), 0o644)) |
||||
|
||||
// Checksum should change.
|
||||
emptyChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.NotEqual(t, originalChecksum, emptyChecksum) |
||||
|
||||
// Restore the scrape config file.
|
||||
require.NoError(t, os.WriteFile(scrapeConfigFilePath, []byte(originalScrapeConfigContent), 0o644)) |
||||
|
||||
// Checksum should return to the original.
|
||||
revertedChecksum := calculateChecksum(t, yamlFilePath) |
||||
require.Equal(t, originalChecksum, revertedChecksum) |
||||
}) |
||||
} |
||||
|
||||
// calculateChecksum generates a checksum for the given YAML file path.
|
||||
func calculateChecksum(t *testing.T, yamlFilePath string) string { |
||||
checksum, err := GenerateChecksum(yamlFilePath) |
||||
require.NoError(t, err) |
||||
require.NotEmpty(t, checksum) |
||||
return checksum |
||||
} |
@ -0,0 +1,37 @@
|
||||
// Copyright 2024 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 prometheusremotewrite |
||||
|
||||
import "context" |
||||
|
||||
// everyNTimes supports checking for context error every n times.
|
||||
type everyNTimes struct { |
||||
n int |
||||
i int |
||||
err error |
||||
} |
||||
|
||||
// checkContext calls ctx.Err() every e.n times and returns an eventual error.
|
||||
func (e *everyNTimes) checkContext(ctx context.Context) error { |
||||
if e.err != nil { |
||||
return e.err |
||||
} |
||||
|
||||
e.i++ |
||||
if e.i >= e.n { |
||||
e.i = 0 |
||||
e.err = ctx.Err() |
||||
} |
||||
|
||||
return e.err |
||||
} |
@ -0,0 +1,40 @@
|
||||
// Copyright 2024 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 prometheusremotewrite |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestEveryNTimes(t *testing.T) { |
||||
const n = 128 |
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
e := &everyNTimes{ |
||||
n: n, |
||||
} |
||||
|
||||
for i := 0; i < n; i++ { |
||||
require.NoError(t, e.checkContext(ctx)) |
||||
} |
||||
|
||||
cancel() |
||||
for i := 0; i < n-1; i++ { |
||||
require.NoError(t, e.checkContext(ctx)) |
||||
} |
||||
require.EqualError(t, e.checkContext(ctx), context.Canceled.Error()) |
||||
// e should remember the error.
|
||||
require.EqualError(t, e.checkContext(ctx), context.Canceled.Error()) |
||||
} |
@ -0,0 +1,157 @@
|
||||
// Copyright 2024 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 v1 |
||||
|
||||
import ( |
||||
"strconv" |
||||
|
||||
"github.com/prometheus/prometheus/model/labels" |
||||
"github.com/prometheus/prometheus/promql/parser" |
||||
) |
||||
|
||||
// Take a Go PromQL AST and translate it to an object that's nicely JSON-serializable
|
||||
// for the tree view in the UI.
|
||||
// TODO: Could it make sense to do this via the normal JSON marshalling methods? Maybe
|
||||
// too UI-specific though.
|
||||
func translateAST(node parser.Expr) interface{} { |
||||
if node == nil { |
||||
return nil |
||||
} |
||||
|
||||
switch n := node.(type) { |
||||
case *parser.AggregateExpr: |
||||
return map[string]interface{}{ |
||||
"type": "aggregation", |
||||
"op": n.Op.String(), |
||||
"expr": translateAST(n.Expr), |
||||
"param": translateAST(n.Param), |
||||
"grouping": sanitizeList(n.Grouping), |
||||
"without": n.Without, |
||||
} |
||||
case *parser.BinaryExpr: |
||||
var matching interface{} |
||||
if m := n.VectorMatching; m != nil { |
||||
matching = map[string]interface{}{ |
||||
"card": m.Card.String(), |
||||
"labels": sanitizeList(m.MatchingLabels), |
||||
"on": m.On, |
||||
"include": sanitizeList(m.Include), |
||||
} |
||||
} |
||||
|
||||
return map[string]interface{}{ |
||||
"type": "binaryExpr", |
||||
"op": n.Op.String(), |
||||
"lhs": translateAST(n.LHS), |
||||
"rhs": translateAST(n.RHS), |
||||
"matching": matching, |
||||
"bool": n.ReturnBool, |
||||
} |
||||
case *parser.Call: |
||||
args := []interface{}{} |
||||
for _, arg := range n.Args { |
||||
args = append(args, translateAST(arg)) |
||||
} |
||||
|
||||
return map[string]interface{}{ |
||||
"type": "call", |
||||
"func": map[string]interface{}{ |
||||
"name": n.Func.Name, |
||||
"argTypes": n.Func.ArgTypes, |
||||
"variadic": n.Func.Variadic, |
||||
"returnType": n.Func.ReturnType, |
||||
}, |
||||
"args": args, |
||||
} |
||||
case *parser.MatrixSelector: |
||||
vs := n.VectorSelector.(*parser.VectorSelector) |
||||
return map[string]interface{}{ |
||||
"type": "matrixSelector", |
||||
"name": vs.Name, |
||||
"range": n.Range.Milliseconds(), |
||||
"offset": vs.OriginalOffset.Milliseconds(), |
||||
"matchers": translateMatchers(vs.LabelMatchers), |
||||
"timestamp": vs.Timestamp, |
||||
"startOrEnd": getStartOrEnd(vs.StartOrEnd), |
||||
} |
||||
case *parser.SubqueryExpr: |
||||
return map[string]interface{}{ |
||||
"type": "subquery", |
||||
"expr": translateAST(n.Expr), |
||||
"range": n.Range.Milliseconds(), |
||||
"offset": n.OriginalOffset.Milliseconds(), |
||||
"step": n.Step.Milliseconds(), |
||||
"timestamp": n.Timestamp, |
||||
"startOrEnd": getStartOrEnd(n.StartOrEnd), |
||||
} |
||||
case *parser.NumberLiteral: |
||||
return map[string]string{ |
||||
"type": "numberLiteral", |
||||
"val": strconv.FormatFloat(n.Val, 'f', -1, 64), |
||||
} |
||||
case *parser.ParenExpr: |
||||
return map[string]interface{}{ |
||||
"type": "parenExpr", |
||||
"expr": translateAST(n.Expr), |
||||
} |
||||
case *parser.StringLiteral: |
||||
return map[string]interface{}{ |
||||
"type": "stringLiteral", |
||||
"val": n.Val, |
||||
} |
||||
case *parser.UnaryExpr: |
||||
return map[string]interface{}{ |
||||
"type": "unaryExpr", |
||||
"op": n.Op.String(), |
||||
"expr": translateAST(n.Expr), |
||||
} |
||||
case *parser.VectorSelector: |
||||
return map[string]interface{}{ |
||||
"type": "vectorSelector", |
||||
"name": n.Name, |
||||
"offset": n.OriginalOffset.Milliseconds(), |
||||
"matchers": translateMatchers(n.LabelMatchers), |
||||
"timestamp": n.Timestamp, |
||||
"startOrEnd": getStartOrEnd(n.StartOrEnd), |
||||
} |
||||
} |
||||
panic("unsupported node type") |
||||
} |
||||
|
||||
func sanitizeList(l []string) []string { |
||||
if l == nil { |
||||
return []string{} |
||||
} |
||||
return l |
||||
} |
||||
|
||||
func translateMatchers(in []*labels.Matcher) interface{} { |
||||
out := []map[string]interface{}{} |
||||
for _, m := range in { |
||||
out = append(out, map[string]interface{}{ |
||||
"name": m.Name, |
||||
"value": m.Value, |
||||
"type": m.Type.String(), |
||||
}) |
||||
} |
||||
return out |
||||
} |
||||
|
||||
func getStartOrEnd(startOrEnd parser.ItemType) interface{} { |
||||
if startOrEnd == 0 { |
||||
return nil |
||||
} |
||||
|
||||
return startOrEnd.String() |
||||
} |
@ -0,0 +1,24 @@
|
||||
# Logs |
||||
logs |
||||
*.log |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
pnpm-debug.log* |
||||
lerna-debug.log* |
||||
|
||||
node_modules |
||||
dist |
||||
dist-ssr |
||||
*.local |
||||
|
||||
# Editor directories and files |
||||
.vscode/* |
||||
!.vscode/extensions.json |
||||
.idea |
||||
.DS_Store |
||||
*.suo |
||||
*.ntvs* |
||||
*.njsproj |
||||
*.sln |
||||
*.sw? |
@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite |
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. |
||||
|
||||
Currently, two official plugins are available: |
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh |
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh |
||||
|
||||
## Expanding the ESLint configuration |
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: |
||||
|
||||
- Configure the top-level `parserOptions` property like this: |
||||
|
||||
```js |
||||
export default { |
||||
// other rules... |
||||
parserOptions: { |
||||
ecmaVersion: 'latest', |
||||
sourceType: 'module', |
||||
project: ['./tsconfig.json', './tsconfig.node.json'], |
||||
tsconfigRootDir: __dirname, |
||||
}, |
||||
} |
||||
``` |
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` |
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` |
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list |
@ -0,0 +1,71 @@
|
||||
import { fixupConfigRules } from '@eslint/compat'; |
||||
import reactRefresh from 'eslint-plugin-react-refresh'; |
||||
import globals from 'globals'; |
||||
import tsParser from '@typescript-eslint/parser'; |
||||
import path from 'node:path'; |
||||
import { fileURLToPath } from 'node:url'; |
||||
import js from '@eslint/js'; |
||||
import { FlatCompat } from '@eslint/eslintrc'; |
||||
|
||||
const __filename = fileURLToPath(import.meta.url); |
||||
const __dirname = path.dirname(__filename); |
||||
const compat = new FlatCompat({ |
||||
baseDirectory: __dirname, |
||||
recommendedConfig: js.configs.recommended, |
||||
allConfig: js.configs.all |
||||
}); |
||||
|
||||
export default [{ |
||||
ignores: ['**/dist', '**/.eslintrc.cjs'], |
||||
}, ...fixupConfigRules(compat.extends( |
||||
'eslint:recommended', |
||||
'plugin:@typescript-eslint/recommended', |
||||
'plugin:react-hooks/recommended', |
||||
)), { |
||||
plugins: { |
||||
'react-refresh': reactRefresh, |
||||
}, |
||||
|
||||
languageOptions: { |
||||
globals: { |
||||
...globals.browser, |
||||
}, |
||||
|
||||
parser: tsParser, |
||||
}, |
||||
|
||||
rules: { |
||||
'react-refresh/only-export-components': ['warn', { |
||||
allowConstantExport: true, |
||||
}], |
||||
|
||||
// Disable the base rule as it can report incorrect errors
|
||||
'no-unused-vars': 'off', |
||||
|
||||
// Use the TypeScript-specific rule for unused vars
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { |
||||
argsIgnorePattern: '^_', |
||||
varsIgnorePattern: '^_', |
||||
caughtErrorsIgnorePattern: '^_', |
||||
}], |
||||
|
||||
'prefer-const': ['error', { |
||||
destructuring: 'all', |
||||
}], |
||||
}, |
||||
}, |
||||
// Override for Node.js-based config files
|
||||
{ |
||||
files: ['postcss.config.cjs'], // Specify any other config files
|
||||
languageOptions: { |
||||
ecmaVersion: 2021, // Optional, set ECMAScript version
|
||||
sourceType: 'script', // For CommonJS (non-ESM) modules
|
||||
globals: { |
||||
module: 'readonly', |
||||
require: 'readonly', |
||||
process: 'readonly', |
||||
__dirname: 'readonly', // Include other Node.js globals if needed
|
||||
}, |
||||
}, |
||||
}, |
||||
]; |
@ -0,0 +1,35 @@
|
||||
<!doctype html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
|
||||
<!-- |
||||
Placeholders replaced by Prometheus during serving: |
||||
- GLOBAL_CONSOLES_LINK is replaced and set to the consoles link if it exists. |
||||
It will render a "Consoles" link in the navbar when it is non-empty. |
||||
- PROMETHEUS_AGENT_MODE is replaced by a boolean indicating if Prometheus is running in agent mode. |
||||
It true, it will disable querying capacities in the UI and generally adapt the UI to the agent mode. |
||||
It has to be represented as a string, because booleans can be mangled to !1 in production builds. |
||||
- PROMETHEUS_READY is replaced by a boolean indicating whether Prometheus was ready at the time the |
||||
web app was served. It has to be represented as a string, because booleans can be mangled to !1 in |
||||
production builds. |
||||
--> |
||||
<script> |
||||
const GLOBAL_CONSOLES_LINK='CONSOLES_LINK_PLACEHOLDER'; |
||||
const GLOBAL_AGENT_MODE='AGENT_MODE_PLACEHOLDER'; |
||||
const GLOBAL_READY='READY_PLACEHOLDER'; |
||||
</script> |
||||
|
||||
<!-- |
||||
The TITLE_PLACEHOLDER magic value is replaced during serving by Prometheus. |
||||
We need it dynamic because it can be overridden by the command line flag `web.page-title`. |
||||
--> |
||||
<title>TITLE_PLACEHOLDER</title> |
||||
</head> |
||||
<body> |
||||
<div id="root"></div> |
||||
<script type="module" src="/src/main.tsx"></script> |
||||
</body> |
||||
</html> |
@ -0,0 +1,72 @@
|
||||
{ |
||||
"name": "@prometheus-io/mantine-ui", |
||||
"private": true, |
||||
"version": "0.54.1", |
||||
"type": "module", |
||||
"scripts": { |
||||
"start": "vite", |
||||
"build": "tsc && vite build", |
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0", |
||||
"lint:fix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix", |
||||
"preview": "vite preview", |
||||
"test": "vitest" |
||||
}, |
||||
"dependencies": { |
||||
"@codemirror/autocomplete": "^6.18.0", |
||||
"@codemirror/language": "^6.10.2", |
||||
"@codemirror/lint": "^6.8.1", |
||||
"@codemirror/state": "^6.4.1", |
||||
"@codemirror/view": "^6.33.0", |
||||
"@floating-ui/dom": "^1.6.7", |
||||
"@lezer/common": "^1.2.1", |
||||
"@lezer/highlight": "^1.2.1", |
||||
"@mantine/code-highlight": "^7.11.2", |
||||
"@mantine/core": "^7.11.2", |
||||
"@mantine/dates": "^7.11.2", |
||||
"@mantine/hooks": "^7.11.2", |
||||
"@mantine/notifications": "^7.11.2", |
||||
"@nexucis/fuzzy": "^0.5.1", |
||||
"@nexucis/kvsearch": "^0.9.1", |
||||
"@prometheus-io/codemirror-promql": "^0.54.1", |
||||
"@reduxjs/toolkit": "^2.2.1", |
||||
"@tabler/icons-react": "^2.47.0", |
||||
"@tanstack/react-query": "^5.22.2", |
||||
"@testing-library/jest-dom": "^6.5.0", |
||||
"@testing-library/react": "^16.0.1", |
||||
"@types/lodash": "^4.17.7", |
||||
"@types/sanitize-html": "^2.13.0", |
||||
"@uiw/react-codemirror": "^4.23.1", |
||||
"clsx": "^2.1.1", |
||||
"dayjs": "^1.11.10", |
||||
"lodash": "^4.17.21", |
||||
"react": "^18.3.1", |
||||
"react-dom": "^18.3.1", |
||||
"react-infinite-scroll-component": "^6.1.0", |
||||
"react-redux": "^9.1.2", |
||||
"react-router-dom": "^6.26.1", |
||||
"sanitize-html": "^2.13.0", |
||||
"uplot": "^1.6.30", |
||||
"uplot-react": "^1.2.2", |
||||
"use-query-params": "^2.2.1" |
||||
}, |
||||
"devDependencies": { |
||||
"@eslint/compat": "^1.1.1", |
||||
"@eslint/eslintrc": "^3.1.0", |
||||
"@eslint/js": "^9.9.1", |
||||
"@types/react": "^18.3.5", |
||||
"@types/react-dom": "^18.3.0", |
||||
"@typescript-eslint/eslint-plugin": "^6.21.0", |
||||
"@typescript-eslint/parser": "^6.21.0", |
||||
"@vitejs/plugin-react": "^4.2.1", |
||||
"eslint": "^9.9.1", |
||||
"eslint-plugin-react-hooks": "^5.1.0-rc-e56f4ae3-20240830", |
||||
"eslint-plugin-react-refresh": "^0.4.11", |
||||
"globals": "^15.9.0", |
||||
"jsdom": "^25.0.0", |
||||
"postcss": "^8.4.35", |
||||
"postcss-preset-mantine": "^1.17.0", |
||||
"postcss-simple-vars": "^7.0.1", |
||||
"vite": "^5.1.0", |
||||
"vitest": "^2.0.5" |
||||
} |
||||
} |
@ -0,0 +1,14 @@
|
||||
module.exports = { |
||||
plugins: { |
||||
'postcss-preset-mantine': {}, |
||||
'postcss-simple-vars': { |
||||
variables: { |
||||
'mantine-breakpoint-xs': '36em', |
||||
'mantine-breakpoint-sm': '48em', |
||||
'mantine-breakpoint-md': '62em', |
||||
'mantine-breakpoint-lg': '75em', |
||||
'mantine-breakpoint-xl': '88em', |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
@ -0,0 +1,40 @@
|
||||
.control { |
||||
display: block; |
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); |
||||
border-radius: var(--mantine-radius-md); |
||||
font-weight: 500; |
||||
|
||||
@mixin hover { |
||||
background-color: var(--mantine-color-gray-8); |
||||
} |
||||
} |
||||
|
||||
.link { |
||||
display: block; |
||||
line-height: 1; |
||||
padding: rem(8px) rem(12px); |
||||
border-radius: var(--mantine-radius-sm); |
||||
text-decoration: none; |
||||
color: var(--mantine-color-gray-0); |
||||
font-size: var(--mantine-font-size-sm); |
||||
font-weight: 500; |
||||
background-color: transparent; |
||||
|
||||
@mixin hover { |
||||
background-color: var(--mantine-color-gray-6); |
||||
color: var(--mantine-color-gray-0); |
||||
} |
||||
|
||||
[data-mantine-color-scheme] &[aria-current="page"] { |
||||
background-color: var(--mantine-color-blue-filled); |
||||
color: var(--mantine-color-white); |
||||
} |
||||
} |
||||
|
||||
/* Font used for autocompletion item icons. */ |
||||
@font-face { |
||||
font-family: "codicon"; |
||||
src: |
||||
local("codicon"), |
||||
url(./fonts/codicon.ttf) format("truetype"); |
||||
} |
@ -0,0 +1,463 @@
|
||||
import "@mantine/core/styles.css"; |
||||
import "@mantine/code-highlight/styles.css"; |
||||
import "@mantine/notifications/styles.css"; |
||||
import "@mantine/dates/styles.css"; |
||||
import classes from "./App.module.css"; |
||||
import PrometheusLogo from "./images/prometheus-logo.svg"; |
||||
|
||||
import { |
||||
AppShell, |
||||
Box, |
||||
Burger, |
||||
Button, |
||||
Group, |
||||
MantineProvider, |
||||
Menu, |
||||
Skeleton, |
||||
Text, |
||||
createTheme, |
||||
rem, |
||||
} from "@mantine/core"; |
||||
import { useDisclosure } from "@mantine/hooks"; |
||||
import { |
||||
IconBell, |
||||
IconBellFilled, |
||||
IconChevronDown, |
||||
IconChevronRight, |
||||
IconCloudDataConnection, |
||||
IconDatabase, |
||||
IconDeviceDesktopAnalytics, |
||||
IconFlag, |
||||
IconHeartRateMonitor, |
||||
IconInfoCircle, |
||||
IconSearch, |
||||
IconServer, |
||||
IconServerCog, |
||||
} from "@tabler/icons-react"; |
||||
import { |
||||
BrowserRouter, |
||||
Link, |
||||
NavLink, |
||||
Navigate, |
||||
Route, |
||||
Routes, |
||||
} from "react-router-dom"; |
||||
import { IconTable } from "@tabler/icons-react"; |
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
||||
import QueryPage from "./pages/query/QueryPage"; |
||||
import AlertsPage from "./pages/AlertsPage"; |
||||
import RulesPage from "./pages/RulesPage"; |
||||
import TargetsPage from "./pages/targets/TargetsPage"; |
||||
import StatusPage from "./pages/StatusPage"; |
||||
import TSDBStatusPage from "./pages/TSDBStatusPage"; |
||||
import FlagsPage from "./pages/FlagsPage"; |
||||
import ConfigPage from "./pages/ConfigPage"; |
||||
import AgentPage from "./pages/AgentPage"; |
||||
import { Suspense, useEffect } from "react"; |
||||
import ErrorBoundary from "./components/ErrorBoundary"; |
||||
import { ThemeSelector } from "./components/ThemeSelector"; |
||||
import { Notifications } from "@mantine/notifications"; |
||||
import { useAppDispatch } from "./state/hooks"; |
||||
import { updateSettings, useSettings } from "./state/settingsSlice"; |
||||
import SettingsMenu from "./components/SettingsMenu"; |
||||
import ReadinessWrapper from "./components/ReadinessWrapper"; |
||||
import { QueryParamProvider } from "use-query-params"; |
||||
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6"; |
||||
import ServiceDiscoveryPage from "./pages/service-discovery/ServiceDiscoveryPage"; |
||||
import AlertmanagerDiscoveryPage from "./pages/AlertmanagerDiscoveryPage"; |
||||
|
||||
const queryClient = new QueryClient(); |
||||
|
||||
const navIconStyle = { width: rem(16), height: rem(16) }; |
||||
|
||||
const mainNavPages = [ |
||||
{ |
||||
title: "Query", |
||||
path: "/query", |
||||
icon: <IconSearch style={navIconStyle} />, |
||||
element: <QueryPage />, |
||||
inAgentMode: false, |
||||
}, |
||||
{ |
||||
title: "Alerts", |
||||
path: "/alerts", |
||||
icon: <IconBellFilled style={navIconStyle} />, |
||||
element: <AlertsPage />, |
||||
inAgentMode: false, |
||||
}, |
||||
]; |
||||
|
||||
const monitoringStatusPages = [ |
||||
{ |
||||
title: "Target health", |
||||
path: "/targets", |
||||
icon: <IconHeartRateMonitor style={navIconStyle} />, |
||||
element: <TargetsPage />, |
||||
inAgentMode: true, |
||||
}, |
||||
{ |
||||
title: "Rule health", |
||||
path: "/rules", |
||||
icon: <IconTable style={navIconStyle} />, |
||||
element: <RulesPage />, |
||||
inAgentMode: false, |
||||
}, |
||||
{ |
||||
title: "Service discovery", |
||||
path: "/service-discovery", |
||||
icon: <IconCloudDataConnection style={navIconStyle} />, |
||||
element: <ServiceDiscoveryPage />, |
||||
inAgentMode: true, |
||||
}, |
||||
{ |
||||
title: "Alertmanager discovery", |
||||
path: "/discovered-alertmanagers", |
||||
icon: <IconBell style={navIconStyle} />, |
||||
element: <AlertmanagerDiscoveryPage />, |
||||
inAgentMode: false, |
||||
}, |
||||
]; |
||||
|
||||
const serverStatusPages = [ |
||||
{ |
||||
title: "Runtime & build information", |
||||
path: "/status", |
||||
icon: <IconInfoCircle style={navIconStyle} />, |
||||
element: <StatusPage />, |
||||
inAgentMode: true, |
||||
}, |
||||
{ |
||||
title: "TSDB status", |
||||
path: "/tsdb-status", |
||||
icon: <IconDatabase style={navIconStyle} />, |
||||
element: <TSDBStatusPage />, |
||||
inAgentMode: false, |
||||
}, |
||||
{ |
||||
title: "Command-line flags", |
||||
path: "/flags", |
||||
icon: <IconFlag style={navIconStyle} />, |
||||
element: <FlagsPage />, |
||||
inAgentMode: true, |
||||
}, |
||||
{ |
||||
title: "Configuration", |
||||
path: "/config", |
||||
icon: <IconServerCog style={navIconStyle} />, |
||||
element: <ConfigPage />, |
||||
inAgentMode: true, |
||||
}, |
||||
]; |
||||
|
||||
const allStatusPages = [...monitoringStatusPages, ...serverStatusPages]; |
||||
|
||||
const theme = createTheme({ |
||||
colors: { |
||||
"codebox-bg": [ |
||||
"#f5f5f5", |
||||
"#e7e7e7", |
||||
"#cdcdcd", |
||||
"#b2b2b2", |
||||
"#9a9a9a", |
||||
"#8b8b8b", |
||||
"#848484", |
||||
"#717171", |
||||
"#656565", |
||||
"#575757", |
||||
], |
||||
}, |
||||
}); |
||||
|
||||
// This dynamically/generically determines the pathPrefix by stripping the first known
|
||||
// endpoint suffix from the window location path. It works out of the box for both direct
|
||||
// hosting and reverse proxy deployments with no additional configurations required.
|
||||
const getPathPrefix = (path: string) => { |
||||
if (path.endsWith("/")) { |
||||
path = path.slice(0, -1); |
||||
} |
||||
|
||||
const pagePaths = [ |
||||
...mainNavPages, |
||||
...allStatusPages, |
||||
{ path: "/agent" }, |
||||
].map((p) => p.path); |
||||
|
||||
const pagePath = pagePaths.find((p) => path.endsWith(p)); |
||||
return path.slice(0, path.length - (pagePath || "").length); |
||||
}; |
||||
|
||||
const navLinkXPadding = "md"; |
||||
|
||||
function App() { |
||||
const [opened, { toggle }] = useDisclosure(); |
||||
|
||||
const pathPrefix = getPathPrefix(window.location.pathname); |
||||
const dispatch = useAppDispatch(); |
||||
|
||||
useEffect(() => { |
||||
dispatch(updateSettings({ pathPrefix })); |
||||
}, [pathPrefix, dispatch]); |
||||
|
||||
const { agentMode, consolesLink } = useSettings(); |
||||
|
||||
const navLinks = ( |
||||
<> |
||||
{consolesLink && ( |
||||
<Button |
||||
component="a" |
||||
href={consolesLink} |
||||
className={classes.link} |
||||
leftSection={<IconDeviceDesktopAnalytics style={navIconStyle} />} |
||||
px={navLinkXPadding} |
||||
> |
||||
Consoles |
||||
</Button> |
||||
)} |
||||
|
||||
{mainNavPages |
||||
.filter((p) => !agentMode || p.inAgentMode) |
||||
.map((p) => ( |
||||
<Button |
||||
key={p.path} |
||||
component={NavLink} |
||||
to={p.path} |
||||
className={classes.link} |
||||
leftSection={p.icon} |
||||
px={navLinkXPadding} |
||||
> |
||||
{p.title} |
||||
</Button> |
||||
))} |
||||
|
||||
<Menu shadow="md" width={240}> |
||||
<Routes> |
||||
{allStatusPages |
||||
.filter((p) => !agentMode || p.inAgentMode) |
||||
.map((p) => ( |
||||
<Route |
||||
key={p.path} |
||||
path={p.path} |
||||
element={ |
||||
<Menu.Target> |
||||
<Button |
||||
component={NavLink} |
||||
to={p.path} |
||||
className={classes.link} |
||||
leftSection={p.icon} |
||||
rightSection={<IconChevronDown style={navIconStyle} />} |
||||
px={navLinkXPadding} |
||||
> |
||||
Status <IconChevronRight style={navIconStyle} /> {p.title} |
||||
</Button> |
||||
</Menu.Target> |
||||
} |
||||
/> |
||||
))} |
||||
<Route |
||||
path="*" |
||||
element={ |
||||
<Menu.Target> |
||||
<Button |
||||
component={NavLink} |
||||
to="/" |
||||
className={classes.link} |
||||
leftSection={<IconServer style={navIconStyle} />} |
||||
rightSection={<IconChevronDown style={navIconStyle} />} |
||||
onClick={(e) => { |
||||
e.preventDefault(); |
||||
}} |
||||
px={navLinkXPadding} |
||||
> |
||||
Status |
||||
</Button> |
||||
</Menu.Target> |
||||
} |
||||
/> |
||||
</Routes> |
||||
|
||||
<Menu.Dropdown> |
||||
<Menu.Label>Monitoring status</Menu.Label> |
||||
{monitoringStatusPages |
||||
.filter((p) => !agentMode || p.inAgentMode) |
||||
.map((p) => ( |
||||
<Menu.Item |
||||
key={p.path} |
||||
component={NavLink} |
||||
to={p.path} |
||||
leftSection={p.icon} |
||||
> |
||||
{p.title} |
||||
</Menu.Item> |
||||
))} |
||||
|
||||
<Menu.Divider /> |
||||
<Menu.Label>Server status</Menu.Label> |
||||
{serverStatusPages |
||||
.filter((p) => !agentMode || p.inAgentMode) |
||||
.map((p) => ( |
||||
<Menu.Item |
||||
key={p.path} |
||||
component={NavLink} |
||||
to={p.path} |
||||
leftSection={p.icon} |
||||
> |
||||
{p.title} |
||||
</Menu.Item> |
||||
))} |
||||
</Menu.Dropdown> |
||||
</Menu> |
||||
|
||||
{/* <Button |
||||
component="a" |
||||
href="https://prometheus.io/docs/prometheus/latest/getting_started/" |
||||
className={classes.link} |
||||
leftSection={<IconHelp style={navIconStyle} />} |
||||
target="_blank" |
||||
px={navLinkXPadding} |
||||
> |
||||
Help |
||||
</Button> */} |
||||
</> |
||||
); |
||||
|
||||
return ( |
||||
<BrowserRouter basename={pathPrefix}> |
||||
<QueryParamProvider adapter={ReactRouter6Adapter}> |
||||
<MantineProvider defaultColorScheme="auto" theme={theme}> |
||||
<Notifications position="top-right" /> |
||||
|
||||
<QueryClientProvider client={queryClient}> |
||||
<AppShell |
||||
header={{ height: 56 }} |
||||
navbar={{ |
||||
width: 300, |
||||
// TODO: On pages with a long title like "/status", the navbar
|
||||
// breaks in an ugly way for narrow windows. Fix this.
|
||||
breakpoint: "sm", |
||||
collapsed: { desktop: true, mobile: !opened }, |
||||
}} |
||||
padding="md" |
||||
> |
||||
<AppShell.Header bg="rgb(65, 73, 81)" c="#fff"> |
||||
<Group h="100%" px="md" wrap="nowrap"> |
||||
<Group |
||||
style={{ flex: 1 }} |
||||
justify="space-between" |
||||
wrap="nowrap" |
||||
> |
||||
<Group gap={65} wrap="nowrap"> |
||||
<Link |
||||
to="/" |
||||
style={{ textDecoration: "none", color: "white" }} |
||||
> |
||||
<Group gap={10} wrap="nowrap"> |
||||
<img src={PrometheusLogo} height={30} /> |
||||
<Text fz={20}>Prometheus{agentMode && " Agent"}</Text> |
||||
</Group> |
||||
</Link> |
||||
<Group gap={12} visibleFrom="sm" wrap="nowrap"> |
||||
{navLinks} |
||||
</Group> |
||||
</Group> |
||||
<Group visibleFrom="xs" wrap="nowrap"> |
||||
<ThemeSelector /> |
||||
<SettingsMenu /> |
||||
</Group> |
||||
</Group> |
||||
<Burger |
||||
opened={opened} |
||||
onClick={toggle} |
||||
hiddenFrom="sm" |
||||
size="sm" |
||||
color="gray.2" |
||||
/> |
||||
</Group> |
||||
</AppShell.Header> |
||||
|
||||
<AppShell.Navbar py="md" px={4} bg="rgb(65, 73, 81)" c="#fff"> |
||||
{navLinks} |
||||
<Group mt="md" hiddenFrom="xs" justify="center"> |
||||
<ThemeSelector /> |
||||
<SettingsMenu /> |
||||
</Group> |
||||
</AppShell.Navbar> |
||||
|
||||
<AppShell.Main> |
||||
<ErrorBoundary key={location.pathname}> |
||||
<Suspense |
||||
fallback={ |
||||
<Box mt="lg"> |
||||
{Array.from(Array(10), (_, i) => ( |
||||
<Skeleton |
||||
key={i} |
||||
height={40} |
||||
mb={15} |
||||
width={1000} |
||||
mx="auto" |
||||
/> |
||||
))} |
||||
</Box> |
||||
} |
||||
> |
||||
<Routes> |
||||
<Route |
||||
path="/" |
||||
element={ |
||||
<Navigate |
||||
to={agentMode ? "/agent" : "/query"} |
||||
replace |
||||
/> |
||||
} |
||||
/> |
||||
{agentMode ? ( |
||||
<Route |
||||
path="/agent" |
||||
element={ |
||||
<ReadinessWrapper> |
||||
<AgentPage /> |
||||
</ReadinessWrapper> |
||||
} |
||||
/> |
||||
) : ( |
||||
<> |
||||
<Route |
||||
path="/query" |
||||
element={ |
||||
<ReadinessWrapper> |
||||
<QueryPage /> |
||||
</ReadinessWrapper> |
||||
} |
||||
/> |
||||
<Route |
||||
path="/alerts" |
||||
element={ |
||||
<ReadinessWrapper> |
||||
<AlertsPage /> |
||||
</ReadinessWrapper> |
||||
} |
||||
/> |
||||
</> |
||||
)} |
||||
{allStatusPages.map((p) => ( |
||||
<Route |
||||
key={p.path} |
||||
path={p.path} |
||||
element={ |
||||
<ReadinessWrapper>{p.element}</ReadinessWrapper> |
||||
} |
||||
/> |
||||
))} |
||||
</Routes> |
||||
</Suspense> |
||||
</ErrorBoundary> |
||||
</AppShell.Main> |
||||
</AppShell> |
||||
{/* <ReactQueryDevtools initialIsOpen={false} /> */} |
||||
</QueryClientProvider> |
||||
</MantineProvider> |
||||
</QueryParamProvider> |
||||
</BrowserRouter> |
||||
); |
||||
} |
||||
|
||||
export default App; |
@ -0,0 +1,58 @@
|
||||
.statsBadge { |
||||
background-color: light-dark( |
||||
var(--mantine-color-gray-1), |
||||
var(--mantine-color-gray-8) |
||||
); |
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5)); |
||||
} |
||||
|
||||
.labelBadge { |
||||
background-color: light-dark( |
||||
var(--mantine-color-gray-1), |
||||
var(--mantine-color-gray-8) |
||||
); |
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5)); |
||||
} |
||||
|
||||
.healthOk { |
||||
background-color: light-dark( |
||||
var(--mantine-color-green-1), |
||||
var(--mantine-color-green-9) |
||||
); |
||||
color: light-dark(var(--mantine-color-green-9), var(--mantine-color-green-1)); |
||||
} |
||||
|
||||
.healthErr { |
||||
background-color: light-dark( |
||||
var(--mantine-color-red-1), |
||||
darken(var(--mantine-color-red-9), 0.25) |
||||
); |
||||
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-1)); |
||||
} |
||||
|
||||
.healthWarn { |
||||
background-color: light-dark( |
||||
var(--mantine-color-yellow-1), |
||||
var(--mantine-color-yellow-9) |
||||
); |
||||
color: light-dark( |
||||
var(--mantine-color-yellow-9), |
||||
var(--mantine-color-yellow-1) |
||||
); |
||||
} |
||||
|
||||
.healthInfo { |
||||
background-color: light-dark( |
||||
var(--mantine-color-blue-1), |
||||
var(--mantine-color-blue-9) |
||||
); |
||||
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1)); |
||||
} |
||||
|
||||
.healthUnknown { |
||||
background-color: light-dark( |
||||
var(--mantine-color-gray-2), |
||||
var(--mantine-color-gray-7) |
||||
); |
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4)); |
||||
} |
@ -0,0 +1,19 @@
|
||||
.panelHealthOk { |
||||
border-left: 5px solid |
||||
light-dark(var(--mantine-color-green-3), var(--mantine-color-green-8)) !important; |
||||
} |
||||
|
||||
.panelHealthErr { |
||||
border-left: 5px solid |
||||
light-dark(var(--mantine-color-red-3), var(--mantine-color-red-9)) !important; |
||||
} |
||||
|
||||
.panelHealthWarn { |
||||
border-left: 5px solid |
||||
light-dark(var(--mantine-color-orange-3), var(--mantine-color-yellow-9)) !important; |
||||
} |
||||
|
||||
.panelHealthUnknown { |
||||
border-left: 5px solid |
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-6)) !important; |
||||
} |
@ -0,0 +1,128 @@
|
||||
import { QueryKey, useQuery, useSuspenseQuery } from "@tanstack/react-query"; |
||||
import { useSettings } from "../state/settingsSlice"; |
||||
|
||||
export const API_PATH = "api/v1"; |
||||
|
||||
export type SuccessAPIResponse<T> = { |
||||
status: "success"; |
||||
data: T; |
||||
warnings?: string[]; |
||||
infos?: string[]; |
||||
}; |
||||
|
||||
export type ErrorAPIResponse = { |
||||
status: "error"; |
||||
errorType: string; |
||||
error: string; |
||||
}; |
||||
|
||||
export type APIResponse<T> = SuccessAPIResponse<T> | ErrorAPIResponse; |
||||
|
||||
const createQueryFn = |
||||
<T>({ |
||||
pathPrefix, |
||||
path, |
||||
params, |
||||
recordResponseTime, |
||||
}: { |
||||
pathPrefix: string; |
||||
path: string; |
||||
params?: Record<string, string>; |
||||
recordResponseTime?: (time: number) => void; |
||||
}) => |
||||
async ({ signal }: { signal: AbortSignal }) => { |
||||
const queryString = params |
||||
? `?${new URLSearchParams(params).toString()}` |
||||
: ""; |
||||
|
||||
try { |
||||
const startTime = Date.now(); |
||||
|
||||
const res = await fetch( |
||||
`${pathPrefix}/${API_PATH}${path}${queryString}`, |
||||
{ |
||||
cache: "no-store", |
||||
credentials: "same-origin", |
||||
signal, |
||||
} |
||||
); |
||||
|
||||
if ( |
||||
!res.ok && |
||||
!res.headers.get("content-type")?.startsWith("application/json") |
||||
) { |
||||
// For example, Prometheus may send a 503 Service Unavailable response
|
||||
// with a "text/plain" content type when it's starting up. But the API
|
||||
// may also respond with a JSON error message and the same error code.
|
||||
throw new Error(res.statusText); |
||||
} |
||||
|
||||
const apiRes = (await res.json()) as APIResponse<T>; |
||||
|
||||
if (recordResponseTime) { |
||||
recordResponseTime(Date.now() - startTime); |
||||
} |
||||
|
||||
if (apiRes.status === "error") { |
||||
throw new Error( |
||||
apiRes.error !== undefined |
||||
? apiRes.error |
||||
: 'missing "error" field in response JSON' |
||||
); |
||||
} |
||||
|
||||
return apiRes as SuccessAPIResponse<T>; |
||||
} catch (error) { |
||||
if (!(error instanceof Error)) { |
||||
throw new Error("Unknown error"); |
||||
} |
||||
|
||||
switch (error.name) { |
||||
case "TypeError": |
||||
throw new Error("Network error or unable to reach the server"); |
||||
case "SyntaxError": |
||||
throw new Error("Invalid JSON response"); |
||||
default: |
||||
throw error; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
type QueryOptions = { |
||||
key?: QueryKey; |
||||
path: string; |
||||
params?: Record<string, string>; |
||||
enabled?: boolean; |
||||
recordResponseTime?: (time: number) => void; |
||||
}; |
||||
|
||||
export const useAPIQuery = <T>({ |
||||
key, |
||||
path, |
||||
params, |
||||
enabled, |
||||
recordResponseTime, |
||||
}: QueryOptions) => { |
||||
const { pathPrefix } = useSettings(); |
||||
|
||||
return useQuery<SuccessAPIResponse<T>>({ |
||||
queryKey: key !== undefined ? key : [path, params], |
||||
retry: false, |
||||
refetchOnWindowFocus: false, |
||||
gcTime: 0, |
||||
enabled, |
||||
queryFn: createQueryFn({ pathPrefix, path, params, recordResponseTime }), |
||||
}); |
||||
}; |
||||
|
||||
export const useSuspenseAPIQuery = <T>({ key, path, params }: QueryOptions) => { |
||||
const { pathPrefix } = useSettings(); |
||||
|
||||
return useSuspenseQuery<SuccessAPIResponse<T>>({ |
||||
queryKey: key !== undefined ? key : [path, params], |
||||
retry: false, |
||||
refetchOnWindowFocus: false, |
||||
gcTime: 0, |
||||
queryFn: createQueryFn({ pathPrefix, path, params }), |
||||
}); |
||||
}; |
@ -0,0 +1,10 @@
|
||||
export type AlertmanagerTarget = { |
||||
url: string; |
||||
}; |
||||
|
||||
// Result type for /api/v1/alertmanagers endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#alertmanagers
|
||||
export type AlertmanagersResult = { |
||||
activeAlertmanagers: AlertmanagerTarget[]; |
||||
droppedAlertmanagers: AlertmanagerTarget[]; |
||||
}; |
@ -0,0 +1,5 @@
|
||||
// Result type for /api/v1/status/config endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#config
|
||||
export default interface ConfigResult { |
||||
yaml: string; |
||||
} |
@ -0,0 +1,3 @@
|
||||
// Result type for /api/v1/label/<label_name>/values endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#querying-label-values
|
||||
export type LabelValuesResult = string[]; |
@ -0,0 +1,6 @@
|
||||
// Result type for /api/v1/alerts endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#querying-target-metadata
|
||||
export type MetadataResult = Record< |
||||
string, |
||||
{ type: string; help: string; unit: string }[] |
||||
>; |
@ -0,0 +1,51 @@
|
||||
export interface Metric { |
||||
[key: string]: string; |
||||
} |
||||
|
||||
export interface Histogram { |
||||
count: string; |
||||
sum: string; |
||||
buckets?: [number, string, string, string][]; |
||||
} |
||||
|
||||
export interface InstantSample { |
||||
metric: Metric; |
||||
value?: SampleValue; |
||||
histogram?: SampleHistogram; |
||||
} |
||||
|
||||
export interface RangeSamples { |
||||
metric: Metric; |
||||
values?: SampleValue[]; |
||||
histograms?: SampleHistogram[]; |
||||
} |
||||
|
||||
export type SampleValue = [number, string]; |
||||
export type SampleHistogram = [number, Histogram]; |
||||
|
||||
// Result type for /api/v1/query endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#instant-queries
|
||||
export type InstantQueryResult = |
||||
| { |
||||
resultType: "vector"; |
||||
result: InstantSample[]; |
||||
} |
||||
| { |
||||
resultType: "matrix"; |
||||
result: RangeSamples[]; |
||||
} |
||||
| { |
||||
resultType: "scalar"; |
||||
result: SampleValue; |
||||
} |
||||
| { |
||||
resultType: "string"; |
||||
result: SampleValue; |
||||
}; |
||||
|
||||
// Result type for /api/v1/query_range endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries
|
||||
export type RangeQueryResult = { |
||||
resultType: "matrix"; |
||||
result: RangeSamples[]; |
||||
}; |
@ -0,0 +1,63 @@
|
||||
type RuleState = "pending" | "firing" | "inactive"; |
||||
|
||||
export interface Alert { |
||||
labels: Record<string, string>; |
||||
state: RuleState; |
||||
value: string; |
||||
annotations: Record<string, string>; |
||||
activeAt: string; |
||||
keepFiringSince: string; |
||||
} |
||||
|
||||
type CommonRuleFields = { |
||||
name: string; |
||||
query: string; |
||||
evaluationTime: string; |
||||
health: "ok" | "unknown" | "err"; |
||||
lastError?: string; |
||||
lastEvaluation: string; |
||||
}; |
||||
|
||||
export type AlertingRule = { |
||||
type: "alerting"; |
||||
// For alerting rules, the 'labels' field is always present, even when there are no labels.
|
||||
labels: Record<string, string>; |
||||
annotations: Record<string, string>; |
||||
duration: number; |
||||
keepFiringFor: number; |
||||
state: RuleState; |
||||
alerts: Alert[]; |
||||
} & CommonRuleFields; |
||||
|
||||
type RecordingRule = { |
||||
type: "recording"; |
||||
// For recording rules, the 'labels' field is only present when there are labels.
|
||||
labels?: Record<string, string>; |
||||
} & CommonRuleFields; |
||||
|
||||
export type Rule = AlertingRule | RecordingRule; |
||||
|
||||
interface RuleGroup { |
||||
name: string; |
||||
file: string; |
||||
interval: string; |
||||
rules: Rule[]; |
||||
evaluationTime: string; |
||||
lastEvaluation: string; |
||||
} |
||||
|
||||
export type AlertingRuleGroup = Omit<RuleGroup, "rules"> & { |
||||
rules: AlertingRule[]; |
||||
}; |
||||
|
||||
// Result type for /api/v1/alerts endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#alerts
|
||||
export interface RulesResult { |
||||
groups: RuleGroup[]; |
||||
} |
||||
|
||||
// Same as RulesResult above, but can be used when the caller ensures via a
|
||||
// "type=alert" query parameter that all rules are alerting rules.
|
||||
export interface AlertingRulesResult { |
||||
groups: AlertingRuleGroup[]; |
||||
} |
@ -0,0 +1,2 @@
|
||||
// Result type for /api/v1/scrape_pools endpoint.
|
||||
export type ScrapePoolsResult = { scrapePools: string[] }; |
@ -0,0 +1,6 @@
|
||||
// Result type for /api/v1/series endpoint.
|
||||
|
||||
import { Metric } from "./query"; |
||||
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers
|
||||
export type SeriesResult = Metric[]; |
@ -0,0 +1,29 @@
|
||||
export interface Labels { |
||||
[key: string]: string; |
||||
} |
||||
|
||||
export type Target = { |
||||
discoveredLabels: Labels; |
||||
labels: Labels; |
||||
scrapePool: string; |
||||
scrapeUrl: string; |
||||
globalUrl: string; |
||||
lastError: string; |
||||
lastScrape: string; |
||||
lastScrapeDuration: number; |
||||
health: string; |
||||
scrapeInterval: string; |
||||
scrapeTimeout: string; |
||||
}; |
||||
|
||||
export interface DroppedTarget { |
||||
discoveredLabels: Labels; |
||||
} |
||||
|
||||
// Result type for /api/v1/targets endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#targets
|
||||
export type TargetsResult = { |
||||
activeTargets: Target[]; |
||||
droppedTargets: DroppedTarget[]; |
||||
droppedTargetCounts: Record<string, number>; |
||||
}; |
@ -0,0 +1,22 @@
|
||||
interface Stats { |
||||
name: string; |
||||
value: number; |
||||
} |
||||
|
||||
interface HeadStats { |
||||
numSeries: number; |
||||
numLabelPairs: number; |
||||
chunkCount: number; |
||||
minTime: number; |
||||
maxTime: number; |
||||
} |
||||
|
||||
// Result type for /api/v1/status/tsdb endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#tsdb-stats
|
||||
export interface TSDBStatusResult { |
||||
headStats: HeadStats; |
||||
seriesCountByMetricName: Stats[]; |
||||
labelValueCountByLabelName: Stats[]; |
||||
memoryInBytesByLabelName: Stats[]; |
||||
seriesCountByLabelValuePair: Stats[]; |
||||
} |
@ -0,0 +1,7 @@
|
||||
// Result type for /api/v1/status/walreplay endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#wal-replay-stats
|
||||
export interface WALReplayStatus { |
||||
min: number; |
||||
max: number; |
||||
current: number; |
||||
} |
@ -0,0 +1,323 @@
|
||||
import { HighlightStyle } from "@codemirror/language"; |
||||
import { EditorView } from "@codemirror/view"; |
||||
import { tags } from "@lezer/highlight"; |
||||
|
||||
export const baseTheme = EditorView.theme({ |
||||
".cm-content": { |
||||
paddingTop: "3px", |
||||
paddingBottom: "0px", |
||||
}, |
||||
"&.cm-editor": { |
||||
"&.cm-focused": { |
||||
outline: "none", |
||||
outline_fallback: "none", |
||||
}, |
||||
backgroundColor: "transparent", |
||||
}, |
||||
".cm-scroller": { |
||||
overflow: "hidden", |
||||
fontFamily: '"DejaVu Sans Mono", monospace', |
||||
}, |
||||
".cm-placeholder": { |
||||
fontFamily: |
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"', |
||||
}, |
||||
|
||||
".cm-matchingBracket": { |
||||
fontWeight: "bold", |
||||
outline: "1px dashed transparent", |
||||
}, |
||||
".cm-nonmatchingBracket": { borderColor: "red" }, |
||||
|
||||
".cm-tooltip.cm-tooltip-autocomplete": { |
||||
"& > ul": { |
||||
maxHeight: "350px", |
||||
fontFamily: '"DejaVu Sans Mono", monospace', |
||||
maxWidth: "unset", |
||||
}, |
||||
"& > ul > li": { |
||||
padding: "2px 1em 2px 3px", |
||||
}, |
||||
minWidth: "30%", |
||||
}, |
||||
|
||||
".cm-completionDetail": { |
||||
float: "right", |
||||
color: "#999", |
||||
}, |
||||
|
||||
".cm-tooltip.cm-completionInfo": { |
||||
padding: "10px", |
||||
fontFamily: |
||||
"'Open Sans', 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;", |
||||
border: "none", |
||||
minWidth: "250px", |
||||
maxWidth: "min-content", |
||||
}, |
||||
|
||||
".cm-completionInfo.cm-completionInfo-right": { |
||||
"&:before": { |
||||
content: "' '", |
||||
height: "0", |
||||
position: "absolute", |
||||
width: "0", |
||||
left: "-20px", |
||||
borderWidth: "10px", |
||||
borderStyle: "solid", |
||||
borderColor: "transparent", |
||||
}, |
||||
marginTop: "-11px", |
||||
marginLeft: "12px", |
||||
}, |
||||
".cm-completionInfo.cm-completionInfo-left": { |
||||
"&:before": { |
||||
content: "' '", |
||||
height: "0", |
||||
position: "absolute", |
||||
width: "0", |
||||
right: "-20px", |
||||
borderWidth: "10px", |
||||
borderStyle: "solid", |
||||
borderColor: "transparent", |
||||
}, |
||||
marginTop: "-11px", |
||||
marginRight: "12px", |
||||
}, |
||||
".cm-completionInfo.cm-completionInfo-right-narrow": { |
||||
"&:before": { |
||||
content: "' '", |
||||
height: "0", |
||||
position: "absolute", |
||||
width: "0", |
||||
top: "-20px", |
||||
borderWidth: "10px", |
||||
borderStyle: "solid", |
||||
borderColor: "transparent", |
||||
}, |
||||
marginTop: "10px", |
||||
marginLeft: "150px", |
||||
}, |
||||
".cm-completionMatchedText": { |
||||
textDecoration: "none", |
||||
fontWeight: "bold", |
||||
}, |
||||
|
||||
".cm-selectionMatch": { |
||||
backgroundColor: "#e6f3ff", |
||||
}, |
||||
|
||||
".cm-diagnostic": { |
||||
"&.cm-diagnostic-error": { |
||||
borderLeft: "3px solid #e65013", |
||||
}, |
||||
}, |
||||
|
||||
".cm-completionIcon": { |
||||
boxSizing: "content-box", |
||||
fontSize: "16px", |
||||
lineHeight: "1", |
||||
marginRight: "10px", |
||||
verticalAlign: "top", |
||||
"&:after": { content: "'\\ea88'" }, |
||||
fontFamily: "codicon", |
||||
paddingRight: "0", |
||||
opacity: "1", |
||||
}, |
||||
|
||||
".cm-completionIcon-function, .cm-completionIcon-method": { |
||||
"&:after": { content: "'\\ea8c'" }, |
||||
}, |
||||
".cm-completionIcon-class": { |
||||
"&:after": { content: "'○'" }, |
||||
}, |
||||
".cm-completionIcon-interface": { |
||||
"&:after": { content: "'◌'" }, |
||||
}, |
||||
".cm-completionIcon-variable": { |
||||
"&:after": { content: "'𝑥'" }, |
||||
}, |
||||
".cm-completionIcon-constant": { |
||||
"&:after": { content: "'\\eb5f'" }, |
||||
}, |
||||
".cm-completionIcon-type": { |
||||
"&:after": { content: "'𝑡'" }, |
||||
}, |
||||
".cm-completionIcon-enum": { |
||||
"&:after": { content: "'∪'" }, |
||||
}, |
||||
".cm-completionIcon-property": { |
||||
"&:after": { content: "'□'" }, |
||||
}, |
||||
".cm-completionIcon-keyword": { |
||||
"&:after": { content: "'\\eb62'" }, |
||||
}, |
||||
".cm-completionIcon-namespace": { |
||||
"&:after": { content: "'▢'" }, |
||||
}, |
||||
".cm-completionIcon-text": { |
||||
"&:after": { content: "'\\ea95'" }, |
||||
color: "#ee9d28", |
||||
}, |
||||
}); |
||||
|
||||
export const lightTheme = EditorView.theme( |
||||
{ |
||||
".cm-tooltip": { |
||||
backgroundColor: "#f8f8f8", |
||||
borderColor: "rgba(52, 79, 113, 0.2)", |
||||
}, |
||||
|
||||
".cm-tooltip.cm-tooltip-autocomplete": { |
||||
"& li:hover": { |
||||
backgroundColor: "#ddd", |
||||
}, |
||||
"& > ul > li[aria-selected]": { |
||||
backgroundColor: "#d6ebff", |
||||
color: "unset", |
||||
}, |
||||
}, |
||||
|
||||
".cm-tooltip.cm-completionInfo": { |
||||
backgroundColor: "#d6ebff", |
||||
}, |
||||
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right": { |
||||
"&:before": { |
||||
borderRightColor: "#d6ebff", |
||||
}, |
||||
}, |
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right-narrow": { |
||||
"&:before": { |
||||
borderBottomColor: "#d6ebff", |
||||
}, |
||||
}, |
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-left": { |
||||
"&:before": { |
||||
borderLeftColor: "#d6ebff", |
||||
}, |
||||
}, |
||||
|
||||
".cm-line": { |
||||
"&::selection": { |
||||
backgroundColor: "#add6ff", |
||||
}, |
||||
"& > span::selection": { |
||||
backgroundColor: "#add6ff", |
||||
}, |
||||
}, |
||||
|
||||
".cm-matchingBracket": { |
||||
color: "#000", |
||||
backgroundColor: "#dedede", |
||||
}, |
||||
|
||||
".cm-completionMatchedText": { |
||||
color: "#0066bf", |
||||
}, |
||||
|
||||
".cm-completionIcon": { |
||||
color: "#007acc", |
||||
}, |
||||
|
||||
".cm-completionIcon-constant": { |
||||
color: "#007acc", |
||||
}, |
||||
|
||||
".cm-completionIcon-function, .cm-completionIcon-method": { |
||||
color: "#652d90", |
||||
}, |
||||
|
||||
".cm-completionIcon-keyword": { |
||||
color: "#616161", |
||||
}, |
||||
}, |
||||
{ dark: false } |
||||
); |
||||
|
||||
export const darkTheme = EditorView.theme( |
||||
{ |
||||
".cm-content": { |
||||
caretColor: "#fff", |
||||
}, |
||||
|
||||
".cm-tooltip.cm-completionInfo": { |
||||
backgroundColor: "#333338", |
||||
}, |
||||
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right": { |
||||
"&:before": { |
||||
borderRightColor: "#333338", |
||||
}, |
||||
}, |
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right-narrow": { |
||||
"&:before": { |
||||
borderBottomColor: "#333338", |
||||
}, |
||||
}, |
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-left": { |
||||
"&:before": { |
||||
borderLeftColor: "#333338", |
||||
}, |
||||
}, |
||||
|
||||
".cm-line": { |
||||
"&::selection": { |
||||
backgroundColor: "#767676", |
||||
}, |
||||
"& > span::selection": { |
||||
backgroundColor: "#767676", |
||||
}, |
||||
}, |
||||
|
||||
".cm-matchingBracket, &.cm-focused .cm-matchingBracket": { |
||||
backgroundColor: "#616161", |
||||
}, |
||||
|
||||
".cm-completionMatchedText": { |
||||
color: "#7dd3fc", |
||||
}, |
||||
|
||||
".cm-completionIcon, .cm-completionIcon-constant": { |
||||
color: "#7dd3fc", |
||||
}, |
||||
|
||||
".cm-completionIcon-function, .cm-completionIcon-method": { |
||||
color: "#d8b4fe", |
||||
}, |
||||
|
||||
".cm-completionIcon-keyword": { |
||||
color: "#cbd5e1 !important", |
||||
}, |
||||
}, |
||||
{ dark: true } |
||||
); |
||||
|
||||
export const promqlHighlighter = HighlightStyle.define([ |
||||
{ tag: tags.number, color: "#09885a" }, |
||||
{ tag: tags.string, color: "#a31515" }, |
||||
{ tag: tags.keyword, color: "#008080" }, |
||||
{ tag: tags.function(tags.variableName), color: "#008080" }, |
||||
{ tag: tags.labelName, color: "#800000" }, |
||||
{ tag: tags.operator }, |
||||
{ tag: tags.modifier, color: "#008080" }, |
||||
{ tag: tags.paren }, |
||||
{ tag: tags.squareBracket }, |
||||
{ tag: tags.brace }, |
||||
{ tag: tags.invalid, color: "red" }, |
||||
{ tag: tags.comment, color: "#888", fontStyle: "italic" }, |
||||
]); |
||||
|
||||
export const darkPromqlHighlighter = HighlightStyle.define([ |
||||
{ tag: tags.number, color: "#22c55e" }, |
||||
{ tag: tags.string, color: "#fca5a5" }, |
||||
{ tag: tags.keyword, color: "#14bfad" }, |
||||
{ tag: tags.function(tags.variableName), color: "#14bfad" }, |
||||
{ tag: tags.labelName, color: "#ff8585" }, |
||||
{ tag: tags.operator }, |
||||
{ tag: tags.modifier, color: "#14bfad" }, |
||||
{ tag: tags.paren }, |
||||
{ tag: tags.squareBracket }, |
||||
{ tag: tags.brace }, |
||||
{ tag: tags.invalid, color: "#ff3d3d" }, |
||||
{ tag: tags.comment, color: "#9ca3af", fontStyle: "italic" }, |
||||
]); |
@ -0,0 +1,54 @@
|
||||
import { ComponentType, useEffect, useState } from "react"; |
||||
import InfiniteScroll from "react-infinite-scroll-component"; |
||||
|
||||
const initialNumberOfItemsDisplayed = 50; |
||||
|
||||
export interface InfiniteScrollItemsProps<T> { |
||||
items: T[]; |
||||
} |
||||
|
||||
interface CustomInfiniteScrollProps<T> { |
||||
allItems: T[]; |
||||
child: ComponentType<InfiniteScrollItemsProps<T>>; |
||||
} |
||||
|
||||
const CustomInfiniteScroll = <T,>({ |
||||
allItems, |
||||
child, |
||||
}: CustomInfiniteScrollProps<T>) => { |
||||
const [items, setItems] = useState<T[]>(allItems.slice(0, 50)); |
||||
const [index, setIndex] = useState<number>(initialNumberOfItemsDisplayed); |
||||
const [hasMore, setHasMore] = useState<boolean>( |
||||
allItems.length > initialNumberOfItemsDisplayed |
||||
); |
||||
const Child = child; |
||||
|
||||
useEffect(() => { |
||||
setItems(allItems.slice(0, initialNumberOfItemsDisplayed)); |
||||
setHasMore(allItems.length > initialNumberOfItemsDisplayed); |
||||
}, [allItems]); |
||||
|
||||
const fetchMoreData = () => { |
||||
if (items.length === allItems.length) { |
||||
setHasMore(false); |
||||
} else { |
||||
const newIndex = index + initialNumberOfItemsDisplayed; |
||||
setIndex(newIndex); |
||||
setItems(allItems.slice(0, newIndex)); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<InfiniteScroll |
||||
next={fetchMoreData} |
||||
hasMore={hasMore} |
||||
loader={<h4>loading...</h4>} |
||||
dataLength={items.length} |
||||
height={items.length > 25 ? "75vh" : ""} |
||||
> |
||||
<Child items={items} /> |
||||
</InfiniteScroll> |
||||
); |
||||
}; |
||||
|
||||
export default CustomInfiniteScroll; |
@ -0,0 +1,58 @@
|
||||
import { Anchor, Badge, Group, Stack } from "@mantine/core"; |
||||
import { FC } from "react"; |
||||
|
||||
export interface EndpointLinkProps { |
||||
endpoint: string; |
||||
globalUrl: string; |
||||
} |
||||
|
||||
const EndpointLink: FC<EndpointLinkProps> = ({ endpoint, globalUrl }) => { |
||||
let url: URL; |
||||
let search = ""; |
||||
let invalidURL = false; |
||||
try { |
||||
url = new URL(endpoint); |
||||
} catch (err: unknown) { |
||||
// In cases of IPv6 addresses with a Zone ID, URL may not be parseable.
|
||||
// See https://github.com/prometheus/prometheus/issues/9760
|
||||
// In this case, we attempt to prepare a synthetic URL with the
|
||||
// same query parameters, for rendering purposes.
|
||||
invalidURL = true; |
||||
if (endpoint.indexOf("?") > -1) { |
||||
search = endpoint.substring(endpoint.indexOf("?")); |
||||
} |
||||
url = new URL("http://0.0.0.0" + search); |
||||
} |
||||
|
||||
const { host, pathname, protocol, searchParams }: URL = url; |
||||
const params = Array.from(searchParams.entries()); |
||||
const displayLink = invalidURL |
||||
? endpoint.replace(search, "") |
||||
: `${protocol}//${host}${pathname}`; |
||||
return ( |
||||
<Stack gap={0}> |
||||
<Anchor size="sm" href={globalUrl} target="_blank"> |
||||
{displayLink} |
||||
</Anchor> |
||||
{params.length > 0 && ( |
||||
<Group gap="xs" mt="md"> |
||||
{params.map(([labelName, labelValue]: [string, string]) => { |
||||
return ( |
||||
<Badge |
||||
size="sm" |
||||
variant="light" |
||||
color="gray" |
||||
key={`${labelName}/${labelValue}`} |
||||
style={{ textTransform: "none" }} |
||||
> |
||||
{`${labelName}="${labelValue}"`} |
||||
</Badge> |
||||
); |
||||
})} |
||||
</Group> |
||||
)} |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
export default EndpointLink; |
@ -0,0 +1,58 @@
|
||||
import { Alert } from "@mantine/core"; |
||||
import { IconAlertTriangle } from "@tabler/icons-react"; |
||||
import { Component, ErrorInfo, ReactNode } from "react"; |
||||
import { useLocation } from "react-router-dom"; |
||||
|
||||
interface Props { |
||||
children?: ReactNode; |
||||
title?: string; |
||||
} |
||||
|
||||
interface State { |
||||
error: Error | null; |
||||
} |
||||
|
||||
class ErrorBoundary extends Component<Props, State> { |
||||
public state: State = { |
||||
error: null, |
||||
}; |
||||
|
||||
public static getDerivedStateFromError(error: Error): State { |
||||
// Update state so the next render will show the fallback UI.
|
||||
return { error }; |
||||
} |
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) { |
||||
console.error("Uncaught error:", error, errorInfo); |
||||
} |
||||
|
||||
public render() { |
||||
if (this.state.error !== null) { |
||||
return ( |
||||
<Alert |
||||
color="red" |
||||
title={this.props.title || "Error querying page data"} |
||||
icon={<IconAlertTriangle size={14} />} |
||||
maw={500} |
||||
mx="auto" |
||||
mt="lg" |
||||
> |
||||
<strong>Error:</strong> {this.state.error.message} |
||||
</Alert> |
||||
); |
||||
} |
||||
|
||||
return this.props.children; |
||||
} |
||||
} |
||||
|
||||
const ResettingErrorBoundary = (props: Props) => { |
||||
const location = useLocation(); |
||||
return ( |
||||
<ErrorBoundary key={location.pathname} title={props.title}> |
||||
{props.children} |
||||
</ErrorBoundary> |
||||
); |
||||
}; |
||||
|
||||
export default ResettingErrorBoundary; |
@ -0,0 +1,38 @@
|
||||
import { Badge, BadgeVariant, Group, MantineColor, Stack } from "@mantine/core"; |
||||
import { FC } from "react"; |
||||
import { escapeString } from "../lib/escapeString"; |
||||
import badgeClasses from "../Badge.module.css"; |
||||
|
||||
export interface LabelBadgesProps { |
||||
labels: Record<string, string>; |
||||
variant?: BadgeVariant; |
||||
color?: MantineColor; |
||||
wrapper?: typeof Group | typeof Stack; |
||||
} |
||||
|
||||
export const LabelBadges: FC<LabelBadgesProps> = ({ |
||||
labels, |
||||
variant, |
||||
color, |
||||
wrapper: Wrapper = Group, |
||||
}) => ( |
||||
<Wrapper gap="xs"> |
||||
{Object.entries(labels).map(([k, v]) => { |
||||
return ( |
||||
<Badge |
||||
variant={variant ? variant : "light"} |
||||
color={color ? color : undefined} |
||||
className={color ? undefined : badgeClasses.labelBadge} |
||||
styles={{ |
||||
label: { |
||||
textTransform: "none", |
||||
}, |
||||
}} |
||||
key={k} |
||||
> |
||||
{k}="{escapeString(v)}" |
||||
</Badge> |
||||
); |
||||
})} |
||||
</Wrapper> |
||||
); |
@ -0,0 +1,93 @@
|
||||
import { FC, PropsWithChildren, useEffect, useState } from "react"; |
||||
import { useAppDispatch } from "../state/hooks"; |
||||
import { updateSettings, useSettings } from "../state/settingsSlice"; |
||||
import { useSuspenseAPIQuery } from "../api/api"; |
||||
import { WALReplayStatus } from "../api/responseTypes/walreplay"; |
||||
import { Progress, Stack, Title } from "@mantine/core"; |
||||
import { useSuspenseQuery } from "@tanstack/react-query"; |
||||
|
||||
const ReadinessLoader: FC = () => { |
||||
const { pathPrefix } = useSettings(); |
||||
const dispatch = useAppDispatch(); |
||||
|
||||
// Query key is incremented every second to retrigger the status fetching.
|
||||
const [queryKey, setQueryKey] = useState(0); |
||||
|
||||
// Query readiness status.
|
||||
const { data: ready } = useSuspenseQuery<boolean>({ |
||||
queryKey: ["ready", queryKey], |
||||
retry: false, |
||||
refetchOnWindowFocus: false, |
||||
gcTime: 0, |
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => { |
||||
try { |
||||
const res = await fetch(`${pathPrefix}/-/ready`, { |
||||
cache: "no-store", |
||||
credentials: "same-origin", |
||||
signal, |
||||
}); |
||||
switch (res.status) { |
||||
case 200: |
||||
return true; |
||||
case 503: |
||||
return false; |
||||
default: |
||||
throw new Error(res.statusText); |
||||
} |
||||
} catch (error) { |
||||
throw new Error("Unexpected error while fetching ready status"); |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
// Query WAL replay status.
|
||||
const { |
||||
data: { |
||||
data: { min, max, current }, |
||||
}, |
||||
} = useSuspenseAPIQuery<WALReplayStatus>({ |
||||
path: "/status/walreplay", |
||||
key: ["walreplay", queryKey], |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
if (ready) { |
||||
dispatch(updateSettings({ ready: ready })); |
||||
} |
||||
}, [ready, dispatch]); |
||||
|
||||
useEffect(() => { |
||||
const interval = setInterval(() => setQueryKey((v) => v + 1), 1000); |
||||
return () => clearInterval(interval); |
||||
}, []); |
||||
|
||||
return ( |
||||
<Stack gap="lg" maw={1000} mx="auto" mt="xs"> |
||||
<Title order={2}>Starting up...</Title> |
||||
{max > 0 && ( |
||||
<> |
||||
<p> |
||||
Replaying WAL ({current}/{max}) |
||||
</p> |
||||
<Progress |
||||
size="xl" |
||||
animated |
||||
value={((current - min + 1) / (max - min + 1)) * 100} |
||||
/> |
||||
</> |
||||
)} |
||||
</Stack> |
||||
); |
||||
}; |
||||
|
||||
export const ReadinessWrapper: FC<PropsWithChildren> = ({ children }) => { |
||||
const { ready } = useSettings(); |
||||
|
||||
if (ready) { |
||||
return <>{children}</>; |
||||
} |
||||
|
||||
return <ReadinessLoader />; |
||||
}; |
||||
|
||||
export default ReadinessWrapper; |
@ -0,0 +1,15 @@
|
||||
.codebox { |
||||
background-color: light-dark( |
||||
var(--mantine-color-gray-1), |
||||
var(--mantine-color-gray-9) |
||||
); |
||||
} |
||||
|
||||
.queryButton { |
||||
opacity: 0; |
||||
transition: opacity 0.1s ease-in-out; |
||||
} |
||||
|
||||
.codebox:hover .queryButton { |
||||
opacity: 1; |
||||
} |
@ -0,0 +1,116 @@
|
||||
import { |
||||
ActionIcon, |
||||
Badge, |
||||
Box, |
||||
Card, |
||||
Group, |
||||
rem, |
||||
Table, |
||||
Tooltip, |
||||
useComputedColorScheme, |
||||
} from "@mantine/core"; |
||||
import { IconClockPause, IconClockPlay, IconSearch } from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
import { formatPrometheusDuration } from "../lib/formatTime"; |
||||
import codeboxClasses from "./RuleDefinition.module.css"; |
||||
import { Rule } from "../api/responseTypes/rules"; |
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror"; |
||||
import { syntaxHighlighting } from "@codemirror/language"; |
||||
import { |
||||
baseTheme, |
||||
darkPromqlHighlighter, |
||||
lightTheme, |
||||
promqlHighlighter, |
||||
} from "../codemirror/theme"; |
||||
import { PromQLExtension } from "@prometheus-io/codemirror-promql"; |
||||
import { LabelBadges } from "./LabelBadges"; |
||||
import { useSettings } from "../state/settingsSlice"; |
||||
|
||||
const promqlExtension = new PromQLExtension(); |
||||
|
||||
const RuleDefinition: FC<{ rule: Rule }> = ({ rule }) => { |
||||
const theme = useComputedColorScheme(); |
||||
const { pathPrefix } = useSettings(); |
||||
|
||||
return ( |
||||
<> |
||||
<Card p="xs" className={codeboxClasses.codebox} fz="sm" shadow="none"> |
||||
<CodeMirror |
||||
basicSetup={false} |
||||
value={rule.query} |
||||
editable={false} |
||||
extensions={[ |
||||
baseTheme, |
||||
lightTheme, |
||||
syntaxHighlighting( |
||||
theme === "light" ? promqlHighlighter : darkPromqlHighlighter |
||||
), |
||||
promqlExtension.asExtension(), |
||||
EditorView.lineWrapping, |
||||
]} |
||||
/> |
||||
|
||||
<Tooltip label={"Query rule expression"} withArrow position="top"> |
||||
<ActionIcon |
||||
pos="absolute" |
||||
top={7} |
||||
right={7} |
||||
variant="light" |
||||
onClick={() => { |
||||
window.open( |
||||
`${pathPrefix}/query?g0.expr=${encodeURIComponent(rule.query)}&g0.tab=1`, |
||||
"_blank" |
||||
); |
||||
}} |
||||
className={codeboxClasses.queryButton} |
||||
> |
||||
<IconSearch style={{ width: rem(14) }} /> |
||||
</ActionIcon> |
||||
</Tooltip> |
||||
</Card> |
||||
{rule.type === "alerting" && ( |
||||
<Group mt="lg" gap="xs"> |
||||
{rule.duration && ( |
||||
<Badge |
||||
variant="light" |
||||
styles={{ label: { textTransform: "none" } }} |
||||
leftSection={<IconClockPause size={12} />} |
||||
> |
||||
for: {formatPrometheusDuration(rule.duration * 1000)} |
||||
</Badge> |
||||
)} |
||||
{rule.keepFiringFor && ( |
||||
<Badge |
||||
variant="light" |
||||
styles={{ label: { textTransform: "none" } }} |
||||
leftSection={<IconClockPlay size={12} />} |
||||
> |
||||
keep_firing_for: {formatPrometheusDuration(rule.duration * 1000)} |
||||
</Badge> |
||||
)} |
||||
</Group> |
||||
)} |
||||
{rule.labels && Object.keys(rule.labels).length > 0 && ( |
||||
<Box mt="lg"> |
||||
<LabelBadges labels={rule.labels} /> |
||||
</Box> |
||||
)} |
||||
{rule.type === "alerting" && Object.keys(rule.annotations).length > 0 && ( |
||||
<Table mt="lg" fz="sm"> |
||||
<Table.Tbody> |
||||
{Object.entries(rule.annotations).map(([k, v]) => ( |
||||
<Table.Tr key={k}> |
||||
<Table.Th c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))"> |
||||
{k} |
||||
</Table.Th> |
||||
<Table.Td>{v}</Table.Td> |
||||
</Table.Tr> |
||||
))} |
||||
</Table.Tbody> |
||||
</Table> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default RuleDefinition; |
@ -0,0 +1,107 @@
|
||||
import { Popover, ActionIcon, Fieldset, Checkbox, Stack } from "@mantine/core"; |
||||
import { IconSettings } from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
import { useAppDispatch } from "../state/hooks"; |
||||
import { updateSettings, useSettings } from "../state/settingsSlice"; |
||||
|
||||
const SettingsMenu: FC = () => { |
||||
const { |
||||
useLocalTime, |
||||
enableQueryHistory, |
||||
enableAutocomplete, |
||||
enableSyntaxHighlighting, |
||||
enableLinter, |
||||
showAnnotations, |
||||
} = useSettings(); |
||||
const dispatch = useAppDispatch(); |
||||
|
||||
return ( |
||||
<Popover position="bottom" withArrow shadow="md"> |
||||
<Popover.Target> |
||||
<ActionIcon color="gray" aria-label="Settings" size={32}> |
||||
<IconSettings size={20} /> |
||||
</ActionIcon> |
||||
</Popover.Target> |
||||
<Popover.Dropdown> |
||||
<Stack> |
||||
<Fieldset p="md" legend="Global settings"> |
||||
<Checkbox |
||||
checked={useLocalTime} |
||||
label="Use local time" |
||||
onChange={(event) => |
||||
dispatch( |
||||
updateSettings({ useLocalTime: event.currentTarget.checked }) |
||||
) |
||||
} |
||||
/> |
||||
</Fieldset> |
||||
|
||||
<Fieldset p="md" legend="Query page settings"> |
||||
<Stack> |
||||
<Checkbox |
||||
checked={enableQueryHistory} |
||||
label="Enable query history" |
||||
onChange={(event) => |
||||
dispatch( |
||||
updateSettings({ |
||||
enableQueryHistory: event.currentTarget.checked, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
<Checkbox |
||||
checked={enableAutocomplete} |
||||
label="Enable autocomplete" |
||||
onChange={(event) => |
||||
dispatch( |
||||
updateSettings({ |
||||
enableAutocomplete: event.currentTarget.checked, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
<Checkbox |
||||
checked={enableSyntaxHighlighting} |
||||
label="Enable syntax highlighting" |
||||
onChange={(event) => |
||||
dispatch( |
||||
updateSettings({ |
||||
enableSyntaxHighlighting: event.currentTarget.checked, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
<Checkbox |
||||
checked={enableLinter} |
||||
label="Enable linter" |
||||
onChange={(event) => |
||||
dispatch( |
||||
updateSettings({ |
||||
enableLinter: event.currentTarget.checked, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
</Stack> |
||||
</Fieldset> |
||||
|
||||
<Fieldset p="md" legend="Alerts page settings"> |
||||
<Checkbox |
||||
checked={showAnnotations} |
||||
label="Show expanded annotations" |
||||
onChange={(event) => |
||||
dispatch( |
||||
updateSettings({ |
||||
showAnnotations: event.currentTarget.checked, |
||||
}) |
||||
) |
||||
} |
||||
/> |
||||
</Fieldset> |
||||
</Stack> |
||||
</Popover.Dropdown> |
||||
</Popover> |
||||
); |
||||
}; |
||||
|
||||
export default SettingsMenu; |
@ -0,0 +1,142 @@
|
||||
import { FC } from "react"; |
||||
import { |
||||
CheckIcon, |
||||
Combobox, |
||||
ComboboxChevron, |
||||
ComboboxClearButton, |
||||
Group, |
||||
Pill, |
||||
PillsInput, |
||||
useCombobox, |
||||
} from "@mantine/core"; |
||||
import { IconHeartRateMonitor } from "@tabler/icons-react"; |
||||
|
||||
interface StatePillProps extends React.ComponentPropsWithoutRef<"div"> { |
||||
value: string; |
||||
onRemove?: () => void; |
||||
} |
||||
|
||||
export function StatePill({ value, onRemove, ...others }: StatePillProps) { |
||||
return ( |
||||
<Pill |
||||
fw={600} |
||||
style={{ textTransform: "uppercase", fontWeight: 600 }} |
||||
onRemove={onRemove} |
||||
{...others} |
||||
withRemoveButton={!!onRemove} |
||||
> |
||||
{value} |
||||
</Pill> |
||||
); |
||||
} |
||||
|
||||
interface StateMultiSelectProps { |
||||
options: string[]; |
||||
optionClass: (option: string) => string; |
||||
optionCount?: (option: string) => number; |
||||
placeholder: string; |
||||
values: string[]; |
||||
onChange: (values: string[]) => void; |
||||
} |
||||
|
||||
export const StateMultiSelect: FC<StateMultiSelectProps> = ({ |
||||
options, |
||||
optionClass, |
||||
optionCount, |
||||
placeholder, |
||||
values, |
||||
onChange, |
||||
}) => { |
||||
const combobox = useCombobox({ |
||||
onDropdownClose: () => combobox.resetSelectedOption(), |
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"), |
||||
}); |
||||
|
||||
const handleValueSelect = (val: string) => |
||||
onChange( |
||||
values.includes(val) ? values.filter((v) => v !== val) : [...values, val] |
||||
); |
||||
|
||||
const handleValueRemove = (val: string) => |
||||
onChange(values.filter((v) => v !== val)); |
||||
|
||||
const renderedValues = values.map((item) => ( |
||||
<StatePill |
||||
value={optionCount ? `${item} (${optionCount(item)})` : item} |
||||
className={optionClass(item)} |
||||
onRemove={() => handleValueRemove(item)} |
||||
key={item} |
||||
/> |
||||
)); |
||||
|
||||
return ( |
||||
<Combobox |
||||
store={combobox} |
||||
onOptionSubmit={handleValueSelect} |
||||
withinPortal={false} |
||||
> |
||||
<Combobox.DropdownTarget> |
||||
<PillsInput |
||||
pointer |
||||
onClick={() => combobox.toggleDropdown()} |
||||
miw={200} |
||||
leftSection={<IconHeartRateMonitor size={14} />} |
||||
rightSection={ |
||||
values.length > 0 ? ( |
||||
<ComboboxClearButton onClear={() => onChange([])} /> |
||||
) : ( |
||||
<ComboboxChevron /> |
||||
) |
||||
} |
||||
> |
||||
<Pill.Group> |
||||
{renderedValues.length > 0 ? ( |
||||
renderedValues |
||||
) : ( |
||||
<PillsInput.Field placeholder={placeholder} mt={1} /> |
||||
)} |
||||
|
||||
<Combobox.EventsTarget> |
||||
<PillsInput.Field |
||||
type="hidden" |
||||
onBlur={() => combobox.closeDropdown()} |
||||
onKeyDown={(event) => { |
||||
if (event.key === "Backspace") { |
||||
event.preventDefault(); |
||||
handleValueRemove(values[values.length - 1]); |
||||
} |
||||
}} |
||||
/> |
||||
</Combobox.EventsTarget> |
||||
</Pill.Group> |
||||
</PillsInput> |
||||
</Combobox.DropdownTarget> |
||||
|
||||
<Combobox.Dropdown> |
||||
<Combobox.Options> |
||||
{options.map((value) => { |
||||
return ( |
||||
<Combobox.Option |
||||
value={value} |
||||
key={value} |
||||
active={values.includes(value)} |
||||
> |
||||
<Group gap="sm"> |
||||
{values.includes(value) ? ( |
||||
<CheckIcon size={12} color="gray" /> |
||||
) : null} |
||||
<StatePill |
||||
value={ |
||||
optionCount ? `${value} (${optionCount(value)})` : value |
||||
} |
||||
className={optionClass(value)} |
||||
/> |
||||
</Group> |
||||
</Combobox.Option> |
||||
); |
||||
})} |
||||
</Combobox.Options> |
||||
</Combobox.Dropdown> |
||||
</Combobox> |
||||
); |
||||
}; |
@ -0,0 +1,64 @@
|
||||
import { |
||||
useMantineColorScheme, |
||||
SegmentedControl, |
||||
rem, |
||||
MantineColorScheme, |
||||
Tooltip, |
||||
} from "@mantine/core"; |
||||
import { |
||||
IconMoonFilled, |
||||
IconSunFilled, |
||||
IconUserFilled, |
||||
} from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
|
||||
export const ThemeSelector: FC = () => { |
||||
const { colorScheme, setColorScheme } = useMantineColorScheme(); |
||||
const iconProps = { |
||||
style: { width: rem(20), height: rem(20), display: "block" }, |
||||
stroke: 1.5, |
||||
}; |
||||
|
||||
return ( |
||||
<SegmentedControl |
||||
color="gray.7" |
||||
size="xs" |
||||
// styles={{ root: { backgroundColor: "var(--mantine-color-gray-7)" } }}
|
||||
styles={{ |
||||
root: { |
||||
padding: 3, |
||||
backgroundColor: "var(--mantine-color-gray-6)", |
||||
}, |
||||
}} |
||||
withItemsBorders={false} |
||||
value={colorScheme} |
||||
onChange={(v) => setColorScheme(v as MantineColorScheme)} |
||||
data={[ |
||||
{ |
||||
value: "light", |
||||
label: ( |
||||
<Tooltip label="Use light theme" offset={15}> |
||||
<IconSunFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
{ |
||||
value: "dark", |
||||
label: ( |
||||
<Tooltip label="Use dark theme" offset={15}> |
||||
<IconMoonFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
{ |
||||
value: "auto", |
||||
label: ( |
||||
<Tooltip label="Use browser-preferred theme" offset={15}> |
||||
<IconUserFilled {...iconProps} /> |
||||
</Tooltip> |
||||
), |
||||
}, |
||||
]} |
||||
/> |
||||
); |
||||
}; |
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,4 @@
|
||||
// Used for escaping escape sequences and double quotes in double-quoted strings.
|
||||
export const escapeString = (str: string) => { |
||||
return str.replace(/([\\"])/g, "\\$1"); |
||||
}; |
@ -0,0 +1,21 @@
|
||||
export const parsePrometheusFloat = (str: string): number => { |
||||
switch (str) { |
||||
case "+Inf": |
||||
return Infinity; |
||||
case "-Inf": |
||||
return -Infinity; |
||||
default: |
||||
return parseFloat(str); |
||||
} |
||||
}; |
||||
|
||||
export const formatPrometheusFloat = (num: number): string => { |
||||
switch (num) { |
||||
case Infinity: |
||||
return "+Inf"; |
||||
case -Infinity: |
||||
return "-Inf"; |
||||
default: |
||||
return num.toString(); |
||||
} |
||||
}; |
@ -0,0 +1,12 @@
|
||||
import { escapeString } from "./escapeString"; |
||||
|
||||
export const formatSeries = (labels: { [key: string]: string }): string => { |
||||
if (labels === null) { |
||||
return "scalar"; |
||||
} |
||||
|
||||
return `${labels.__name__ || ""}{${Object.entries(labels) |
||||
.filter(([k]) => k !== "__name__") |
||||
.map(([k, v]) => `${k}="${escapeString(v)}"`) |
||||
.join(", ")}}`;
|
||||
}; |
@ -0,0 +1,136 @@
|
||||
import dayjs from "dayjs"; |
||||
import duration from "dayjs/plugin/duration"; |
||||
dayjs.extend(duration); |
||||
import utc from "dayjs/plugin/utc"; |
||||
dayjs.extend(utc); |
||||
|
||||
// Parse Prometheus-specific duration strings such as "5m" or "1d2h3m4s" into milliseconds.
|
||||
export const parsePrometheusDuration = (durationStr: string): number | null => { |
||||
if (durationStr === "") { |
||||
return null; |
||||
} |
||||
if (durationStr === "0") { |
||||
// Allow 0 without a unit.
|
||||
return 0; |
||||
} |
||||
|
||||
const durationRE = new RegExp( |
||||
"^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$" |
||||
); |
||||
const matches = durationStr.match(durationRE); |
||||
if (!matches) { |
||||
return null; |
||||
} |
||||
|
||||
let dur = 0; |
||||
|
||||
// Parse the match at pos `pos` in the regex and use `mult` to turn that
|
||||
// into ms, then add that value to the total parsed duration.
|
||||
const m = (pos: number, mult: number) => { |
||||
if (matches[pos] === undefined) { |
||||
return; |
||||
} |
||||
const n = parseInt(matches[pos]); |
||||
dur += n * mult; |
||||
}; |
||||
|
||||
m(2, 1000 * 60 * 60 * 24 * 365); // y
|
||||
m(4, 1000 * 60 * 60 * 24 * 7); // w
|
||||
m(6, 1000 * 60 * 60 * 24); // d
|
||||
m(8, 1000 * 60 * 60); // h
|
||||
m(10, 1000 * 60); // m
|
||||
m(12, 1000); // s
|
||||
m(14, 1); // ms
|
||||
|
||||
return dur; |
||||
}; |
||||
|
||||
// Format a duration in milliseconds into a Prometheus duration string like "1d2h3m4s".
|
||||
export const formatPrometheusDuration = (d: number): string => { |
||||
let ms = d; |
||||
let r = ""; |
||||
if (ms === 0) { |
||||
return "0s"; |
||||
} |
||||
|
||||
const f = (unit: string, mult: number, exact: boolean) => { |
||||
if (exact && ms % mult !== 0) { |
||||
return; |
||||
} |
||||
const v = Math.floor(ms / mult); |
||||
if (v > 0) { |
||||
r += `${v}${unit}`; |
||||
ms -= v * mult; |
||||
} |
||||
}; |
||||
|
||||
// Only format years and weeks if the remainder is zero, as it is often
|
||||
// easier to read 90d than 12w6d.
|
||||
f("y", 1000 * 60 * 60 * 24 * 365, true); |
||||
f("w", 1000 * 60 * 60 * 24 * 7, true); |
||||
|
||||
f("d", 1000 * 60 * 60 * 24, false); |
||||
f("h", 1000 * 60 * 60, false); |
||||
f("m", 1000 * 60, false); |
||||
f("s", 1000, false); |
||||
f("ms", 1, false); |
||||
|
||||
return r; |
||||
}; |
||||
|
||||
export function parseTime(timeText: string): number { |
||||
return dayjs.utc(timeText).valueOf(); |
||||
} |
||||
|
||||
export const now = (): number => dayjs().valueOf(); |
||||
|
||||
export const humanizeDuration = (milliseconds: number): string => { |
||||
if (milliseconds === 0) { |
||||
return "0s"; |
||||
} |
||||
|
||||
const sign = milliseconds < 0 ? "-" : ""; |
||||
const duration = dayjs.duration(Math.abs(milliseconds), "ms"); |
||||
const ms = Math.floor(duration.milliseconds()); |
||||
const s = Math.floor(duration.seconds()); |
||||
const m = Math.floor(duration.minutes()); |
||||
const h = Math.floor(duration.hours()); |
||||
const d = Math.floor(duration.asDays()); |
||||
const parts: string[] = []; |
||||
if (d !== 0) { |
||||
parts.push(`${d}d`); |
||||
} |
||||
if (h !== 0) { |
||||
parts.push(`${h}h`); |
||||
} |
||||
if (m !== 0) { |
||||
parts.push(`${m}m`); |
||||
} |
||||
if (s !== 0) { |
||||
if (ms !== 0) { |
||||
parts.push(`${s}.${ms}s`); |
||||
} else { |
||||
parts.push(`${s}s`); |
||||
} |
||||
} else if (milliseconds !== 0) { |
||||
parts.push(`${milliseconds.toFixed(3)}ms`); |
||||
} |
||||
return sign + parts.join(" "); |
||||
}; |
||||
|
||||
export const humanizeDurationRelative = ( |
||||
startStr: string, |
||||
end: number, |
||||
suffix: string = " ago" |
||||
): string => { |
||||
const start = parseTime(startStr); |
||||
if (start < 0) { |
||||
return "never"; |
||||
} |
||||
return humanizeDuration(end - start) + suffix; |
||||
}; |
||||
|
||||
export const formatTimestamp = (t: number, useLocalTime: boolean) => |
||||
useLocalTime |
||||
? dayjs.unix(t).tz(dayjs.tz.guess()).format() |
||||
: dayjs.unix(t).utc().format(); |
@ -0,0 +1,15 @@
|
||||
import React from "react"; |
||||
import ReactDOM from "react-dom/client"; |
||||
import App from "./App.tsx"; |
||||
import store from "./state/store.ts"; |
||||
import { Provider } from "react-redux"; |
||||
import "./fonts/codicon.ttf"; |
||||
import "./promql.css"; |
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render( |
||||
<React.StrictMode> |
||||
<Provider store={store}> |
||||
<App /> |
||||
</Provider> |
||||
</React.StrictMode> |
||||
); |
@ -0,0 +1,27 @@
|
||||
import { Card, Group, Text } from "@mantine/core"; |
||||
import { IconSpy } from "@tabler/icons-react"; |
||||
import { FC } from "react"; |
||||
|
||||
const AgentPage: FC = () => { |
||||
return ( |
||||
<Card shadow="xs" withBorder p="md" mt="xs"> |
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs"> |
||||
<IconSpy size={22} /> |
||||
<Text fz="xl" fw={600}> |
||||
Prometheus Agent |
||||
</Text> |
||||
</Group> |
||||
<Text p="md"> |
||||
This Prometheus instance is running in <strong>agent mode</strong>. In |
||||
this mode, Prometheus is only used to scrape discovered targets and |
||||
forward the scraped metrics to remote write endpoints. |
||||
</Text> |
||||
<Text p="md"> |
||||
Some features are not available in this mode, such as querying and |
||||
alerting. |
||||
</Text> |
||||
</Card> |
||||
); |
||||
}; |
||||
|
||||
export default AgentPage; |
@ -0,0 +1,80 @@
|
||||
import { Alert, Card, Group, Stack, Table, Text } from "@mantine/core"; |
||||
import { IconBell, IconBellOff, IconInfoCircle } from "@tabler/icons-react"; |
||||
|
||||
import { useSuspenseAPIQuery } from "../api/api"; |
||||
import { AlertmanagersResult } from "../api/responseTypes/alertmanagers"; |
||||
import EndpointLink from "../components/EndpointLink"; |
||||
|
||||
export const targetPoolDisplayLimit = 20; |
||||
|
||||
export default function AlertmanagerDiscoveryPage() { |
||||
// Load the list of all available scrape pools.
|
||||
const { |
||||
data: { |
||||
data: { activeAlertmanagers, droppedAlertmanagers }, |
||||
}, |
||||
} = useSuspenseAPIQuery<AlertmanagersResult>({ |
||||
path: `/alertmanagers`, |
||||
}); |
||||
|
||||
return ( |
||||
<Stack gap="lg" maw={1000} mx="auto" mt="xs"> |
||||
<Card shadow="xs" withBorder p="md"> |
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs"> |
||||
<IconBell size={22} /> |
||||
<Text fz="xl" fw={600}> |
||||
Active Alertmanagers |
||||
</Text> |
||||
</Group> |
||||
{activeAlertmanagers.length === 0 ? ( |
||||
<Alert title="No active alertmanagers" icon={<IconInfoCircle />}> |
||||
No active alertmanagers found. |
||||
</Alert> |
||||
) : ( |
||||
<Table layout="fixed"> |
||||
<Table.Tbody> |
||||
{activeAlertmanagers.map((alertmanager) => ( |
||||
<Table.Tr key={alertmanager.url}> |
||||
<Table.Td> |
||||
<EndpointLink |
||||
endpoint={alertmanager.url} |
||||
globalUrl={alertmanager.url} |
||||
/> |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
))} |
||||
</Table.Tbody> |
||||
</Table> |
||||
)} |
||||
</Card> |
||||
<Card shadow="xs" withBorder p="md"> |
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs"> |
||||
<IconBellOff size={22} /> |
||||
<Text fz="xl" fw={600}> |
||||
Dropped Alertmanagers |
||||
</Text> |
||||
</Group> |
||||
{droppedAlertmanagers.length === 0 ? ( |
||||
<Alert title="No dropped alertmanagers" icon={<IconInfoCircle />}> |
||||
No dropped alertmanagers found. |
||||
</Alert> |
||||
) : ( |
||||
<Table layout="fixed"> |
||||
<Table.Tbody> |
||||
{droppedAlertmanagers.map((alertmanager) => ( |
||||
<Table.Tr key={alertmanager.url}> |
||||
<Table.Td> |
||||
<EndpointLink |
||||
endpoint={alertmanager.url} |
||||
globalUrl={alertmanager.url} |
||||
/> |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
))} |
||||
</Table.Tbody> |
||||
</Table> |
||||
)} |
||||
</Card> |
||||
</Stack> |
||||
); |
||||
} |
@ -0,0 +1,413 @@
|
||||
import { |
||||
Card, |
||||
Group, |
||||
Table, |
||||
Text, |
||||
Accordion, |
||||
Badge, |
||||
Tooltip, |
||||
Box, |
||||
Stack, |
||||
Alert, |
||||
TextInput, |
||||
Anchor, |
||||
} from "@mantine/core"; |
||||
import { useSuspenseAPIQuery } from "../api/api"; |
||||
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules"; |
||||
import badgeClasses from "../Badge.module.css"; |
||||
import panelClasses from "../Panel.module.css"; |
||||
import RuleDefinition from "../components/RuleDefinition"; |
||||
import { humanizeDurationRelative, now } from "../lib/formatTime"; |
||||
import { Fragment, useMemo } from "react"; |
||||
import { StateMultiSelect } from "../components/StateMultiSelect"; |
||||
import { IconInfoCircle, IconSearch } from "@tabler/icons-react"; |
||||
import { LabelBadges } from "../components/LabelBadges"; |
||||
import { useSettings } from "../state/settingsSlice"; |
||||
import { |
||||
ArrayParam, |
||||
BooleanParam, |
||||
StringParam, |
||||
useQueryParam, |
||||
withDefault, |
||||
} from "use-query-params"; |
||||
import { useDebouncedValue } from "@mantine/hooks"; |
||||
import { KVSearch } from "@nexucis/kvsearch"; |
||||
|
||||
type AlertsPageData = { |
||||
// How many rules are in each state across all groups.
|
||||
globalCounts: { |
||||
inactive: number; |
||||
pending: number; |
||||
firing: number; |
||||
}; |
||||
groups: { |
||||
name: string; |
||||
file: string; |
||||
// How many rules are in each state for this group.
|
||||
counts: { |
||||
total: number; |
||||
inactive: number; |
||||
pending: number; |
||||
firing: number; |
||||
}; |
||||
rules: { |
||||
rule: AlertingRule; |
||||
// How many alerts are in each state for this rule.
|
||||
counts: { |
||||
firing: number; |
||||
pending: number; |
||||
}; |
||||
}[]; |
||||
}[]; |
||||
}; |
||||
|
||||
const kvSearch = new KVSearch<AlertingRule>({ |
||||
shouldSort: true, |
||||
indexedKeys: ["name", "labels", ["labels", /.*/]], |
||||
}); |
||||
|
||||
const buildAlertsPageData = ( |
||||
data: AlertingRulesResult, |
||||
search: string, |
||||
stateFilter: (string | null)[] |
||||
) => { |
||||
const pageData: AlertsPageData = { |
||||
globalCounts: { |
||||
inactive: 0, |
||||
pending: 0, |
||||
firing: 0, |
||||
}, |
||||
groups: [], |
||||
}; |
||||
|
||||
for (const group of data.groups) { |
||||
const groupCounts = { |
||||
total: 0, |
||||
inactive: 0, |
||||
pending: 0, |
||||
firing: 0, |
||||
}; |
||||
|
||||
for (const r of group.rules) { |
||||
groupCounts.total++; |
||||
switch (r.state) { |
||||
case "inactive": |
||||
pageData.globalCounts.inactive++; |
||||
groupCounts.inactive++; |
||||
break; |
||||
case "firing": |
||||
pageData.globalCounts.firing++; |
||||
groupCounts.firing++; |
||||
break; |
||||
case "pending": |
||||
pageData.globalCounts.pending++; |
||||
groupCounts.pending++; |
||||
break; |
||||
default: |
||||
throw new Error(`Unknown rule state: ${r.state}`); |
||||
} |
||||
} |
||||
|
||||
const filteredRules: AlertingRule[] = ( |
||||
search === "" |
||||
? group.rules |
||||
: kvSearch.filter(search, group.rules).map((value) => value.original) |
||||
).filter((r) => stateFilter.length === 0 || stateFilter.includes(r.state)); |
||||
|
||||
pageData.groups.push({ |
||||
name: group.name, |
||||
file: group.file, |
||||
counts: groupCounts, |
||||
rules: filteredRules.map((r) => ({ |
||||
rule: r, |
||||
counts: { |
||||
firing: r.alerts.filter((a) => a.state === "firing").length, |
||||
pending: r.alerts.filter((a) => a.state === "pending").length, |
||||
}, |
||||
})), |
||||
}); |
||||
} |
||||
|
||||
return pageData; |
||||
}; |
||||
|
||||
export default function AlertsPage() { |
||||
// Fetch the alerting rules data.
|
||||
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({ |
||||
path: `/rules`, |
||||
params: { |
||||
type: "alert", |
||||
}, |
||||
}); |
||||
|
||||
const { showAnnotations } = useSettings(); |
||||
|
||||
// Define URL query params.
|
||||
const [stateFilter, setStateFilter] = useQueryParam( |
||||
"state", |
||||
withDefault(ArrayParam, []) |
||||
); |
||||
const [searchFilter, setSearchFilter] = useQueryParam( |
||||
"search", |
||||
withDefault(StringParam, "") |
||||
); |
||||
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250); |
||||
const [showEmptyGroups, setShowEmptyGroups] = useQueryParam( |
||||
"showEmptyGroups", |
||||
withDefault(BooleanParam, true) |
||||
); |
||||
|
||||
// Update the page data whenever the fetched data or filters change.
|
||||
const alertsPageData: AlertsPageData = useMemo( |
||||
() => buildAlertsPageData(data.data, debouncedSearch, stateFilter), |
||||
[data, stateFilter, debouncedSearch] |
||||
); |
||||
|
||||
const shownGroups = showEmptyGroups |
||||
? alertsPageData.groups |
||||
: alertsPageData.groups.filter((g) => g.rules.length > 0); |
||||
|
||||
return ( |
||||
<Stack mt="xs"> |
||||
<Group> |
||||
<StateMultiSelect |
||||
options={["inactive", "pending", "firing"]} |
||||
optionClass={(o) => |
||||
o === "inactive" |
||||
? badgeClasses.healthOk |
||||
: o === "pending" |
||||
? badgeClasses.healthWarn |
||||
: badgeClasses.healthErr |
||||
} |
||||
optionCount={(o) => |
||||
alertsPageData.globalCounts[ |
||||
o as keyof typeof alertsPageData.globalCounts |
||||
] |
||||
} |
||||
placeholder="Filter by rule state" |
||||
values={(stateFilter?.filter((v) => v !== null) as string[]) || []} |
||||
onChange={(values) => setStateFilter(values)} |
||||
/> |
||||
<TextInput |
||||
flex={1} |
||||
leftSection={<IconSearch size={14} />} |
||||
placeholder="Filter by rule name or labels" |
||||
value={searchFilter || ""} |
||||
onChange={(event) => |
||||
setSearchFilter(event.currentTarget.value || null) |
||||
} |
||||
></TextInput> |
||||
</Group> |
||||
{alertsPageData.groups.length === 0 ? ( |
||||
<Alert title="No rules found" icon={<IconInfoCircle size={14} />}> |
||||
No rules found. |
||||
</Alert> |
||||
) : ( |
||||
!showEmptyGroups && |
||||
alertsPageData.groups.length !== shownGroups.length && ( |
||||
<Alert |
||||
title="Hiding groups with no matching rules" |
||||
icon={<IconInfoCircle size={14} />} |
||||
> |
||||
Hiding {alertsPageData.groups.length - shownGroups.length} empty |
||||
groups due to filters or no rules. |
||||
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}> |
||||
Show empty groups |
||||
</Anchor> |
||||
</Alert> |
||||
) |
||||
)} |
||||
<Stack> |
||||
{shownGroups.map((g, i) => { |
||||
return ( |
||||
<Card |
||||
shadow="xs" |
||||
withBorder |
||||
p="md" |
||||
key={i} // TODO: Find a stable and definitely unique key.
|
||||
> |
||||
<Group mb="md" mt="xs" ml="xs" justify="space-between"> |
||||
<Group align="baseline"> |
||||
<Text |
||||
fz="xl" |
||||
fw={600} |
||||
c="var(--mantine-primary-color-filled)" |
||||
> |
||||
{g.name} |
||||
</Text> |
||||
<Text fz="sm" c="gray.6"> |
||||
{g.file} |
||||
</Text> |
||||
</Group> |
||||
<Group> |
||||
{g.counts.firing > 0 && ( |
||||
<Badge className={badgeClasses.healthErr}> |
||||
firing ({g.counts.firing}) |
||||
</Badge> |
||||
)} |
||||
{g.counts.pending > 0 && ( |
||||
<Badge className={badgeClasses.healthWarn}> |
||||
pending ({g.counts.pending}) |
||||
</Badge> |
||||
)} |
||||
{g.counts.inactive > 0 && ( |
||||
<Badge className={badgeClasses.healthOk}> |
||||
inactive ({g.counts.inactive}) |
||||
</Badge> |
||||
)} |
||||
</Group> |
||||
</Group> |
||||
{g.counts.total === 0 ? ( |
||||
<Alert title="No rules" icon={<IconInfoCircle />}> |
||||
No rules in this group. |
||||
<Anchor |
||||
ml="md" |
||||
fz="1em" |
||||
onClick={() => setShowEmptyGroups(false)} |
||||
> |
||||
Hide empty groups |
||||
</Anchor> |
||||
</Alert> |
||||
) : g.rules.length === 0 ? ( |
||||
<Alert title="No matching rules" icon={<IconInfoCircle />}> |
||||
No rules in this group match your filter criteria (omitted{" "} |
||||
{g.counts.total} filtered rules). |
||||
<Anchor |
||||
ml="md" |
||||
fz="1em" |
||||
onClick={() => setShowEmptyGroups(false)} |
||||
> |
||||
Hide empty groups |
||||
</Anchor> |
||||
</Alert> |
||||
) : ( |
||||
<Accordion multiple variant="separated"> |
||||
{g.rules.map((r, j) => { |
||||
return ( |
||||
<Accordion.Item |
||||
styles={{ |
||||
item: { |
||||
// TODO: This transparency hack is an OK workaround to make the collapsed items
|
||||
// have a different background color than their surrounding group card in dark mode,
|
||||
// but it would be better to use CSS to override the light/dark colors for
|
||||
// collapsed/expanded accordion items.
|
||||
backgroundColor: "#c0c0c015", |
||||
}, |
||||
}} |
||||
key={j} |
||||
value={j.toString()} |
||||
className={ |
||||
r.counts.firing > 0 |
||||
? panelClasses.panelHealthErr |
||||
: r.counts.pending > 0 |
||||
? panelClasses.panelHealthWarn |
||||
: panelClasses.panelHealthOk |
||||
} |
||||
> |
||||
<Accordion.Control> |
||||
<Group wrap="nowrap" justify="space-between" mr="lg"> |
||||
<Text>{r.rule.name}</Text> |
||||
<Group gap="xs"> |
||||
{r.counts.firing > 0 && ( |
||||
<Badge className={badgeClasses.healthErr}> |
||||
firing ({r.counts.firing}) |
||||
</Badge> |
||||
)} |
||||
{r.counts.pending > 0 && ( |
||||
<Badge className={badgeClasses.healthWarn}> |
||||
pending ({r.counts.pending}) |
||||
</Badge> |
||||
)} |
||||
</Group> |
||||
</Group> |
||||
</Accordion.Control> |
||||
<Accordion.Panel> |
||||
<RuleDefinition rule={r.rule} /> |
||||
{r.rule.alerts.length > 0 && ( |
||||
<Table mt="lg"> |
||||
<Table.Thead> |
||||
<Table.Tr> |
||||
<Table.Th>Alert labels</Table.Th> |
||||
<Table.Th>State</Table.Th> |
||||
<Table.Th>Active Since</Table.Th> |
||||
<Table.Th>Value</Table.Th> |
||||
</Table.Tr> |
||||
</Table.Thead> |
||||
<Table.Tbody> |
||||
{r.rule.type === "alerting" && |
||||
r.rule.alerts.map((a, k) => ( |
||||
<Fragment key={k}> |
||||
<Table.Tr> |
||||
<Table.Td> |
||||
<LabelBadges labels={a.labels} /> |
||||
</Table.Td> |
||||
<Table.Td> |
||||
<Badge |
||||
className={ |
||||
a.state === "firing" |
||||
? badgeClasses.healthErr |
||||
: badgeClasses.healthWarn |
||||
} |
||||
> |
||||
{a.state} |
||||
</Badge> |
||||
</Table.Td> |
||||
<Table.Td |
||||
style={{ whiteSpace: "nowrap" }} |
||||
> |
||||
<Tooltip label={a.activeAt}> |
||||
<Box> |
||||
{humanizeDurationRelative( |
||||
a.activeAt, |
||||
now(), |
||||
"" |
||||
)} |
||||
</Box> |
||||
</Tooltip> |
||||
</Table.Td> |
||||
<Table.Td |
||||
style={{ whiteSpace: "nowrap" }} |
||||
> |
||||
{isNaN(Number(a.value)) |
||||
? a.value |
||||
: Number(a.value)} |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
{showAnnotations && ( |
||||
<Table.Tr> |
||||
<Table.Td colSpan={4}> |
||||
<Table mt="md" mb="xl"> |
||||
<Table.Tbody> |
||||
{Object.entries( |
||||
a.annotations |
||||
).map(([k, v]) => ( |
||||
<Table.Tr key={k}> |
||||
<Table.Th c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))"> |
||||
{k} |
||||
</Table.Th> |
||||
<Table.Td>{v}</Table.Td> |
||||
</Table.Tr> |
||||
))} |
||||
</Table.Tbody> |
||||
</Table> |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
)} |
||||
</Fragment> |
||||
))} |
||||
</Table.Tbody> |
||||
</Table> |
||||
)} |
||||
</Accordion.Panel> |
||||
</Accordion.Item> |
||||
); |
||||
})} |
||||
</Accordion> |
||||
)} |
||||
</Card> |
||||
); |
||||
})} |
||||
</Stack> |
||||
</Stack> |
||||
); |
||||
} |
@ -0,0 +1,23 @@
|
||||
import { CodeHighlight } from "@mantine/code-highlight"; |
||||
import { useSuspenseAPIQuery } from "../api/api"; |
||||
import ConfigResult from "../api/responseTypes/config"; |
||||
|
||||
export default function ConfigPage() { |
||||
const { |
||||
data: { |
||||
data: { yaml }, |
||||
}, |
||||
} = useSuspenseAPIQuery<ConfigResult>({ path: `/status/config` }); |
||||
|
||||
return ( |
||||
<CodeHighlight |
||||
code={yaml} |
||||
language="yaml" |
||||
miw="50vw" |
||||
w="fit-content" |
||||
maw="calc(100vw - 75px)" |
||||
mx="auto" |
||||
mt="xs" |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,21 @@
|
||||
.th { |
||||
padding: 0; |
||||
} |
||||
|
||||
.control { |
||||
width: 100%; |
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); |
||||
|
||||
@mixin hover { |
||||
background-color: light-dark( |
||||
var(--mantine-color-gray-0), |
||||
var(--mantine-color-dark-6) |
||||
); |
||||
} |
||||
} |
||||
|
||||
.icon { |
||||
width: rem(21px); |
||||
height: rem(21px); |
||||
border-radius: rem(21px); |
||||
} |
@ -0,0 +1,182 @@
|
||||
import { useState } from "react"; |
||||
import { |
||||
Table, |
||||
UnstyledButton, |
||||
Group, |
||||
Text, |
||||
Center, |
||||
TextInput, |
||||
rem, |
||||
keys, |
||||
Card, |
||||
} from "@mantine/core"; |
||||
import { |
||||
IconSelector, |
||||
IconChevronDown, |
||||
IconChevronUp, |
||||
IconSearch, |
||||
} from "@tabler/icons-react"; |
||||
import classes from "./FlagsPage.module.css"; |
||||
import { useSuspenseAPIQuery } from "../api/api"; |
||||
|
||||
interface RowData { |
||||
flag: string; |
||||
value: string; |
||||
} |
||||
|
||||
interface ThProps { |
||||
children: React.ReactNode; |
||||
reversed: boolean; |
||||
sorted: boolean; |
||||
onSort(): void; |
||||
} |
||||
|
||||
function Th({ children, reversed, sorted, onSort }: ThProps) { |
||||
const Icon = sorted |
||||
? reversed |
||||
? IconChevronUp |
||||
: IconChevronDown |
||||
: IconSelector; |
||||
return ( |
||||
<Table.Th className={classes.th}> |
||||
<UnstyledButton onClick={onSort} className={classes.control}> |
||||
<Group justify="space-between"> |
||||
<Text fw={600} fz="sm"> |
||||
{children} |
||||
</Text> |
||||
<Center className={classes.icon}> |
||||
<Icon style={{ width: rem(16), height: rem(16) }} stroke={1.5} /> |
||||
</Center> |
||||
</Group> |
||||
</UnstyledButton> |
||||
</Table.Th> |
||||
); |
||||
} |
||||
|
||||
function filterData(data: RowData[], search: string) { |
||||
const query = search.toLowerCase().trim(); |
||||
return data.filter((item) => |
||||
keys(data[0]).some((key) => item[key].toLowerCase().includes(query)) |
||||
); |
||||
} |
||||
|
||||
function sortData( |
||||
data: RowData[], |
||||
payload: { sortBy: keyof RowData | null; reversed: boolean; search: string } |
||||
) { |
||||
const { sortBy } = payload; |
||||
|
||||
if (!sortBy) { |
||||
return filterData(data, payload.search); |
||||
} |
||||
|
||||
return filterData( |
||||
[...data].sort((a, b) => { |
||||
if (payload.reversed) { |
||||
return b[sortBy].localeCompare(a[sortBy]); |
||||
} |
||||
|
||||
return a[sortBy].localeCompare(b[sortBy]); |
||||
}), |
||||
payload.search |
||||
); |
||||
} |
||||
|
||||
export default function FlagsPage() { |
||||
const { data } = useSuspenseAPIQuery<Record<string, string>>({ |
||||
path: `/status/flags`, |
||||
}); |
||||
|
||||
const flags = Object.entries(data.data).map(([flag, value]) => ({ |
||||
flag, |
||||
value, |
||||
})); |
||||
|
||||
const [search, setSearch] = useState(""); |
||||
const [sortedData, setSortedData] = useState(flags); |
||||
const [sortBy, setSortBy] = useState<keyof RowData | null>(null); |
||||
const [reverseSortDirection, setReverseSortDirection] = useState(false); |
||||
|
||||
const setSorting = (field: keyof RowData) => { |
||||
const reversed = field === sortBy ? !reverseSortDirection : false; |
||||
setReverseSortDirection(reversed); |
||||
setSortBy(field); |
||||
setSortedData(sortData(flags, { sortBy: field, reversed, search })); |
||||
}; |
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
||||
const { value } = event.currentTarget; |
||||
setSearch(value); |
||||
setSortedData( |
||||
sortData(flags, { sortBy, reversed: reverseSortDirection, search: value }) |
||||
); |
||||
}; |
||||
|
||||
const rows = sortedData.map((row) => ( |
||||
<Table.Tr key={row.flag}> |
||||
<Table.Td> |
||||
<code>--{row.flag}</code> |
||||
</Table.Td> |
||||
<Table.Td> |
||||
<code>{row.value}</code> |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
)); |
||||
|
||||
return ( |
||||
<Card shadow="xs" maw={1000} mx="auto" mt="xs" withBorder> |
||||
<TextInput |
||||
placeholder="Filter by flag name or value" |
||||
mb="md" |
||||
autoFocus |
||||
leftSection={ |
||||
<IconSearch |
||||
style={{ width: rem(16), height: rem(16) }} |
||||
stroke={1.5} |
||||
/> |
||||
} |
||||
value={search} |
||||
onChange={handleSearchChange} |
||||
/> |
||||
<Table |
||||
horizontalSpacing="md" |
||||
verticalSpacing="xs" |
||||
miw={700} |
||||
layout="fixed" |
||||
> |
||||
<Table.Tbody> |
||||
<Table.Tr> |
||||
<Th |
||||
sorted={sortBy === "flag"} |
||||
reversed={reverseSortDirection} |
||||
onSort={() => setSorting("flag")} |
||||
> |
||||
Flag |
||||
</Th> |
||||
|
||||
<Th |
||||
sorted={sortBy === "value"} |
||||
reversed={reverseSortDirection} |
||||
onSort={() => setSorting("value")} |
||||
> |
||||
Value |
||||
</Th> |
||||
</Table.Tr> |
||||
</Table.Tbody> |
||||
<Table.Tbody> |
||||
{rows.length > 0 ? ( |
||||
rows |
||||
) : ( |
||||
<Table.Tr> |
||||
<Table.Td colSpan={2}> |
||||
<Text fw={500} ta="center"> |
||||
Nothing found |
||||
</Text> |
||||
</Table.Td> |
||||
</Table.Tr> |
||||
)} |
||||
</Table.Tbody> |
||||
</Table> |
||||
</Card> |
||||
); |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue