We have two historians in alerting - alert state and notification. The intention of this app is to provide query capabilities for both. In this initial commit, the existing /history API is simply cloned to the new app. It is identical except that it will send Kubernetes-style error responses instead of Grafana-style. This approach was taken to implement the new app more iteratively - ideally we would define a new API, but this requires quite a significant overhaul of the backend code.
310 lines
8.8 KiB
Go
310 lines
8.8 KiB
Go
package historian
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/grafana/grafana-app-sdk/app"
|
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
|
)
|
|
|
|
type mockHistorian struct {
|
|
queryFunc func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error)
|
|
}
|
|
|
|
func (m *mockHistorian) Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
if m.queryFunc != nil {
|
|
return m.queryFunc(ctx, query)
|
|
}
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
type mockResponseWriter struct {
|
|
*httptest.ResponseRecorder
|
|
headers http.Header
|
|
}
|
|
|
|
func newMockResponseWriter() *mockResponseWriter {
|
|
return &mockResponseWriter{
|
|
ResponseRecorder: httptest.NewRecorder(),
|
|
headers: make(http.Header),
|
|
}
|
|
}
|
|
|
|
func (m *mockResponseWriter) Header() http.Header {
|
|
return m.headers
|
|
}
|
|
|
|
func TestGetAlertStateHistoryHandler(t *testing.T) {
|
|
t.Run("returns data frame when query succeeds", func(t *testing.T) {
|
|
now := time.Now()
|
|
testFrame := data.NewFrame("test",
|
|
data.NewField("Time", nil, []time.Time{now, now.Add(time.Second)}),
|
|
data.NewField("Line", nil, []string{"alert fired", "alert resolved"}),
|
|
)
|
|
|
|
mock := &mockHistorian{
|
|
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
assert.Equal(t, int64(123), query.OrgID)
|
|
assert.NotNil(t, query.SignedInUser)
|
|
return testFrame, nil
|
|
},
|
|
}
|
|
|
|
h := handlers{historian: mock}
|
|
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{
|
|
OrgID: 123,
|
|
})
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: ""},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, writer.Code)
|
|
assert.Equal(t, "application/json", writer.headers.Get("Content-Type"))
|
|
|
|
var result *data.Frame
|
|
err = json.Unmarshal(writer.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test", result.Name)
|
|
assert.Equal(t, 2, result.Rows())
|
|
})
|
|
|
|
t.Run("passes query parameters to historian", func(t *testing.T) {
|
|
testFrame := data.NewFrame("test",
|
|
data.NewField("Time", nil, []time.Time{time.Now()}),
|
|
data.NewField("Line", nil, []string{"test"}),
|
|
)
|
|
|
|
var capturedQuery models.HistoryQuery
|
|
mock := &mockHistorian{
|
|
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
capturedQuery = query
|
|
return testFrame, nil
|
|
},
|
|
}
|
|
|
|
h := handlers{historian: mock}
|
|
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 99})
|
|
|
|
params := url.Values{}
|
|
params.Set("ruleUID", "rule-123")
|
|
params.Set("dashboardUID", "dash-456")
|
|
params.Set("panelID", "7")
|
|
params.Set("from", "1000")
|
|
params.Set("to", "2000")
|
|
params.Set("limit", "50")
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: params.Encode()},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "rule-123", capturedQuery.RuleUID)
|
|
assert.Equal(t, "dash-456", capturedQuery.DashboardUID)
|
|
assert.Equal(t, int64(7), capturedQuery.PanelID)
|
|
assert.Equal(t, time.Unix(1000, 0), capturedQuery.From)
|
|
assert.Equal(t, time.Unix(2000, 0), capturedQuery.To)
|
|
assert.Equal(t, 50, capturedQuery.Limit)
|
|
})
|
|
|
|
t.Run("handles label matchers in query", func(t *testing.T) {
|
|
testFrame := data.NewFrame("test",
|
|
data.NewField("Time", nil, []time.Time{time.Now()}),
|
|
data.NewField("Line", nil, []string{"test"}),
|
|
)
|
|
|
|
var capturedQuery models.HistoryQuery
|
|
mock := &mockHistorian{
|
|
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
capturedQuery = query
|
|
return testFrame, nil
|
|
},
|
|
}
|
|
|
|
h := handlers{historian: mock}
|
|
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
|
|
|
params := url.Values{}
|
|
params.Add("labels", `env=prod`)
|
|
params.Add("labels", `region=us-west`)
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: params.Encode()},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.NoError(t, err)
|
|
if len(capturedQuery.Labels) > 0 {
|
|
assert.NotEmpty(t, capturedQuery.Labels)
|
|
}
|
|
})
|
|
|
|
t.Run("returns unauthorized when no user in context", func(t *testing.T) {
|
|
h := handlers{historian: &mockHistorian{}}
|
|
ctx := context.Background()
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: ""},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "authentication required")
|
|
})
|
|
|
|
t.Run("returns internal error when historian query fails", func(t *testing.T) {
|
|
mock := &mockHistorian{
|
|
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
return nil, errors.New("database connection failed")
|
|
},
|
|
}
|
|
|
|
h := handlers{historian: mock}
|
|
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: ""},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "database connection failed")
|
|
})
|
|
|
|
t.Run("returns empty frame when no results", func(t *testing.T) {
|
|
emptyFrame := data.NewFrame("empty")
|
|
|
|
mock := &mockHistorian{
|
|
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
return emptyFrame, nil
|
|
},
|
|
}
|
|
|
|
h := handlers{historian: mock}
|
|
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: ""},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, writer.Code)
|
|
|
|
var result *data.Frame
|
|
err = json.Unmarshal(writer.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, result.Rows())
|
|
})
|
|
|
|
t.Run("encodes complex data frame with multiple fields", func(t *testing.T) {
|
|
now := time.Now()
|
|
complexFrame := data.NewFrame("complex",
|
|
data.NewField("Time", nil, []time.Time{now}),
|
|
data.NewField("Line", nil, []string{"alert fired"}),
|
|
data.NewField("Value", nil, []float64{42.5}),
|
|
data.NewField("Labels", nil, []string{`{"env":"prod"}`}),
|
|
)
|
|
|
|
mock := &mockHistorian{
|
|
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
return complexFrame, nil
|
|
},
|
|
}
|
|
|
|
h := handlers{historian: mock}
|
|
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: ""},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, writer.Code)
|
|
|
|
var result *data.Frame
|
|
err = json.Unmarshal(writer.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 4, len(result.Fields))
|
|
assert.Equal(t, 1, result.Rows())
|
|
})
|
|
}
|
|
|
|
func TestParseHistoryQueryIntegration(t *testing.T) {
|
|
t.Run("parses all supported query parameters", func(t *testing.T) {
|
|
testFrame := data.NewFrame("test",
|
|
data.NewField("Time", nil, []time.Time{time.Now()}),
|
|
data.NewField("Line", nil, []string{"test"}),
|
|
)
|
|
|
|
var capturedQuery models.HistoryQuery
|
|
mock := &mockHistorian{
|
|
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
|
|
capturedQuery = query
|
|
return testFrame, nil
|
|
},
|
|
}
|
|
|
|
h := handlers{historian: mock}
|
|
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 5})
|
|
|
|
params := url.Values{}
|
|
params.Set("ruleUID", "test-rule")
|
|
params.Set("dashboardUID", "test-dash")
|
|
params.Set("panelID", "3")
|
|
params.Set("from", "1609459200")
|
|
params.Set("to", "1609545600")
|
|
params.Set("limit", "100")
|
|
params.Set("current", "alerting")
|
|
params.Set("previous", "normal")
|
|
|
|
writer := newMockResponseWriter()
|
|
req := &app.CustomRouteRequest{
|
|
URL: &url.URL{RawQuery: params.Encode()},
|
|
}
|
|
|
|
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(5), capturedQuery.OrgID)
|
|
assert.Equal(t, "test-rule", capturedQuery.RuleUID)
|
|
assert.Equal(t, "test-dash", capturedQuery.DashboardUID)
|
|
assert.Equal(t, int64(3), capturedQuery.PanelID)
|
|
assert.Equal(t, time.Unix(1609459200, 0), capturedQuery.From)
|
|
assert.Equal(t, time.Unix(1609545600, 0), capturedQuery.To)
|
|
assert.Equal(t, 100, capturedQuery.Limit)
|
|
assert.Equal(t, "alerting", capturedQuery.Current)
|
|
assert.Equal(t, "normal", capturedQuery.Previous)
|
|
})
|
|
}
|