Compare commits

...

17 Commits

Author SHA1 Message Date
Ashwin Bhatkal
c217cc96c3 chore: variable store set function (#10174)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* chore: variable store set function

* chore: fix var
2026-02-03 14:49:44 +00:00
Vikrant Gupta
580cf32eb5 feat(authz): migrate rbac to authz (#10134)
* feat(authz): migrate rbac to authz

* feat(authz): handle public dashboard migration

* feat(authz): fix integration tests

* feat(authz): fix integration tests

* feat(authz): keep the error same as today
2026-02-03 19:54:07 +05:30
Abhi kumar
6d3580cbfa feat: added a new tooltip plugin (#10167)
* feat: added a new tooltip plugin

* fix: pr review comments

* fix: pr review comments

* fix: pr review comments

* chore: remove global.d.ts override
2026-02-03 14:06:16 +00:00
Abhi kumar
6c5d36caa9 fix: small fixes for scale + legend sizing (#10168)
* fix: small fixes for scale + legend sizing

* fix: minor naming fix
2026-02-03 17:55:17 +05:30
Abhishek Kumar Singh
c4a6c7e277 test(integration): alert firing verification fixture (#10131)
* chore: fixture for notification channel

* chore: return notification channel info in Create notification channel API

* fix: change scope of create channel fixture to function level

* test: added fixture for creating alert rule

* chore: added debug message on assertion failure

* refactor: improve error handling in webhook notification channel deletion

* fix: enhance error handling in alert rule creation and deletion

* chore: ran py linter and fmt

* chore: ran py linter and fmt

* fix: add timeout to alert rule creation and deletion requests

* fix: silenced pylint on too broad exception

* fix: suppress pylint warnings for broad exception handling in alert rule deletion

* test: added fixture for inserting alert data

* refactor: added fixture for getting test data file path

* feat: add alerts to integration CI workflow

* chore: linter fixes

* chore: changed scope for get_testdata_file_path

* feat: alert firing verification fixture

* feat: broken fixture for collect firing alerts

* chore: py-formatter

* chore: py-formatter

* refactor: updated expected alert to dataclass

* chore: updated get_testdata_file_path fixture to a util function

* chore: removed wrong ref

* chore: lint and formatted

* chore: moved utils function to alertutils from fixtures

* chore: return firing alert from collect alert func

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-02-03 16:51:39 +05:30
SagarRajput-7
c9cd974dca feat: sidebar enhancement (#10157)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: sidebar enhancement (#9748)

* fix: sidebar enhancement

* fix: new source btn changes

* fix: shortcut order changes

* fix: changes in more section collapse behaviour

* fix: sidebar shortcut changes, consistency, cleanup in collapse mode

* fix: sidebar pin, tooltip and other changes

* feat: updated alignment issues

* fix: sidenav enhancement - fixes

* fix: code fix

* fix: sidenav enhancement

* feat: addressed comments and feedback

* feat: fix default shortcut empty issue

* feat: code clean and improvements

* feat: refactor and cleanup

* feat: refactor and addressed comment

* feat: removed isscrolled

* feat: corrected the ref intialization
2026-02-03 07:58:21 +00:00
Abhi kumar
5b3f121431 feat: added line styling options for uplot (#10166) 2026-02-03 13:14:35 +05:30
Jatinderjit Singh
c79373314a fix: add missing data for promql and anomaly rules (#10097) 2026-02-03 11:48:16 +05:30
Ashwin Bhatkal
858cd287fa chore: query builder / hooks to use new variable store (#10148)
* chore: query builder / hooks to use new variable store

* chore: fix tests
2026-02-03 09:33:02 +05:30
Abhishek Kumar Singh
afdb674068 test(integration): added fixture for inserting alert data (#10101)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: fixture for notification channel

* chore: return notification channel info in Create notification channel API

* fix: change scope of create channel fixture to function level

* test: added fixture for creating alert rule

* chore: added debug message on assertion failure

* refactor: improve error handling in webhook notification channel deletion

* fix: enhance error handling in alert rule creation and deletion

* chore: ran py linter and fmt

* chore: ran py linter and fmt

* fix: add timeout to alert rule creation and deletion requests

* fix: silenced pylint on too broad exception

* fix: suppress pylint warnings for broad exception handling in alert rule deletion

* test: added fixture for inserting alert data

* refactor: added fixture for getting test data file path

* feat: add alerts to integration CI workflow

* chore: linter fixes

* chore: changed scope for get_testdata_file_path

* chore: py-formatter

* chore: py-formatter

* chore: updated get_testdata_file_path fixture to a util function

* chore: removed wrong ref

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-02-02 23:37:14 +05:30
Aditya Singh
30a6721472 Add cancel query functionality to dashboard edit panel (#10152)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: add cancel query functionality for dashboard panels

* feat: pass loading query from edit panel

* feat: revert loading
2026-02-02 15:02:42 +00:00
Abhi kumar
518dfcbe59 fix: minor fix for uplot scale (#10164) 2026-02-02 13:24:45 +00:00
Ashwin Bhatkal
424127c27c chore: dashboard grid / widget / hooks to use new variable store (#10147)
* chore: dashboard grid / widget / hooks to use new variable store

* chore: fix tests
2026-02-02 12:54:34 +00:00
Abhi kumar
2dcb817de1 feat: Added ChartLayout component + utils to correctly position Legends and Chart in the view (#10160)
* feat: added chartlayout component to render charts and legends

* fix: added fix for legenditemsSet calculation

* chore: added pulse frontend as codeowners for uplotv2

* chore: cleaned up the legend size calculations function

* chore: removed config from deps in charlayout

* fix: added fix for height calculation

* fix: pr review changes
2026-02-02 17:27:14 +05:30
Ashwin Bhatkal
f6f8c78aaf chore: dashboard container to use new variable store (#10146)
* chore: dashboard container to use new variable store

* chore: fix tests
2026-02-02 15:20:47 +05:30
Abhishek Kumar Singh
3c99dfdfa5 test(integration): added fixture for creating alert rule (#10092)
* chore: fixture for notification channel

* chore: return notification channel info in Create notification channel API

* fix: change scope of create channel fixture to function level

* test: added fixture for creating alert rule

* chore: added debug message on assertion failure

* refactor: improve error handling in webhook notification channel deletion

* fix: enhance error handling in alert rule creation and deletion

* chore: ran py linter and fmt

* chore: ran py linter and fmt

* fix: add timeout to alert rule creation and deletion requests

* fix: silenced pylint on too broad exception

* fix: suppress pylint warnings for broad exception handling in alert rule deletion

* feat: add alerts to integration CI workflow

* chore: py-formatter

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-02-02 14:56:33 +05:30
Abhi kumar
6ed72519b8 feat: implement uPlot builder pattern API for chart configuration (#10069)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: implement uPlot builder pattern API for chart configuration

* feat: added panel context wrapper

* chore: removed console logs

* chore: minor cleanup

* chore: fixed uplot lib

* chore: added datautils

* chore: type fixes

* chore: fixed linter issues

* fix: added fix for removehook + legend state sync

* fix: added fix for orphand plot

* fix: legend animation frame race condition

* fix: added fix for skipping series with no data

* fix: added fix for header title when cursorIdx is 0

* fix: fixed cursor comments

* fix: fixed cursor comments

* fix: added safeguard for setseries visibility

* chore: updated context placement

* chore: added changes for storing legendstate in localstorage

* chore: added changes related to yaxisunit and decimalprecision

* chore: minor changes in the builders

* chore: minor updates

* chore: variable updates in useLegendSync

* chore: added thresold hook and restructured the code

* chore: pr review comments

* chore: moved uplot specific files to uplotv2

* chore: pr review changes

* chore: pr review changes

* chore: pr review changes

* chore: types fixes

* chore: linted and added comments

* chore: series visibility update fix

* chore: fixed pr review comments

* chore: updated normalize plot utils

* chore: added memoization in plotcontext

* chore: minor changes

* chore: fixed cursor comments

* chore: fixed cursor comments

* chore: fixed cursor comments

* chore: restructured files

* fix: renaming

* fix: renaming

* chore: minor fix

* chore: timestamp fix

* fix: added fix for tooltip label generation

* fix: pr review changes

* fix: pr review changes

* fix: pr review changes

* fix: pr review changes

* fix: pr review changes

* fix: pr review changes

* fix: cursor review

* fix: cursor review

* fix: cursor review
2026-02-02 12:45:00 +05:30
119 changed files with 7237 additions and 671 deletions

3
.github/CODEOWNERS vendored
View File

@@ -132,3 +132,6 @@
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## UplotV2
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend

View File

@@ -42,10 +42,11 @@ jobs:
- callbackauthn
- cloudintegrations
- dashboard
- querier
- ttl
- preference
- logspipelines
- preference
- querier
- role
- ttl
- alerts
sqlstore-provider:
- postgres

View File

@@ -79,7 +79,7 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
if err != nil {
return err
}
@@ -208,7 +208,7 @@ func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashb
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}
@@ -285,7 +285,7 @@ func (module *module) deletePublic(ctx context.Context, orgID valuer.UUID, dashb
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}

View File

@@ -116,18 +116,18 @@ func (setter *setter) Patch(ctx context.Context, orgID valuer.UUID, role *rolety
return setter.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
}
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := roletypes.GetAdditionTuples(id, orgID, relation, additions)
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := roletypes.GetDeletionTuples(id, orgID, relation, deletions)
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}

View File

@@ -50,7 +50,7 @@ type GetAnomaliesResponse struct {
//
// ^ ^
// | |
// (rounded value for past peiod) + (seasonal growth)
// (rounded value for past period) + (seasonal growth)
//
// score = abs(value - prediction) / stddev (current_season_query)
type anomalyQueryParams struct {
@@ -74,12 +74,12 @@ type anomalyQueryParams struct {
// : For daily seasonality, this is the query range params for the (now-2d-5m, now-1d)
// : For hourly seasonality, this is the query range params for the (now-2h-5m, now-1h)
PastSeasonQuery *v3.QueryRangeParamsV3
// Past2SeasonQuery is the query range params for past 2 seasonal period to the current season
// Past2SeasonQuery is the query range params for past 2 seasonal periods to the current season
// Example: For weekly seasonality, this is the query range params for the (now-3w-5m, now-2w)
// : For daily seasonality, this is the query range params for the (now-3d-5m, now-2d)
// : For hourly seasonality, this is the query range params for the (now-3h-5m, now-2h)
Past2SeasonQuery *v3.QueryRangeParamsV3
// Past3SeasonQuery is the query range params for past 3 seasonal period to the current season
// Past3SeasonQuery is the query range params for past 3 seasonal periods to the current season
// Example: For weekly seasonality, this is the query range params for the (now-4w-5m, now-3w)
// : For daily seasonality, this is the query range params for the (now-4d-5m, now-3d)
// : For hourly seasonality, this is the query range params for the (now-4h-5m, now-3h)

View File

@@ -234,6 +234,11 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
}
}
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
@@ -285,6 +290,11 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult)
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)

View File

@@ -0,0 +1,268 @@
package rules
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// mockAnomalyProvider is a mock implementation of anomaly.Provider for testing.
// We need this because the anomaly provider makes 6 different queries for various
// time periods (current, past period, current season, past season, past 2 seasons,
// past 3 seasons), making it cumbersome to create mock data.
type mockAnomalyProvider struct {
responses []*anomaly.GetAnomaliesResponse
callCount int
}
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.GetAnomaliesRequest) (*anomaly.GetAnomaliesResponse, error) {
if m.callCount >= len(m.responses) {
return &anomaly.GetAnomaliesResponse{Results: []*v3.Result{}}, nil
}
resp := m.responses[m.callCount]
m.callCount++
return resp, nil
}
func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
// Test basic AlertOnAbsent functionality (without AbsentFor grace period)
baseTime := time.Unix(1700000000, 0)
evalWindow := 5 * time.Minute
evalTime := baseTime.Add(5 * time.Minute)
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(evalWindow),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
},
},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
},
},
}
cases := []struct {
description string
alertOnAbsent bool
expectAlerts int
}{
{
description: "AlertOnAbsent=false",
alertOnAbsent: false,
expectAlerts: 0,
},
{
description: "AlertOnAbsent=true",
alertOnAbsent: true,
expectAlerts: 1,
},
}
logger := instrumentationtest.New().Logger()
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule(
"test-anomaly-rule",
valuer.GenerateUUID(),
&postableRule,
reader,
nil,
logger,
nil,
)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.GetAnomaliesResponse{responseNoData},
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
assert.Equal(t, c.expectAlerts, alertsFound)
})
}
}
func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
// Test missing data alert with AbsentFor grace period
// 1. Call Eval with data at time t1, to populate lastTimestampWithDatapoints
// 2. Call Eval without data at time t2
// 3. Alert fires only if t2 - t1 > AbsentFor
baseTime := time.Unix(1700000000, 0)
evalWindow := 5 * time.Minute
// Set target higher than test data so regular threshold alerts don't fire
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(evalWindow),
Frequency: ruletypes.Duration(time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
},
},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
},
},
}
cases := []struct {
description string
absentFor uint64
timeBetweenEvals time.Duration
expectAlertOnEval2 int
}{
{
description: "WithinGracePeriod",
absentFor: 5,
timeBetweenEvals: 4 * time.Minute,
expectAlertOnEval2: 0,
},
{
description: "AfterGracePeriod",
absentFor: 5,
timeBetweenEvals: 6 * time.Minute,
expectAlertOnEval2: 1,
},
}
logger := instrumentationtest.New().Logger()
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AbsentFor = c.absentFor
t1 := baseTime.Add(5 * time.Minute)
t2 := t1.Add(c.timeBetweenEvals)
responseWithData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
{
QueryName: "A",
AnomalyScores: []*v3.Series{
{
Labels: map[string]string{"test": "label"},
Points: []v3.Point{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
},
},
},
},
},
}
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, reader, nil, logger, nil)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.GetAnomaliesResponse{responseWithData, responseNoData},
}
alertsFound1, err := rule.Eval(context.Background(), t1)
require.NoError(t, err)
assert.Equal(t, 0, alertsFound1, "First eval with data should not alert")
alertsFound2, err := rule.Eval(context.Background(), t2)
require.NoError(t, err)
assert.Equal(t, c.expectAlertOnEval2, alertsFound2)
})
}
}

View File

@@ -28,17 +28,16 @@ import {
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
} from 'constants/antlrQueryConstants';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Info, TriangleAlert } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
IDetailedError,
IQueryContext,
IValidationResult,
} from 'types/antlrQueryTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -207,14 +206,9 @@ function QuerySearch({
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
// Add back the generateOptions function and useEffect
@@ -1069,7 +1063,7 @@ function QuerySearch({
);
// Add dynamic variables suggestions for the current key
const variableName = dynamicVariables?.find(
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === keyName,
)?.name;

View File

@@ -48,7 +48,7 @@
}
.app-content {
width: calc(100% - 64px); // width of the sidebar
width: calc(100% - 54px); // width of the sidebar
z-index: 0;
margin: 0 auto;

View File

@@ -21,6 +21,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -44,7 +45,7 @@ import {
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData, IDashboardVariable } from 'types/api/dashboard/getAll';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
@@ -56,7 +57,11 @@ import { Base64Icons } from '../DashboardSettings/General/utils';
import DashboardVariableSelection from '../DashboardVariablesSelection';
import SettingsDrawer from './SettingsDrawer';
import { VariablesSettingsTab } from './types';
import { DEFAULT_ROW_NAME, downloadObjectAsJson } from './utils';
import {
DEFAULT_ROW_NAME,
downloadObjectAsJson,
sanitizeDashboardData,
} from './utils';
import './Description.styles.scss';
@@ -64,28 +69,6 @@ interface DashboardDescriptionProps {
handle: FullScreenHandle;
}
export function sanitizeDashboardData(
selectedData: DashboardData,
): DashboardData {
if (!selectedData?.variables) {
return selectedData;
}
const updatedVariables = Object.entries(selectedData.variables).reduce(
(acc, [key, value]) => {
const { selectedValue: _selectedValue, ...rest } = value;
acc[key] = rest;
return acc;
},
{} as Record<string, IDashboardVariable>,
);
return {
...selectedData,
variables: updatedVariables,
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
@@ -119,6 +102,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
uuid: selectedDashboard.id,
}
: ({} as DashboardData);
const { dashboardVariables } = useDashboardVariables();
const { title = '', description, tags, image = Base64Icons[0] } =
selectedData || {};
@@ -576,7 +560,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
<section className="dashboard-description-section">{description}</section>
)}
{!isEmpty(selectedData.variables) && (
{!isEmpty(dashboardVariables) && (
<section className="dashboard-variables">
<DashboardVariableSelection />
</section>

View File

@@ -1,3 +1,27 @@
import { DashboardData, IDashboardVariable } from 'types/api/dashboard/getAll';
export function sanitizeDashboardData(
selectedData: DashboardData,
): DashboardData {
if (!selectedData?.variables) {
return selectedData;
}
const updatedVariables = Object.entries(selectedData.variables).reduce(
(acc, [key, value]) => {
const { selectedValue: _selectedValue, ...rest } = value;
acc[key] = rest;
return acc;
},
{} as Record<string, IDashboardVariable>,
);
return {
...selectedData,
variables: updatedVariables,
};
}
export function downloadObjectAsJson(
exportObj: unknown,
exportName: string,

View File

@@ -14,10 +14,8 @@ import { CustomSelect } from 'components/NewSelect';
import TextToolTip from 'components/TextToolTip';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
createDynamicVariableToWidgetsMap,
getWidgetsHavingDynamicVariableAttribute,
} from 'hooks/dashboard/utils';
import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynamicVariableId';
import { getWidgetsHavingDynamicVariableAttribute } from 'hooks/dashboard/utils';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
@@ -243,23 +241,11 @@ function VariableItem({
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { selectedDashboard } = useDashboard();
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {
const dynamicVariables = Object.values(
selectedDashboard?.data?.variables || {},
)?.filter((variable: IDashboardVariable) => variable.type === 'DYNAMIC');
const widgets =
selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || [];
const widgetsHavingDynamicVariables = createDynamicVariableToWidgetsMap(
dynamicVariables,
widgets as Widgets[],
);
if (variableData?.id && variableData.id in widgetsHavingDynamicVariables) {
setSelectedWidgets(widgetsHavingDynamicVariables[variableData.id] || []);
if (variableData?.id && variableData.id in widgetsByDynamicVariableId) {
setSelectedWidgets(widgetsByDynamicVariableId[variableData.id] || []);
} else if (dynamicVariablesSelectedValue?.name) {
const widgets = getWidgetsHavingDynamicVariableAttribute(
dynamicVariablesSelectedValue?.name,
@@ -275,6 +261,7 @@ function VariableItem({
selectedDashboard,
variableData.id,
variableData.name,
widgetsByDynamicVariableId,
]);
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HolderOutlined, PlusOutlined } from '@ant-design/icons';
import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core';
@@ -17,11 +17,13 @@ import { RowProps } from 'antd/lib';
import { VariablesSettingsTabHandle } from 'container/DashboardContainer/DashboardDescription/types';
import { convertVariablesToDbFormat } from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Trash2 } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { TVariableMode } from './types';
import VariableItem from './VariableItem/VariableItem';
@@ -91,13 +93,10 @@ function VariablesSettings({
const { t } = useTranslation(['dashboard']);
const { selectedDashboard, setSelectedDashboard } = useDashboard();
const { dashboardVariables } = useDashboardVariables();
const { notifications } = useNotifications();
const variables = useMemo(() => selectedDashboard?.data?.variables || {}, [
selectedDashboard?.data?.variables,
]);
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
@@ -147,13 +146,13 @@ function VariablesSettings({
const variableNamesMap = {};
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(variables)) {
for (const [key, value] of Object.entries(dashboardVariables)) {
const { order, id, name } = value;
tableRowData.push({
key,
name: key,
...variables[key],
...dashboardVariables[key],
id,
});
@@ -174,10 +173,10 @@ function VariablesSettings({
setVariablesTableData(tableRowData);
setVariablesOrderArr(variableOrderArr);
setExistingVariableNamesMap(variableNamesMap);
}, [variables]);
}, [dashboardVariables]);
const updateVariables = (
updatedVariablesData: Dashboard['data']['variables'],
updatedVariablesData: IDashboardVariables,
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,
@@ -312,7 +311,7 @@ function VariablesSettings({
currentVariableId?: string,
): boolean => {
// Check if any other dynamic variable already uses this attribute key
const isDuplicateAttributeKey = Object.values(variables).some(
const isDuplicateAttributeKey = Object.values(dashboardVariables).some(
(variable: IDashboardVariable) =>
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute === attributeKey &&
@@ -422,7 +421,7 @@ function VariablesSettings({
{variableViewMode ? (
<VariableItem
variableData={{ ...variableEditData } as IDashboardVariable}
existingVariables={variables}
existingVariables={dashboardVariables}
onSave={onVariableSaveHandler}
onCancel={onDoneVariableViewMode}
validateName={validateVariableName}

View File

@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
@@ -33,9 +34,7 @@ function DashboardVariableSelection(): JSX.Element | null {
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { data } = selectedDashboard || {};
const { variables } = data || {};
const { dashboardVariables } = useDashboardVariables();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
@@ -48,29 +47,31 @@ function DashboardVariableSelection(): JSX.Element | null {
);
useEffect(() => {
if (variables) {
const tableRowData = [];
const tableRowData = [];
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(variables)) {
const { id } = value;
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(dashboardVariables)) {
const { id } = value;
tableRowData.push({
key,
name: key,
...variables[key],
id,
});
}
tableRowData.sort((a, b) => a.order - b.order);
setVariablesTableData(tableRowData);
// Initialize variables with default values if not in URL
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
tableRowData.push({
key,
name: key,
...dashboardVariables[key],
id,
});
}
}, [getUrlVariables, updateUrlVariable, variables]);
tableRowData.sort((a, b) => a.order - b.order);
setVariablesTableData(tableRowData);
// Initialize variables with default values if not in URL
initializeDefaultVariables(
dashboardVariables,
getUrlVariables,
updateUrlVariable,
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
useEffect(() => {
if (variablesTableData.length > 0) {
@@ -94,7 +95,7 @@ function DashboardVariableSelection(): JSX.Element | null {
cycleNodes,
});
}
}, [variables, variablesTableData]);
}, [dashboardVariables, variablesTableData]);
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
// also trigger when the global time changes
@@ -122,7 +123,7 @@ function DashboardVariableSelection(): JSX.Element | null {
if (id) {
// For dynamic variables, only store in localStorage when NOT allSelected
// This makes localStorage much lighter by avoiding storing all individual values
const variable = variables?.[id] || variables?.[name];
const variable = dashboardVariables?.[id] || dashboardVariables?.[name];
const isDynamic = variable?.type === 'DYNAMIC';
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
@@ -185,7 +186,7 @@ function DashboardVariableSelection(): JSX.Element | null {
}
};
if (!variables) {
if (!dashboardVariables) {
return null;
}
@@ -202,7 +203,7 @@ function DashboardVariableSelection(): JSX.Element | null {
variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={`${variable.name}${variable.id}${variable.order}`}
existingVariables={variables}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,
@@ -212,7 +213,7 @@ function DashboardVariableSelection(): JSX.Element | null {
) : (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={variables}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,

View File

@@ -3,7 +3,8 @@ import { useCallback } from 'react';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as uuidv4 } from 'uuid';
import { convertVariablesToDbFormat } from './util';
@@ -27,7 +28,7 @@ interface UseDashboardVariableUpdateReturn {
widgetId?: string,
) => void;
updateVariables: (
updatedVariablesData: Dashboard['data']['variables'],
updatedVariablesData: IDashboardVariables,
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,
@@ -106,7 +107,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
const updateVariables = useCallback(
(
updatedVariablesData: Dashboard['data']['variables'],
updatedVariablesData: IDashboardVariables,
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,

View File

@@ -1,6 +1,7 @@
import { OptionData } from 'components/NewSelect/types';
import { isEmpty, isNull } from 'lodash-es';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export function areArraysEqual(
a: (string | number | boolean)[],
@@ -21,7 +22,7 @@ export function areArraysEqual(
export const convertVariablesToDbFormat = (
variblesArr: IDashboardVariable[],
): Dashboard['data']['variables'] =>
): IDashboardVariables =>
variblesArr.reduce((result, obj: IDashboardVariable) => {
const { id } = obj;

View File

@@ -0,0 +1,130 @@
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
export interface ChartDimensions {
width: number;
height: number;
legendWidth: number;
legendHeight: number;
legendsPerSet: number;
}
const AVG_CHAR_WIDTH = 8;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const LEGEND_GAP = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 36;
/**
* Average text width from series labels (for legendsPerSet).
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return DEFAULT_AVG_LABEL_LENGTH;
}
const averageLabelLength =
legends.reduce((sum, l) => sum + l.length, 0) / legends.length;
return averageLabelLength * AVG_CHAR_WIDTH;
}
/**
* Compute how much space to give to the chart area vs. the legend.
*
* - For a RIGHT legend, we reserve a vertical column on the right and shrink the chart width.
* - For a BOTTOM legend, we reserve up to two rows below the chart and shrink the chart height.
*
* Implementation details (high level):
* - Approximates legend item width from label text length, using a fixed average char width.
* - RIGHT legend:
* - `legendWidth` is clamped between 150px and min(MAX_LEGEND_WIDTH, 30% of container width).
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
* The returned values are the final chart and legend rectangles (width/height),
* plus `legendsPerSet` which hints how many legend items to show per row.
*/
export function calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig,
seriesLabels,
}: {
containerWidth: number;
containerHeight: number;
legendConfig: LegendConfig;
seriesLabels: string[];
}): ChartDimensions {
// Guard: no space to lay out chart or legend
if (containerWidth <= 0 || containerHeight <= 0) {
return {
width: 0,
height: 0,
legendWidth: 0,
legendHeight: 0,
legendsPerSet: 0,
};
}
// Approximate width of a single legend item based on label text.
const approxLegendItemWidth = calculateAverageLegendWidth(seriesLabels);
const legendItemCount = seriesLabels.length;
if (legendConfig.position === LegendPosition.RIGHT) {
const maxRightLegendWidth = Math.min(MAX_LEGEND_WIDTH, containerWidth * 0.3);
const rightLegendWidth = Math.min(
Math.max(150, approxLegendItemWidth),
maxRightLegendWidth,
);
return {
width: Math.max(0, containerWidth - rightLegendWidth),
height: containerHeight,
legendWidth: rightLegendWidth,
legendHeight: containerHeight,
// Single vertical list on the right.
legendsPerSet: 1,
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.min(approxLegendItemWidth, 400);
const legendItemsPerRow = Math.max(
1,
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
);
const legendRowCount = Math.min(
2,
Math.ceil(legendItemCount / legendItemsPerRow),
);
const idealBottomLegendHeight =
legendRowCount > 1
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,
maxAllowedLegendHeight,
);
// How many legend items per row in the Legend component.
const legendsPerSet = Math.ceil(
(containerWidth + LEGEND_GAP) /
(Math.min(MAX_LEGEND_WIDTH, approxLegendItemWidth) + LEGEND_GAP),
);
return {
width: containerWidth,
height: Math.max(0, containerHeight - bottomLegendHeight),
legendWidth: containerWidth,
legendHeight: bottomLegendHeight,
legendsPerSet,
};
}

View File

@@ -0,0 +1,22 @@
.chart-layout {
position: relative;
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
&--legend-right {
flex-direction: row;
.chart-layout__legend-wrapper {
padding-left: 0 !important;
padding-right: 12px !important;
}
}
&__legend-wrapper {
padding-left: 12px;
padding-bottom: 12px;
overflow: auto;
}
}

View File

@@ -0,0 +1,74 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'container/DashboardContainer/visualization/charts/utils';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import './ChartLayout.styles.scss';
export interface ChartLayoutProps {
legendComponent: (legendPerSet: number) => React.ReactNode;
children: (props: {
chartWidth: number;
chartHeight: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
containerWidth: number;
containerHeight: number;
legendConfig: LegendConfig;
config: UPlotConfigBuilder;
}
export default function ChartLayout({
legendComponent,
children,
layoutChildren,
containerWidth,
containerHeight,
legendConfig,
config,
}: ChartLayoutProps): JSX.Element {
const chartDimensions = useMemo(
() => {
const legendItemsMap = config.getLegendItems();
const seriesLabels = Object.values(legendItemsMap)
.map((item) => item.label)
.filter((label): label is string => label !== undefined);
return calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig,
seriesLabels,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerWidth, containerHeight, legendConfig],
);
return (
<div className="chart-layout__container">
<div
className={cx('chart-layout', {
'chart-layout--legend-right':
legendConfig.position === LegendPosition.RIGHT,
})}
>
<div className="chart-layout__content">
{children({
chartWidth: chartDimensions.width,
chartHeight: chartDimensions.height,
})}
</div>
<div
className="chart-layout__legend-wrapper"
style={{
height: chartDimensions.legendHeight,
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.legendsPerSet)}
</div>
</div>
{layoutChildren}
</div>
);
}

View File

@@ -0,0 +1,15 @@
/**
* Represents the visibility state of a single series in a graph
*/
export interface SeriesVisibilityItem {
label: string;
show: boolean;
}
/**
* Represents the stored visibility state for a widget/graph
*/
export interface GraphVisibilityState {
name: string;
dataIndex: SeriesVisibilityItem[];
}

View File

@@ -0,0 +1,74 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
/**
* Retrieves the visibility map for a specific widget from localStorage
* @param widgetId - The unique identifier of the widget
* @returns A Map of series labels to their visibility state, or null if not found
*/
export function getStoredSeriesVisibility(
widgetId: string,
): Map<string, boolean> | null {
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
if (!storedData) {
return null;
}
const visibilityStates: GraphVisibilityState[] = JSON.parse(storedData);
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (!widgetState?.dataIndex?.length) {
return null;
}
return new Map(widgetState.dataIndex.map((item) => [item.label, item.show]));
} catch {
// Silently handle parsing errors - fall back to default visibility
return null;
}
}
export function updateSeriesVisibilityToLocalStorage(
widgetId: string,
seriesVisibility: SeriesVisibilityItem[],
): void {
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
let visibilityStates: GraphVisibilityState[];
if (!storedData) {
visibilityStates = [
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
visibilityStates = JSON.parse(storedData);
}
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (!widgetState) {
visibilityStates = [
...visibilityStates,
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
widgetState.dataIndex = seriesVisibility;
}
localStorage.setItem(
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
JSON.stringify(visibilityStates),
);
} catch {
// Silently handle parsing errors - fall back to default visibility
}
}

View File

@@ -25,6 +25,7 @@ import {
} from 'container/NewWidget/RightContainer/timeItems';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useChartMutable } from 'hooks/useChartMutable';
@@ -79,6 +80,7 @@ function FullView({
}, [setCurrentGraphRef]);
const { selectedDashboard, isDashboardLocked } = useDashboard();
const { dashboardVariables } = useDashboardVariables();
const { user } = useAppContext();
const [editWidget] = useComponentPermission(['edit_widget'], user.role);
@@ -114,7 +116,7 @@ function FullView({
graphType: getGraphType(selectedPanelType),
query: updatedQuery,
globalSelectedInterval: globalSelectedTime,
variables: getDashboardVariables(selectedDashboard?.data.variables),
variables: getDashboardVariables(dashboardVariables),
fillGaps: widget.fillSpans,
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
originalGraphType: selectedPanelType,
@@ -125,7 +127,7 @@ function FullView({
graphType: PANEL_TYPES.LIST,
selectedTime: widget?.timePreferance || 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
variables: getDashboardVariables(selectedDashboard?.data.variables),
variables: getDashboardVariables(dashboardVariables),
tableParams: {
pagination: {
offset: 0,

View File

@@ -53,7 +53,7 @@ function GridCardGraph({
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
widgetsHavingDynamicVariables,
widgetsByDynamicVariableId,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@@ -226,8 +226,8 @@ function GridCardGraph({
? Object.entries(variables).reduce((acc, [id, variable]) => {
if (
variable.type !== 'DYNAMIC' ||
(widgetsHavingDynamicVariables?.[variable.id] &&
widgetsHavingDynamicVariables?.[variable.id].includes(widget.id))
(widgetsByDynamicVariableId?.[variable.id] &&
widgetsByDynamicVariableId?.[variable.id].includes(widget.id))
) {
return { ...acc, [id]: variable.selectedValue };
}

View File

@@ -4,8 +4,9 @@ import { ToggleGraphProps } from 'components/Graph/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { SuccessResponse } from 'types/api';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import uPlot from 'uplot';
@@ -50,7 +51,7 @@ export interface GridCardGraphProps {
headerMenuList?: WidgetGraphComponentProps['headerMenuList'];
onClickHandler?: OnClickPluginOpts['onClick'];
isQueryEnabled: boolean;
variables?: Dashboard['data']['variables'];
variables?: IDashboardVariables;
version?: string;
onDragSelect: (start: number, end: number) => void;
customOnDragSelect?: (start: number, end: number) => void;
@@ -71,7 +72,7 @@ export interface GridCardGraphProps {
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
widgetsHavingDynamicVariables?: Record<string, string[]>;
widgetsByDynamicVariableId?: Record<string, string[]>;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@@ -14,8 +14,9 @@ import { QueryParams } from 'constants/query';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { DEFAULT_ROW_NAME } from 'container/DashboardContainer/DashboardDescription/utils';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynamicVariableId';
import useComponentPermission from 'hooks/useComponentPermission';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -34,7 +35,7 @@ import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { sortLayout } from 'providers/Dashboard/util';
import { UpdateTimeInterval } from 'store/actions';
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
@@ -79,7 +80,9 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
const { pathname } = useLocation();
const dispatch = useDispatch();
const { widgets, variables } = data || {};
const { widgets } = data || {};
const { dashboardVariables } = useDashboardVariables();
const { user } = useAppContext();
@@ -99,21 +102,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
Record<string, { widgets: Layout[]; collapsed: boolean }>
>({});
const widgetsHavingDynamicVariables = useMemo(() => {
const dynamicVariables = Object.values(
selectedDashboard?.data?.variables || {},
)?.filter((variable: IDashboardVariable) => variable.type === 'DYNAMIC');
const widgets =
selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || [];
return createDynamicVariableToWidgetsMap(
dynamicVariables,
widgets as Widgets[],
);
}, [selectedDashboard]);
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {
setCurrentPanelMap(panelMap);
@@ -178,11 +167,11 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
dashboardId: selectedDashboard?.id,
dashboardName: data.title,
numberOfPanels: data.widgets?.length,
numberOfVariables: Object.keys(data?.variables || {}).length || 0,
numberOfVariables: Object.keys(dashboardVariables).length || 0,
});
logEventCalledRef.current = true;
}
}, [data, selectedDashboard?.id]);
}, [dashboardVariables, data, selectedDashboard?.id]);
const onSaveHandler = (): void => {
if (!selectedDashboard) {
@@ -622,13 +611,13 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
<GridCard
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
headerMenuList={widgetActions}
variables={variables}
variables={dashboardVariables}
// version={selectedDashboard?.data?.version}
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
enableDrillDown={enableDrillDown}
widgetsHavingDynamicVariables={widgetsHavingDynamicVariables}
widgetsByDynamicVariableId={widgetsByDynamicVariableId}
/>
</Card>
</CardContainer>

View File

@@ -1,15 +1,14 @@
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
@@ -36,14 +35,9 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const queryRangeMutation = useMutation(getSubstituteVars);
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const getUpdatedQuery = useCallback(
@@ -59,7 +53,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
globalSelectedInterval,
variables: getDashboardVariables(selectedDashboard?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables,
dynamicVariables: dashboardDynamicVariables,
});
// Execute query and process results
@@ -68,7 +62,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
// Map query data from API response
return mapQueryDataFromApi(queryResult.data.compositeQuery);
},
[dynamicVariables, globalSelectedInterval, queryRangeMutation],
[dashboardDynamicVariables, globalSelectedInterval, queryRangeMutation],
);
return {

View File

@@ -168,7 +168,7 @@
.ant-pagination {
position: fixed;
bottom: 0;
width: calc(100% - 64px);
width: calc(100% - 54px);
background: rgb(18, 19, 23);
padding: 16px;
margin: 0;

View File

@@ -442,7 +442,7 @@
.ant-pagination {
position: fixed;
bottom: 0;
width: calc(100% - 64px);
width: calc(100% - 54px);
background: var(--bg-ink-500);
padding: 16px;
margin: 0;

View File

@@ -58,7 +58,7 @@
overflow-y: auto;
box-sizing: border-box;
height: calc(100% - 64px);
height: calc(100% - 54px);
&::-webkit-scrollbar {
height: 1rem;

View File

@@ -39,8 +39,10 @@ import cx from 'classnames';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { sanitizeDashboardData } from 'container/DashboardContainer/DashboardDescription';
import { downloadObjectAsJson } from 'container/DashboardContainer/DashboardDescription/utils';
import {
downloadObjectAsJson,
sanitizeDashboardData,
} from 'container/DashboardContainer/DashboardDescription/utils';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import dayjs from 'dayjs';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';

View File

@@ -194,7 +194,7 @@
.ant-pagination {
position: fixed;
bottom: 0;
width: calc(100% - 64px);
width: calc(100% - 54px);
background: var(--bg-ink-500);
padding: 16px;
margin: 0;

View File

@@ -1,4 +1,7 @@
.dashboard-navigation {
.run-query-dashboard-btn {
min-width: 180px;
}
.ant-tabs-tab {
border: none !important;
margin-left: 0px !important;

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { QueryKey } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Tabs, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -35,8 +36,11 @@ import ClickHouseQueryContainer from './QueryBuilder/clickHouse';
import PromQLQueryContainer from './QueryBuilder/promQL';
import './QuerySection.styles.scss';
function QuerySection({ selectedGraph }: QueryProps): JSX.Element {
function QuerySection({
selectedGraph,
queryRangeKey,
isLoadingQueries,
}: QueryProps): JSX.Element {
const {
currentQuery,
handleRunQuery: handleRunQueryFromQueryBuilder,
@@ -237,7 +241,13 @@ function QuerySection({ selectedGraph }: QueryProps): JSX.Element {
tabBarExtraContent={
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
<RunQueryBtn label="Stage & Run Query" onStageRunQuery={handleRunQuery} />
<RunQueryBtn
className="run-query-dashboard-btn"
label="Stage & Run Query"
onStageRunQuery={handleRunQuery}
isLoadingQueries={isLoadingQueries}
queryRangeKey={queryRangeKey}
/>
</span>
}
items={items}
@@ -248,6 +258,8 @@ function QuerySection({ selectedGraph }: QueryProps): JSX.Element {
interface QueryProps {
selectedGraph: PANEL_TYPES;
queryRangeKey?: QueryKey;
isLoadingQueries?: boolean;
}
export default QuerySection;

View File

@@ -1,4 +1,5 @@
import { memo, useEffect } from 'react';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -24,8 +25,8 @@ function LeftContainer({
setSelectedTracesFields,
selectedWidget,
requestData,
setRequestData,
isLoadingPanelData,
setRequestData,
setQueryResponse,
enableDrillDown = false,
}: WidgetGraphProps): JSX.Element {
@@ -35,15 +36,20 @@ function LeftContainer({
AppState,
GlobalReducer
>((state) => state.globalTime);
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
enabled: !!stagedQuery,
queryKey: [
const queryRangeKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedInterval,
requestData,
minTime,
maxTime,
],
[globalSelectedInterval, requestData, minTime, maxTime],
);
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
enabled: !!stagedQuery,
queryKey: queryRangeKey,
keepPreviousData: true,
});
// Update parent component with query response for legend colors
@@ -64,7 +70,11 @@ function LeftContainer({
enableDrillDown={enableDrillDown}
/>
<QueryContainer className="query-section-left-container">
<QuerySection selectedGraph={selectedGraph} />
<QuerySection
selectedGraph={selectedGraph}
queryRangeKey={queryRangeKey}
isLoadingQueries={queryResponse.isFetching}
/>
{selectedGraph === PANEL_TYPES.LIST && (
<ExplorerColumnsRenderer
selectedLogFields={selectedLogFields}

View File

@@ -26,6 +26,7 @@ import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import GraphTypes, {
ItemsProps,
} from 'container/DashboardContainer/ComponentsSlider/menuItems';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
@@ -35,7 +36,6 @@ import {
Spline,
SquareArrowOutUpRight,
} from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
@@ -131,7 +131,7 @@ function RightContainer({
enableDrillDown = false,
isNewDashboard,
}: RightContainerProps): JSX.Element {
const { selectedDashboard } = useDashboard();
const { dashboardVariables } = useDashboardVariables();
const [inputValue, setInputValue] = useState(title);
const [autoCompleteOpen, setAutoCompleteOpen] = useState(false);
const [cursorPos, setCursorPos] = useState(0);
@@ -173,16 +173,12 @@ function RightContainer({
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
// Get dashboard variables
const dashboardVariables = useMemo<VariableOption[]>(() => {
if (!selectedDashboard?.data?.variables) {
return [];
}
return Object.entries(selectedDashboard.data.variables).map(([, value]) => ({
const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
return Object.entries(dashboardVariables).map(([, value]) => ({
value: value.name || '',
label: value.name || '',
}));
}, [selectedDashboard?.data?.variables]);
}, [dashboardVariables]);
const updateCursorAndDropdown = (value: string, pos: number): void => {
setCursorPos(pos);
@@ -274,7 +270,7 @@ function RightContainer({
<section className="name-description">
<Typography.Text className="typography">Name</Typography.Text>
<AutoComplete
options={dashboardVariables}
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}

View File

@@ -19,6 +19,7 @@ import {
import ROUTES from 'constants/routes';
import { DashboardShortcuts } from 'constants/shortcuts/DashboardShortcuts';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -89,6 +90,8 @@ function NewWidget({
columnWidths,
} = useDashboard();
const { dashboardVariables } = useDashboardVariables();
const { t } = useTranslation(['dashboard']);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -377,7 +380,7 @@ function NewWidget({
graphType: PANEL_TYPES.LIST,
selectedTime: selectedTime.enum || 'GLOBAL_TIME',
globalSelectedInterval: customGlobalSelectedInterval,
variables: getDashboardVariables(selectedDashboard?.data.variables),
variables: getDashboardVariables(dashboardVariables),
tableParams: {
pagination: {
offset: 0,
@@ -394,7 +397,7 @@ function NewWidget({
formatForWeb:
getGraphTypeForFormat(selectedGraph || selectedWidget.panelTypes) ===
PANEL_TYPES.TABLE,
variables: getDashboardVariables(selectedDashboard?.data.variables),
variables: getDashboardVariables(dashboardVariables),
originalGraphType: selectedGraph || selectedWidget?.panelTypes,
};
}
@@ -408,7 +411,7 @@ function NewWidget({
graphType: selectedGraph,
selectedTime: selectedTime.enum || 'GLOBAL_TIME',
globalSelectedInterval: customGlobalSelectedInterval,
variables: getDashboardVariables(selectedDashboard?.data.variables),
variables: getDashboardVariables(dashboardVariables),
};
});

View File

@@ -1,4 +1,7 @@
import { useCallback } from 'react';
import { QueryKey, useIsFetching, useQueryClient } from 'react-query';
import { Button } from 'antd';
import cx from 'classnames';
import {
ChevronUp,
Command,
@@ -9,35 +12,56 @@ import {
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import './RunQueryBtn.scss';
interface RunQueryBtnProps {
className?: string;
label?: string;
isLoadingQueries?: boolean;
handleCancelQuery?: () => void;
onStageRunQuery?: () => void;
queryRangeKey?: QueryKey;
}
function RunQueryBtn({
className,
label,
isLoadingQueries,
handleCancelQuery,
onStageRunQuery,
queryRangeKey,
}: RunQueryBtnProps): JSX.Element {
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
return isLoadingQueries ? (
const queryClient = useQueryClient();
const isKeyFetchingCount = useIsFetching(
queryRangeKey as QueryKey | undefined,
);
const isLoading =
typeof isLoadingQueries === 'boolean'
? isLoadingQueries
: isKeyFetchingCount > 0;
const onCancel = useCallback(() => {
if (handleCancelQuery) {
return handleCancelQuery();
}
if (queryRangeKey) {
queryClient.cancelQueries(queryRangeKey);
}
}, [handleCancelQuery, queryClient, queryRangeKey]);
return isLoading ? (
<Button
type="default"
icon={<Loader2 size={14} className="loading-icon animate-spin" />}
className="cancel-query-btn periscope-btn danger"
onClick={handleCancelQuery}
className={cx('cancel-query-btn periscope-btn danger', className)}
onClick={onCancel}
>
Cancel
</Button>
) : (
<Button
type="primary"
className="run-query-btn periscope-btn primary"
disabled={isLoadingQueries || !onStageRunQuery}
className={cx('run-query-btn periscope-btn primary', className)}
disabled={isLoading || !onStageRunQuery}
onClick={onStageRunQuery}
icon={<Play size={14} />}
>

View File

@@ -3,6 +3,16 @@ import { fireEvent, render, screen } from '@testing-library/react';
import RunQueryBtn from '../RunQueryBtn';
jest.mock('react-query', () => {
const actual = jest.requireActual('react-query');
return {
...actual,
useIsFetching: jest.fn(),
useQueryClient: jest.fn(),
};
});
import { useIsFetching, useQueryClient } from 'react-query';
// Mock OS util
jest.mock('utils/getUserOS', () => ({
getUserOperatingSystem: jest.fn(),
@@ -11,10 +21,43 @@ jest.mock('utils/getUserOS', () => ({
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
describe('RunQueryBtn', () => {
test('renders run state and triggers on click', () => {
beforeEach(() => {
jest.resetAllMocks();
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
(useIsFetching as jest.Mock).mockReturnValue(0);
(useQueryClient as jest.Mock).mockReturnValue({
cancelQueries: jest.fn(),
});
});
test('uses isLoadingQueries prop over useIsFetching', () => {
// Simulate fetching but prop forces not loading
(useIsFetching as jest.Mock).mockReturnValue(1);
const onRun = jest.fn();
render(<RunQueryBtn onStageRunQuery={onRun} isLoadingQueries={false} />);
// Should show "Run Query" (not cancel)
const runBtn = screen.getByRole('button', { name: /run query/i });
expect(runBtn).toBeInTheDocument();
expect(runBtn).toBeEnabled();
});
test('fallback cancel: uses handleCancelQuery when no key provided', () => {
(useIsFetching as jest.Mock).mockReturnValue(0);
const cancelQueries = jest.fn();
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
const onCancel = jest.fn();
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelBtn);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(cancelQueries).not.toHaveBeenCalled();
});
test('renders run state and triggers on click', () => {
const onRun = jest.fn();
render(<RunQueryBtn onStageRunQuery={onRun} />);
const btn = screen.getByRole('button', { name: /run query/i });
@@ -24,17 +67,11 @@ describe('RunQueryBtn', () => {
});
test('disabled when onStageRunQuery is undefined', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
render(<RunQueryBtn />);
expect(screen.getByRole('button', { name: /run query/i })).toBeDisabled();
});
test('shows cancel state and calls handleCancelQuery', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
const onCancel = jest.fn();
render(<RunQueryBtn isLoadingQueries handleCancelQuery={onCancel} />);
const cancel = screen.getByRole('button', { name: /cancel/i });
@@ -42,10 +79,24 @@ describe('RunQueryBtn', () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
test('derives loading from queryKey via useIsFetching and cancels via queryClient', () => {
(useIsFetching as jest.Mock).mockReturnValue(1);
const cancelQueries = jest.fn();
(useQueryClient as jest.Mock).mockReturnValue({ cancelQueries });
const queryKey = ['GET_QUERY_RANGE', '1h', { some: 'req' }, 1, 2];
render(<RunQueryBtn queryRangeKey={queryKey} />);
// Button switches to cancel state
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
expect(cancelBtn).toBeInTheDocument();
// Clicking cancel calls cancelQueries with the key
fireEvent.click(cancelBtn);
expect(cancelQueries).toHaveBeenCalledWith(queryKey);
});
test('shows Command + CornerDownLeft on mac', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
const { container } = render(
<RunQueryBtn onStageRunQuery={(): void => {}} />,
);
@@ -70,9 +121,6 @@ describe('RunQueryBtn', () => {
});
test('renders custom label when provided', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.MACOS,
);
const onRun = jest.fn();
render(<RunQueryBtn onStageRunQuery={onRun} label="Stage & Run Query" />);
expect(

View File

@@ -19,6 +19,7 @@ import {
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
@@ -38,9 +39,7 @@ import {
unset,
} from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import type { BaseSelectRef } from 'rc-select';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import {
BaseAutocompleteData,
DataTypes,
@@ -248,14 +247,9 @@ function QueryBuilderSearchV2(
return false;
}, [currentState, query.aggregateAttribute?.dataType, query.dataSource]);
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const { data, isFetching } = useGetAggregateKeys(
@@ -806,7 +800,7 @@ function QueryBuilderSearchV2(
values.push(...(attributeValues?.payload?.[key] || []));
// here we want to suggest the variable name matching with the key here, we will go over the dynamic variables for the keys
const variableName = dynamicVariables?.find(
const variableName = dashboardDynamicVariables?.find(
(variable) =>
variable?.dynamicVariablesAttribute === currentFilterItem?.key?.key,
)?.name;
@@ -837,7 +831,7 @@ function QueryBuilderSearchV2(
suggestionsData?.payload?.attributes,
operatorConfigKey,
currentFilterItem?.key?.key,
dynamicVariables,
dashboardDynamicVariables,
]);
// keep the query in sync with the selected tags in logs explorer page

View File

@@ -12,7 +12,9 @@ import {
initialQueriesMap,
initialQueryBuilderFormValues,
} from 'constants/queryBuilder';
import { IUseDashboardVariablesReturn } from 'hooks/dashboard/useDashboardVariables';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -145,27 +147,23 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
// Mock dashboard provider with dynamic variables
const mockDashboard = {
data: {
variables: {
service: {
id: 'service',
name: 'service',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
},
},
// Mock dashboard variables
const dashboardVariables = {
service: {
id: 'service',
name: 'service',
type: 'DYNAMIC' as IDashboardVariable['type'],
dynamicVariablesAttribute: 'service.name',
description: '',
sort: 'DISABLED' as IDashboardVariable['sort'],
multiSelect: false,
showALLOption: false,
},
};
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: mockDashboard,
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: (): IUseDashboardVariablesReturn => ({
dashboardVariables: dashboardVariables,
}),
}));

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo } from 'react';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { ArrowLeft, Plus, Settings, X } from 'lucide-react';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
// import { PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -33,17 +33,9 @@ const useDashboardVarConfig = ({
};
// contextItems: React.ReactNode;
} => {
const { selectedDashboard } = useDashboard();
const dashboardDynamicVariables = useDashboardVariablesByType('DYNAMIC');
const { onValueUpdate, createVariable } = useDashboardVariableUpdate();
const dynamicDashboardVariables = useMemo(
(): [string, IDashboardVariable][] =>
Object.entries(selectedDashboard?.data?.variables || {}).filter(
([, value]) => value.name && value.type === 'DYNAMIC',
),
[selectedDashboard],
);
// Function to determine the source from query data
const getSourceFromQuery = useCallback(():
| 'logs'
@@ -116,7 +108,7 @@ const useDashboardVarConfig = ({
<>
{' '}
{Object.entries(fieldVariables).map(([fieldName, value]) => {
const dashboardVar = dynamicDashboardVariables.find(
const dashboardVar = dashboardDynamicVariables.find(
([, dynamicValue]) =>
dynamicValue.dynamicVariablesAttribute === fieldName,
);
@@ -178,7 +170,7 @@ const useDashboardVarConfig = ({
),
[
fieldVariables,
dynamicDashboardVariables,
dashboardDynamicVariables,
handleSetVariable,
handleUnsetVariable,
handleCreateVariable,

View File

@@ -13,6 +13,12 @@
.nav-item-active-marker {
background: #4e74f8;
}
.nav-item-data {
.nav-item-label {
color: var(--bg-vanilla-100, #fff);
}
}
}
&.disabled {
@@ -27,14 +33,14 @@
.nav-item-data {
color: white;
background: var(--Slate-500, #161922);
background: var(--bg-slate-500, #161922);
}
}
&.active {
.nav-item-data {
color: white;
background: var(--Slate-500, #161922);
background: var(--bg-slate-500, #161922);
// color: #3f5ecc;
}
}
@@ -50,9 +56,9 @@
.nav-item-data {
flex-grow: 1;
max-width: calc(100% - 24px);
max-width: calc(100% - 20px);
display: flex;
margin: 0px 8px;
margin: 0px 0px 0px 6px;
padding: 2px 8px;
flex-direction: row;
align-items: center;
@@ -68,7 +74,7 @@
background: transparent;
transition: 0.2s all linear;
transition: 0.08s all ease;
border-radius: 3px;
@@ -100,7 +106,7 @@
&:hover {
.nav-item-label {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
}
.nav-item-pin-icon {
@@ -120,6 +126,12 @@
.nav-item-active-marker {
background: #4e74f8;
}
.nav-item-data {
.nav-item-label {
color: var(--bg-slate-500);
}
}
}
&:hover {

View File

@@ -1,11 +1,12 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Tag } from 'antd';
import { Tag, Tooltip } from 'antd';
import cx from 'classnames';
import { Pin, PinOff } from 'lucide-react';
import { SidebarItem } from '../sideNav.types';
import './NavItem.styles.scss';
import './NavItem.styles.scss';
export default function NavItem({
@@ -74,21 +75,25 @@ export default function NavItem({
)}
{onTogglePin && !isPinned && (
<Pin
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
<Tooltip title="Add to shortcuts" placement="right">
<Pin
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
</Tooltip>
)}
{onTogglePin && isPinned && (
<PinOff
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
<Tooltip title="Remove from shortcuts" placement="right">
<PinOff
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
</Tooltip>
)}
</div>
</div>

View File

@@ -1,5 +1,5 @@
.sidenav-container {
width: 64px;
width: 54px;
height: 100%;
position: relative;
z-index: 1;
@@ -10,47 +10,60 @@
}
.sideNav {
flex: 0 0 64px;
flex: 0 0 54px;
height: 100%;
max-width: 64px;
min-width: 64px;
width: 64px;
border-right: 1px solid var(--Slate-500, #161922);
background: var(--Ink-500, #0b0c0e);
max-width: 54px;
min-width: 54px;
width: 54px;
border-right: 1px solid var(--bg-slate-500, #161922);
background: var(--bg-ink-500, #0b0c0e);
padding-bottom: 48px;
transition: all 0.2s, background 0s, border 0s;
transition: all 0.08s ease, background 0s, border 0s;
.brand-container {
padding: 8px 18px;
padding: 8px 15px;
max-width: 100%;
background: transparent;
}
.brand-company-meta {
display: flex;
gap: 8px;
align-items: center;
gap: 6px;
flex-shrink: 0;
width: 100%;
justify-content: center;
}
.brand {
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
overflow: hidden;
gap: 32px;
height: 32px;
width: 100%;
.brand-logo {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
gap: 8px;
flex-shrink: 0;
width: 20px;
height: 16px;
position: relative;
cursor: pointer;
img {
height: 16px;
width: auto;
display: block;
}
.brand-logo-name {
@@ -66,6 +79,10 @@
.brand-title-section {
display: none;
flex-shrink: 0;
align-items: center;
gap: 0;
position: relative;
.license-type {
display: flex;
@@ -76,7 +93,7 @@
color: var(--bg-vanilla-100);
border-radius: 4px 0px 0px 4px;
background: var(--Slate-400, #1d212d);
background: var(--bg-slate-400, #1d212d);
text-align: center;
font-family: Inter;
@@ -98,11 +115,11 @@
gap: 6px;
border-radius: 0px 4px 4px 0px;
background: var(--Slate-300, #242834);
background: var(--bg-slate-300, #242834);
}
.version {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
text-align: center;
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings: 'salt' on;
@@ -156,24 +173,48 @@
.get-started-nav-items {
display: flex;
margin: 4px 13px 12px 10px;
margin: 4px 10px 12px 8px;
.get-started-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
margin-left: 2px;
gap: 8px;
width: 100%;
height: 32px;
border-radius: 3px;
border: 1px solid var(--Slate-400, #1d212d);
background: var(--Slate-500, #161922);
border: 1px solid var(--bg-slate-400, #1d212d);
background: var(--bg-slate-500, #161922);
box-shadow: none !important;
color: var(--bg-vanilla-400, #c0c1c3);
svg {
color: var(--bg-vanilla-400, #c0c1c3);
}
.nav-item-label {
color: var(--bg-vanilla-400, #c0c1c3);
}
&:hover:not(:disabled) {
background: var(--bg-slate-400, #1d212d);
border-color: var(--bg-slate-400, #1d212d);
color: var(--bg-vanilla-100, #fff);
svg {
color: var(--bg-vanilla-100, #fff);
}
.nav-item-label {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
@@ -192,6 +233,10 @@
width: 100%;
}
.nav-item {
margin-bottom: 6px;
}
.nav-top-section {
display: flex;
flex-direction: column;
@@ -227,7 +272,7 @@
}
.nav-section-title {
color: var(--Slate-50, #62687c);
color: var(--bg-slate-50, #62687c);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -241,7 +286,7 @@
align-items: center;
gap: 8px;
padding: 0 20px;
padding: 0 17px;
.nav-section-title-text {
display: none;
@@ -250,11 +295,17 @@
.nav-section-title-icon {
display: flex;
align-items: center;
transition: opacity 0.08s ease, transform 0.08s ease;
&.reorder {
display: none;
cursor: pointer;
margin-left: auto;
transition: color 0.2s;
&:hover {
color: var(--bg-vanilla-100, #fff);
}
}
}
@@ -268,7 +319,7 @@
}
.nav-section-subtitle {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -276,20 +327,20 @@
line-height: 14px; /* 150% */
letter-spacing: 0.4px;
padding: 0 20px;
padding: 6px 20px;
opacity: 0.6;
display: none;
transition: all 0.3s, background 0s, border 0s;
transition-delay: 0.1s;
transition: all 0.08s ease, background 0s, border 0s;
transition-delay: 0.03s;
}
.nav-items-section {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
transition: all 0.08s ease;
}
}
@@ -302,7 +353,7 @@
.nav-items-section {
opacity: 0;
transform: translateY(-10px);
transition: all 0.4s ease;
transition: all 0.1s ease;
overflow: hidden;
height: 0;
}
@@ -312,11 +363,34 @@
.nav-items-section {
opacity: 1;
transform: translateY(0);
transition: all 0.4s ease;
transition: all 0.1s ease;
overflow: hidden;
height: auto;
}
}
&.sidebar-collapsed {
.nav-title-section {
display: none;
}
.nav-items-section {
margin-top: 0;
opacity: 1;
transform: translateY(0);
transition: all 0.08s ease;
height: auto;
overflow: visible;
}
}
}
.shortcut-nav-items {
&.sidebar-collapsed {
.nav-items-section {
margin-top: 0;
}
}
}
.scroll-for-more-container {
@@ -326,7 +400,7 @@
width: 100%;
bottom: 12px;
bottom: 8px;
margin-left: 50px;
margin-left: 43px;
.scroll-for-more {
display: flex;
@@ -370,8 +444,6 @@
overflow-y: auto;
overflow-x: hidden;
padding-top: 12px;
.secondary-nav-items {
display: flex;
flex-direction: column;
@@ -381,10 +453,10 @@
overflow-x: hidden;
padding: 8px 0;
max-width: 100%;
width: 64px;
width: 54px;
// width: 100%; // temp
transition: all 0.2s, background 0s, border 0s;
transition: all 0.08s ease, background 0s, border 0s;
background: linear-gradient(180deg, rgba(11, 12, 14, 0) 0%, #0b0c0e 27.11%);
@@ -413,7 +485,7 @@
&.scroll-available {
.nav-bottom-section {
border-top: 1px solid var(--Slate-500, #161922);
border-top: 1px solid var(--bg-slate-500, #161922);
}
}
}
@@ -424,24 +496,53 @@
}
&.collapsed {
flex: 0 0 64px;
max-width: 64px;
min-width: 64px;
width: 64px;
flex: 0 0 54px;
max-width: 54px;
min-width: 54px;
width: 54px;
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title,
.nav-section-title {
display: none;
}
.nav-section-subtitle {
display: none;
}
.nav-items-section {
display: flex;
margin-top: 0;
}
.nav-title-section {
margin-top: 0;
margin-bottom: 0;
gap: 0;
}
}
.more-nav-items {
.nav-section-title {
display: none;
}
.nav-items-section {
display: flex;
margin-top: 0;
}
.nav-title-section {
display: none;
}
}
}
.nav-bottom-section {
.secondary-nav-items {
width: 64px;
width: 54px;
}
}
}
@@ -466,7 +567,7 @@
border-radius: 12px;
background: var(--Robin-500, #4e74f8);
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings: 'case' on, 'cpsp' on, 'dlig' on, 'salt' on;
font-family: Inter;
@@ -479,7 +580,7 @@
}
.sidenav-beta-tag {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings: 'case' on, 'cpsp' on, 'dlig' on, 'salt' on;
font-family: Inter;
@@ -494,7 +595,47 @@
background: var(--bg-slate-300);
}
&:hover {
&:not(.pinned) {
.nav-item {
.nav-item-data {
justify-content: center;
}
}
.shortcut-nav-items,
.more-nav-items {
.nav-section-title {
padding: 0 17px;
.nav-section-title-icon {
display: none;
}
}
}
&.dropdown-open {
.nav-item {
.nav-item-data {
flex-grow: 1;
justify-content: flex-start;
}
}
.shortcut-nav-items,
.more-nav-items {
.nav-section-title {
padding: 0 17px;
.nav-section-title-icon {
display: flex;
}
}
}
}
}
&:not(.pinned):hover,
&.dropdown-open {
flex: 0 0 240px;
max-width: 240px;
min-width: 240px;
@@ -505,8 +646,17 @@
z-index: 10;
background: #0b0c0e;
.brand-container {
padding: 8px 15px;
}
.brand {
justify-content: space-between;
justify-content: flex-start;
.brand-company-meta {
justify-content: flex-start;
width: 100%;
}
.brand-title-section {
display: flex;
@@ -533,6 +683,11 @@
.nav-section-title-icon {
&.reorder {
display: flex;
transition: color 0.2s;
&:hover {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
@@ -574,7 +729,7 @@
flex-direction: row;
gap: 3px;
border-radius: 20px;
background: var(--Slate-400, #1d212d);
background: var(--bg-slate-400, #1d212d);
/* Drop Shadow */
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
@@ -590,7 +745,7 @@
width: 140px;
.scroll-for-more-label {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -631,6 +786,13 @@
align-items: flex-start;
}
}
.nav-item {
.nav-item-data {
flex-grow: 1;
justify-content: flex-start;
}
}
}
.get-started-nav-items {
@@ -664,8 +826,17 @@
z-index: 10;
background: #0b0c0e;
.brand-container {
padding: 8px 15px;
}
.brand {
justify-content: space-between;
justify-content: flex-start;
.brand-company-meta {
justify-content: flex-start;
width: 100%;
}
.brand-title-section {
display: flex;
@@ -692,6 +863,11 @@
.nav-section-title-icon {
&.reorder {
display: flex;
transition: color 0.2s;
&:hover {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
@@ -733,7 +909,7 @@
flex-direction: row;
gap: 3px;
border-radius: 20px;
background: var(--Slate-400, #1d212d);
background: var(--bg-slate-400, #1d212d);
/* Drop Shadow */
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
@@ -751,7 +927,7 @@
.scroll-for-more-label {
display: block;
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -856,7 +1032,7 @@
.ant-dropdown-menu-item {
.ant-dropdown-menu-title-content {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -864,6 +1040,12 @@
line-height: normal;
letter-spacing: 0.14px;
}
&:hover:not(.ant-dropdown-menu-item-disabled) {
.ant-dropdown-menu-title-content {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
}
@@ -875,7 +1057,7 @@
gap: 8px;
.user-settings-dropdown-label-text {
color: var(--Slate-50, #62687c);
color: var(--bg-slate-50, #62687c);
font-family: Inter;
font-size: 10px;
font-family: Inter;
@@ -887,7 +1069,7 @@
}
.user-settings-dropdown-label-email {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -897,12 +1079,16 @@
}
.ant-dropdown-menu-item-divider {
background-color: var(--Slate-500, #161922) !important;
background-color: var(--bg-slate-500, #161922) !important;
}
.ant-dropdown-menu-item-disabled {
opacity: 0.7;
}
.ant-dropdown-menu {
width: 100% !important;
}
}
.settings-dropdown,
@@ -912,6 +1098,27 @@
}
}
.secondary-nav-items {
.nav-item {
position: relative;
.nav-item-active-marker {
position: absolute;
left: -5px;
top: 50%;
transform: translateY(-50%);
margin: 0;
width: 8px;
height: 24px;
z-index: 1;
}
}
.nav-item-data {
margin-left: 8px !important;
}
}
.reorder-shortcut-nav-items-modal {
width: 384px !important;
@@ -1028,7 +1235,6 @@
display: flex;
align-items: center;
border-radius: 2px;
border-radius: 2px;
background: var(--Robin-500, #4e74f8) !important;
color: var(--bg-vanilla-100) !important;
font-family: Inter;
@@ -1038,10 +1244,10 @@
line-height: 24px;
&.secondary-btn {
background-color: var(--Slate-500, #161922) !important;
background-color: var(--bg-slate-500, #161922) !important;
border: 1px solid var(--bg-slate-500) !important;
color: var(--Vanilla-400, #c0c1c3) !important;
color: var(--bg-vanilla-400, #c0c1c3) !important;
/* button/ small */
font-family: Inter;
@@ -1064,6 +1270,10 @@
}
}
.help-support-dropdown li.ant-dropdown-menu-item-divider {
background-color: var(--bg-slate-500, #161922) !important;
}
.lightMode {
.sideNav {
background: var(--bg-vanilla-100);
@@ -1095,8 +1305,32 @@
.get-started-nav-items {
.get-started-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--bg-ink-400);
background: var(--bg-vanilla-200);
color: var(--bg-slate-50, #62687c);
svg {
color: var(--bg-slate-50, #62687c);
}
.nav-item-label {
color: var(--bg-ink-400, #62687c);
}
// Hover state (light mode)
&:hover:not(:disabled) {
background: var(--bg-vanilla-300);
border-color: var(--bg-vanilla-300);
color: var(--bg-slate-500, #161922);
svg {
color: var(--bg-slate-500, #161922);
}
.nav-item-label {
color: var(--bg-slate-500, #161922);
}
}
}
}
@@ -1108,7 +1342,25 @@
}
}
.brand-container {
background: transparent;
}
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title {
.nav-section-title-icon {
&.reorder {
&:hover {
color: var(--bg-slate-400, #1d212d);
}
}
}
}
}
}
.secondary-nav-items {
border-top: 1px solid var(--bg-vanilla-300);
@@ -1123,8 +1375,43 @@
}
}
&:hover {
&.pinned {
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title {
.nav-section-title-icon {
&.reorder {
&:hover {
color: var(--bg-slate-400, #1d212d);
}
}
}
}
}
}
}
}
&:not(.pinned):hover,
&.dropdown-open {
background: var(--bg-vanilla-100);
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title {
.nav-section-title-icon {
&.reorder {
&:hover {
color: var(--bg-slate-400, #1d212d);
}
}
}
}
}
}
}
}
}
@@ -1134,6 +1421,12 @@
.ant-dropdown-menu-title-content {
color: var(--bg-ink-400);
}
&:hover:not(.ant-dropdown-menu-item-disabled) {
.ant-dropdown-menu-title-content {
color: var(--bg-ink-500);
}
}
}
}
}
@@ -1210,6 +1503,10 @@
color: var(--bg-ink-400);
}
}
.help-support-dropdown li.ant-dropdown-menu-item-divider {
background-color: var(--bg-vanilla-300) !important;
}
}
.version-tooltip-overlay {
@@ -1222,7 +1519,7 @@
border-radius: 2px;
border: 1px solid var(--bg-slate-500);
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -1237,7 +1534,7 @@
gap: 4px;
.version-update-notification-tooltip-title {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -1247,7 +1544,7 @@
}
.version-update-notification-tooltip-content {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 10px;
font-style: normal;

View File

@@ -157,18 +157,27 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
DefaultHelpSupportDropdownMenuItems,
);
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
const [tempPinnedMenuItems, setTempPinnedMenuItems] = useState<SidebarItem[]>(
[],
);
const [secondaryMenuItems, setSecondaryMenuItems] = useState<SidebarItem[]>(
[],
);
const [hasScroll, setHasScroll] = useState(false);
const navTopSectionRef = useRef<HTMLDivElement>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
const [secondaryMenuItems, setSecondaryMenuItems] = useState<SidebarItem[]>(
[],
);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
const checkScroll = useCallback((): void => {
if (navTopSectionRef.current) {
@@ -217,63 +226,68 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isEditor = user.role === USER_ROLES.EDITOR;
useEffect(() => {
const navShortcuts = (userPreferences?.find(
// Compute initial pinned items and secondary menu items synchronously to avoid flash
const computedPinnedMenuItems = useMemo(() => {
const navShortcutsPreference = userPreferences?.find(
(preference) => preference.name === USER_PREFERENCES.NAV_SHORTCUTS,
)?.value as unknown) as string[];
);
const navShortcuts = (navShortcutsPreference?.value as unknown) as
| string[]
| undefined;
const shouldShowIntegrations =
(isCloudUser || isEnterpriseSelfHostedUser) && (isAdmin || isEditor);
// If userPreferences not loaded yet, return empty to avoid showing defaults before preferences load
if (userPreferences === null) {
return [];
}
if (navShortcuts && isArray(navShortcuts) && navShortcuts.length > 0) {
// nav shortcuts is array of strings
const pinnedItems = navShortcuts
// If preference exists with non-empty array, use stored shortcuts
if (isArray(navShortcuts) && navShortcuts.length > 0) {
return navShortcuts
.map((shortcut) =>
defaultMoreMenuItems.find((item) => item.itemKey === shortcut),
)
.filter((item): item is SidebarItem => item !== undefined);
// Set pinned items in the order they were stored
setPinnedMenuItems(pinnedItems);
setSecondaryMenuItems(
defaultMoreMenuItems.map((item) => ({
...item,
isPinned: pinnedItems.some((pinned) => pinned.itemKey === item.itemKey),
isEnabled:
item.key === ROUTES.INTEGRATIONS
? shouldShowIntegrations
: item.isEnabled,
})),
);
} else {
// Set default pinned items
const defaultPinnedItems = defaultMoreMenuItems.filter(
(item) => item.isPinned,
);
setPinnedMenuItems(defaultPinnedItems);
setSecondaryMenuItems(
defaultMoreMenuItems.map((item) => ({
...item,
isPinned: defaultPinnedItems.some(
(pinned) => pinned.itemKey === item.itemKey,
),
isEnabled:
item.key === ROUTES.INTEGRATIONS
? shouldShowIntegrations
: item.isEnabled,
})),
);
}
// No preference, or empty array → use defaults
return defaultMoreMenuItems.filter((item) => item.isPinned);
}, [userPreferences]);
const computedSecondaryMenuItems = useMemo(() => {
const shouldShowIntegrationsValue =
(isCloudUser || isEnterpriseSelfHostedUser) && (isAdmin || isEditor);
return defaultMoreMenuItems.map((item) => ({
...item,
isPinned: computedPinnedMenuItems.some(
(pinned) => pinned.itemKey === item.itemKey,
),
isEnabled:
item.key === ROUTES.INTEGRATIONS
? shouldShowIntegrationsValue
: item.isEnabled,
}));
}, [
userPreferences,
computedPinnedMenuItems,
isCloudUser,
isEnterpriseSelfHostedUser,
isAdmin,
isEditor,
]);
// Track if we've done the initial sync (to avoid overwriting user actions during session)
const hasInitializedRef = useRef(false);
// Sync state only on initial load when userPreferences first becomes available
useEffect(() => {
// Only sync once: when userPreferences loads for the first time
if (!hasInitializedRef.current && userPreferences !== null) {
setPinnedMenuItems(computedPinnedMenuItems);
setSecondaryMenuItems(computedSecondaryMenuItems);
hasInitializedRef.current = true;
}
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
const isOnboardingV3Enabled = featureFlags?.find(
(flag) => flag.name === FeatureKeys.ONBOARDING_V3,
)?.active;
@@ -327,6 +341,17 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
.map((item) => item.itemKey)
.filter(Boolean) as string[];
// Update context immediately (optimistically) so computed values reflect the change
updateUserPreferenceInContext({
name: USER_PREFERENCES.NAV_SHORTCUTS,
description: USER_PREFERENCES.NAV_SHORTCUTS,
valueType: 'array',
defaultValue: false,
allowedValues: [],
allowedScopes: ['user'],
value: navShortcuts,
});
updateUserPreferenceMutation(
{
name: USER_PREFERENCES.NAV_SHORTCUTS,
@@ -335,6 +360,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
{
onSuccess: (response) => {
if (response.data) {
// Update context again on success to ensure consistency
updateUserPreferenceInContext({
name: USER_PREFERENCES.NAV_SHORTCUTS,
description: USER_PREFERENCES.NAV_SHORTCUTS,
@@ -368,13 +394,13 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
if (isCurrentlyPinned) {
return prevItems.filter((i) => i.key !== item.key);
}
return [item, ...prevItems];
return [...prevItems, item];
});
// Get the updated pinned menu items for preference update
const updatedPinnedItems = pinnedMenuItems.some((i) => i.key === item.key)
? pinnedMenuItems.filter((i) => i.key !== item.key)
: [item, ...pinnedMenuItems];
: [...pinnedMenuItems, item];
// Update user preference with the ordered list of item keys
updateNavShortcutsPreference(updatedPinnedItems);
@@ -455,6 +481,10 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
pathname,
]);
const isSettingsPage = useMemo(() => pathname.startsWith(ROUTES.SETTINGS), [
pathname,
]);
const userSettingsDropdownMenuItems: MenuProps['items'] = useMemo(
() =>
[
@@ -594,7 +624,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
},
{
type: 'group',
label: "WHAT's NEW",
label: "WHAT'S NEW",
},
...dropdownItems,
{
@@ -750,6 +780,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
[secondaryMenuItems],
);
// Get active "More" items that should be visible in collapsed state
const activeMoreMenuItems = useMemo(
() => moreMenuItems.filter((item) => activeMenuKey === item.key),
[moreMenuItems, activeMenuKey],
);
// Check if sidebar is collapsed (not pinned, not hovered, and no dropdown open)
const isCollapsed = !isPinned && !isHovered && !isDropdownOpen;
const renderNavItems = (
items: SidebarItem[],
allowPin?: boolean,
@@ -901,7 +940,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
return (
<div className={cx('sidenav-container', isPinned && 'pinned')}>
<div className={cx('sideNav', isPinned && 'pinned')}>
<div
className={cx(
'sideNav',
isPinned && 'pinned',
isDropdownOpen && 'dropdown-open',
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="brand-container">
<div className="brand">
<div className="brand-company-meta">
@@ -999,35 +1046,43 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
{renderNavItems(primaryMenuItems)}
</div>
<div className="shortcut-nav-items">
<div className="nav-title-section">
<div className="nav-section-title">
<div className="nav-section-title-icon">
<MousePointerClick size={16} />
</div>
{(pinnedMenuItems.length > 0 || !isCollapsed) && (
<div
className={cx('shortcut-nav-items', isCollapsed && 'sidebar-collapsed')}
>
{!isCollapsed && (
<div className="nav-title-section">
<div className="nav-section-title">
<div className="nav-section-title-icon">
<MousePointerClick size={16} />
</div>
<div className="nav-section-title-text">SHORTCUTS</div>
<div className="nav-section-title-text">SHORTCUTS</div>
{pinnedMenuItems.length > 1 && (
<div
className="nav-section-title-icon reorder"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts clicked', {});
setIsReorderShortcutNavItemsModalOpen(true);
}}
>
<Logs size={16} />
{pinnedMenuItems.length > 1 && (
<Tooltip title="Manage shortcuts" placement="right">
<div
className="nav-section-title-icon reorder"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts clicked', {});
setIsReorderShortcutNavItemsModalOpen(true);
}}
>
<Logs size={16} />
</div>
</Tooltip>
)}
</div>
)}
</div>
{pinnedMenuItems.length === 0 && (
<div className="nav-section-subtitle">
You have not added any shortcuts yet.
{pinnedMenuItems.length === 0 && (
<div className="nav-section-subtitle">
You have not added any shortcuts yet.
</div>
)}
</div>
)}
{pinnedMenuItems.length > 0 && (
{(pinnedMenuItems.length > 0 || isCollapsed) && (
<div className="nav-items-section">
{renderNavItems(
pinnedMenuItems.filter((item) => item.isEnabled),
@@ -1036,46 +1091,60 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
</div>
)}
</div>
</div>
)}
{moreMenuItems.length > 0 && (
<div
className={cx(
'more-nav-items',
isMoreMenuCollapsed ? 'collapsed' : 'expanded',
isCollapsed && 'sidebar-collapsed',
)}
>
<div className="nav-title-section">
<div
className="nav-section-title"
onClick={(): void => {
logEvent('Sidebar V2: More menu clicked', {
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
});
setIsMoreMenuCollapsed(!isMoreMenuCollapsed);
}}
>
<div className="nav-section-title-icon">
<Ellipsis size={16} />
</div>
{!isCollapsed && (
<div className="nav-title-section">
<div
className="nav-section-title"
onClick={(): void => {
// Only allow toggling when sidebar is open (pinned, hovered, or dropdown open)
if (isCollapsed) {
return;
}
const newCollapsedState = !isMoreMenuCollapsed;
logEvent('Sidebar V2: More menu clicked', {
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
});
setIsMoreMenuCollapsed(newCollapsedState);
}}
>
<div className="nav-section-title-icon">
<Ellipsis size={16} />
</div>
<div className="nav-section-title-text">MORE</div>
<div className="nav-section-title-text">MORE</div>
<div className="collapse-expand-section-icon">
{isMoreMenuCollapsed ? (
<ChevronDown size={16} />
) : (
<ChevronUp size={16} />
)}
<div className="collapse-expand-section-icon">
{isMoreMenuCollapsed ? (
<ChevronDown size={16} />
) : (
<ChevronUp size={16} />
)}
</div>
</div>
</div>
</div>
)}
<div className="nav-items-section">
{renderNavItems(
moreMenuItems.filter((item) => item.isEnabled),
true,
)}
{/* Show all items when expanded, only active items when collapsed */}
{isCollapsed
? renderNavItems(
activeMoreMenuItems.filter((item) => item.isEnabled),
true,
)
: renderNavItems(
moreMenuItems.filter((item) => item.isEnabled),
true,
)}
</div>
</div>
)}
@@ -1102,6 +1171,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
placement="topLeft"
overlayClassName="nav-dropdown-overlay help-support-dropdown"
trigger={['click']}
onOpenChange={(open): void => setIsDropdownOpen(open)}
>
<div className="nav-item">
<div className="nav-item-data" data-testid="help-support-nav-item">
@@ -1122,8 +1192,10 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
placement="topLeft"
overlayClassName="nav-dropdown-overlay settings-dropdown"
trigger={['click']}
onOpenChange={(open): void => setIsDropdownOpen(open)}
>
<div className="nav-item">
<div className={cx('nav-item', isSettingsPage && 'active')}>
<div className="nav-item-active-marker" />
<div className="nav-item-data" data-testid="settings-nav-item">
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>

View File

@@ -1,15 +1,17 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import useGetResolvedText from '../useGetResolvedText';
// Mock the useDashboard hook
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: function useDashboardMock(): any {
return {
selectedDashboard: null,
};
},
// Create a mock function that we can modify per test
let mockDashboardVariables: IDashboardVariables = {};
// Mock the useDashboardVariables hook
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: jest.fn(() => ({
dashboardVariables: mockDashboardVariables,
})),
}));
describe('useGetResolvedText', () => {
@@ -20,13 +22,35 @@ describe('useGetResolvedText', () => {
const TRUNCATED_SERVICE = 'test, app +2';
const TEXT_TEMPLATE = 'Logs count in $service.name in $severity';
const renderHookWithProps = (props: {
text: string | React.ReactNode;
variables?: Record<string, string | number | boolean>;
dashboardVariables?: Record<string, any>;
maxLength?: number;
matcher?: string;
}): any => renderHook(() => useGetResolvedText(props));
const renderHookWithProps = (
props: {
text: string | React.ReactNode;
maxLength?: number;
matcher?: string;
},
variables?: Record<string, string | number | boolean>,
): any => {
if (variables) {
mockDashboardVariables = Object.entries(
variables,
).reduce<IDashboardVariables>((acc, [key, value]) => {
acc[key] = {
id: key,
name: key,
description: '',
type: 'CUSTOM' as const,
sort: 'DISABLED' as const,
multiSelect: false,
showALLOption: false,
selectedValue: value,
};
return acc;
}, {});
} else {
mockDashboardVariables = {};
}
return renderHook(() => useGetResolvedText(props));
};
it('should resolve variables with truncated and full text', () => {
const text = TEXT_TEMPLATE;
@@ -35,7 +59,7 @@ describe('useGetResolvedText', () => {
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, variables });
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
`Logs count in ${TRUNCATED_SERVICE} in DEBUG, INFO`,
@@ -50,7 +74,7 @@ describe('useGetResolvedText', () => {
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, variables, maxLength: 20 });
const { result } = renderHookWithProps({ text, maxLength: 20 }, variables);
expect(result.current.truncatedText).toBe('Logs count in test, a...');
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
@@ -62,7 +86,7 @@ describe('useGetResolvedText', () => {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text, variables });
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 and test, app +2',
@@ -80,7 +104,7 @@ describe('useGetResolvedText', () => {
'$dyn-service.name': 'dyn-1, dyn-2',
};
const { result } = renderHookWithProps({ text, variables });
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs in test, app +2, test, app +2, test, app +2 - dyn-1, dyn-2',
@@ -97,7 +121,7 @@ describe('useGetResolvedText', () => {
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, variables, matcher: '#' });
const { result } = renderHookWithProps({ text, matcher: '#' }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in DEBUG, INFO',
@@ -112,7 +136,7 @@ describe('useGetResolvedText', () => {
active: true,
};
const { result } = renderHookWithProps({ text, variables });
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('Count: 42, Active: true');
expect(result.current.truncatedText).toBe('Count: 42, Active: true');
@@ -124,7 +148,7 @@ describe('useGetResolvedText', () => {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text, variables });
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in $unknown',
@@ -140,10 +164,12 @@ describe('useGetResolvedText', () => {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({
text: reactNodeText,
const { result } = renderHookWithProps(
{
text: reactNodeText,
},
variables,
});
);
// Should return the ReactNode unchanged
expect(result.current.fullText).toBe(reactNodeText);
@@ -156,10 +182,12 @@ describe('useGetResolvedText', () => {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({
text,
const { result } = renderHookWithProps(
{
text,
},
variables,
});
);
// Should return the number unchanged
expect(result.current.fullText).toBe(text);
@@ -172,10 +200,12 @@ describe('useGetResolvedText', () => {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({
text,
const { result } = renderHookWithProps(
{
text,
},
variables,
});
);
// Should return the boolean unchanged
expect(result.current.fullText).toBe(text);
@@ -189,7 +219,7 @@ describe('useGetResolvedText', () => {
'config.database.host': 'localhost:5432',
};
const { result } = renderHookWithProps({ text, variables });
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('API: /users Config: localhost:5432');
expect(result.current.truncatedText).toBe(
@@ -204,7 +234,7 @@ describe('useGetResolvedText', () => {
'error.type': 'timeout',
};
const { result } = renderHookWithProps({ text, variables });
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('Status: web-api, Error: timeout;');
expect(result.current.truncatedText).toBe('Status: web-api, Error: timeout;');

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -38,20 +38,17 @@ interface ResolvedTextUtilsResult {
function useContextVariables({
maxValues = 2,
// ! To be noted: This customVariables is not Dashboard Custom Variables
customVariables,
}: UseContextVariablesProps): UseContextVariablesResult {
const { selectedDashboard } = useDashboard();
const { dashboardVariables } = useDashboardVariables();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Extract dashboard variables
const dashboardVariables = useMemo(() => {
if (!selectedDashboard?.data?.variables) {
return [];
}
return Object.entries(selectedDashboard.data.variables)
const processedDashboardVariables = useMemo(() => {
return Object.entries(dashboardVariables)
.filter(([, value]) => value.name)
.map(([, value]) => {
let processedValue: string | number | boolean;
@@ -74,7 +71,7 @@ function useContextVariables({
originalValue: value.selectedValue,
};
});
}, [selectedDashboard]);
}, [dashboardVariables]);
// Extract global variables
const globalVariables = useMemo(
@@ -111,8 +108,12 @@ function useContextVariables({
// Combine all variables
const allVariables = useMemo(
() => [...dashboardVariables, ...globalVariables, ...customVariablesList],
[dashboardVariables, globalVariables, customVariablesList],
() => [
...processedDashboardVariables,
...globalVariables,
...customVariablesList,
],
[processedDashboardVariables, globalVariables, customVariablesList],
);
// Create processed variables with truncation logic

View File

@@ -5,9 +5,11 @@ import {
IDashboardVariables,
} from '../../providers/Dashboard/store/dashboardVariablesStore';
export const useDashboardVariables = (): {
export interface IUseDashboardVariablesReturn {
dashboardVariables: IDashboardVariables;
} => {
}
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
const dashboardVariables = useSyncExternalStore(
dashboardVariablesStore.subscribe,
dashboardVariablesStore.getSnapshot,

View File

@@ -5,7 +5,7 @@
// return value should be a full text string, and a truncated text string (if max length is provided)
import { ReactNode, useCallback, useMemo } from 'react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
interface UseGetResolvedTextProps {
text: string | ReactNode;
@@ -23,23 +23,15 @@ interface ResolvedTextResult {
// eslint-disable-next-line sonarjs/cognitive-complexity
function useGetResolvedText({
text,
variables,
maxLength,
matcher = '$',
maxValues = 2, // Default to showing 2 values before +n more
}: UseGetResolvedTextProps): ResolvedTextResult {
const { selectedDashboard } = useDashboard();
const { dashboardVariables } = useDashboardVariables();
const isString = typeof text === 'string';
const processedDashboardVariables = useMemo(() => {
if (variables) {
return variables;
}
if (!selectedDashboard?.data.variables) {
return {};
}
return Object.entries(selectedDashboard.data.variables).reduce<
return Object.entries(dashboardVariables).reduce<
Record<string, string | number | boolean>
>((acc, [, value]) => {
if (!value.name) {
@@ -54,7 +46,7 @@ function useGetResolvedText({
}
return acc;
}, {});
}, [variables, selectedDashboard?.data.variables]);
}, [dashboardVariables]);
// Process array values to add +n more notation for truncated text
const processedVariables = useMemo(() => {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
@@ -10,13 +10,15 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { AppState } from 'store/reducers';
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
@@ -32,12 +34,10 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
const { dashboardVariables } = useDashboardVariables();
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
return useCallback(() => {
@@ -68,9 +68,9 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
globalSelectedInterval,
graphType: getGraphType(widget.panelTypes),
selectedTime: widget.timePreferance,
variables: getDashboardVariables(selectedDashboard?.data.variables),
variables: getDashboardVariables(dashboardVariables),
originalGraphType: widget.panelTypes,
dynamicVariables,
dynamicVariables: dashboardDynamicVariables,
});
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
@@ -104,10 +104,10 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
globalSelectedInterval,
notifications,
queryRangeMutation,
selectedDashboard?.data.variables,
dashboardVariables,
dashboardDynamicVariables,
selectedDashboard?.data.version,
widget,
dynamicVariables,
]);
};

View File

@@ -4,14 +4,13 @@ import { isAxiosError } from 'axios';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { updateBarStepInterval } from 'container/GridCardLayout/utils';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { SuccessResponse, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
@@ -43,14 +42,9 @@ export const useGetQueryRange: UseGetQueryRange = (
headers,
publicQueryMeta,
) => {
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const newRequestData: GetQueryResultsProps = useMemo(() => {
@@ -159,7 +153,7 @@ export const useGetQueryRange: UseGetQueryRange = (
GetMetricQueryRange(
modifiedRequestData,
version,
dynamicVariables,
dashboardDynamicVariables,
signal,
headers,
undefined,

View File

@@ -2,9 +2,9 @@ import { UseQueryOptions, UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -28,7 +28,7 @@ export const useGetWidgetQueryRange = (
const { stagedQuery } = useQueryBuilder();
const { selectedDashboard } = useDashboard();
const { dashboardVariables } = useDashboardVariables();
return useGetQueryRange(
{
@@ -36,7 +36,7 @@ export const useGetWidgetQueryRange = (
selectedTime,
globalSelectedInterval,
query: stagedQuery || initialQueriesMap.metrics,
variables: getDashboardVariables(selectedDashboard?.data.variables),
variables: getDashboardVariables(dashboardVariables),
},
version,
{

View File

@@ -4,9 +4,8 @@ import {
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WhereClauseConfig } from './useAutoComplete';
@@ -32,16 +31,12 @@ export const useOptions = (
const operators = useOperators(key, keys);
// get matching dynamic variables to suggest
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const variableName = dynamicVariables?.find(
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === key,
)?.name;

View File

@@ -1,9 +1,9 @@
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import store from 'store';
import { Dashboard } from 'types/api/dashboard/getAll';
export const getDashboardVariables = (
variables?: Dashboard['data']['variables'],
variables?: IDashboardVariables,
): Record<string, unknown> => {
if (!variables) {
return {};

View File

@@ -0,0 +1,118 @@
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
.legend-virtuoso-container {
height: 100%;
width: 100%;
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-marker {
border-width: 2px;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
&:hover {
background: rgba(255, 255, 255, 0.05);
}
}
.lightMode {
.legend-container {
.legend-virtuoso-container {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
}
}

View File

@@ -0,0 +1,101 @@
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Tooltip as AntdTooltip } from 'antd';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { LegendProps } from '../types';
import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
export const MAX_LEGEND_WIDTH = 320;
const LEGENDS_PER_SET_DEFAULT = 5;
export default function Legend({
position = LegendPosition.BOTTOM,
config,
legendsPerSet = LEGENDS_PER_SET_DEFAULT,
}: LegendProps): JSX.Element {
const {
legendItemsMap,
focusedSeriesIndex,
setFocusedSeriesIndex,
} = useLegendsSync({ config });
const {
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
// Chunk legend items into rows of LEGENDS_PER_ROW items each
const legendRows = useMemo(() => {
const legendItems = Object.values(legendItemsMap);
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
if (i % legendsPerSet === 0) {
acc.push([]);
}
acc[acc.length - 1].push(curr);
return acc;
}, [] as LegendItem[][]);
}, [legendItemsMap, legendsPerSet]);
const renderLegendRow = useCallback(
(rowIndex: number, row: LegendItem[]): JSX.Element => (
<div
key={rowIndex}
className={cx(
'legend-row',
`legend-row-${position.toLowerCase()}`,
legendRows.length === 1 && position === LegendPosition.BOTTOM
? 'legend-single-row'
: '',
)}
>
{row.map((item) => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
<div
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
style={{ maxWidth: `min(${MAX_LEGEND_WIDTH}px, 100%)` }}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
))}
</div>
),
[focusedSeriesIndex, position, legendRows],
);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
>
<Virtuoso
className="legend-virtuoso-container"
data={legendRows}
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
/>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
export function useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
}: {
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
focusedSeriesIndex: number | null;
}): {
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onFocusSeries: (seriesIndex: number | null) => void;
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
} {
const {
onFocusSeries: onFocusSeriesPlot,
onToggleSeriesOnOff,
onToggleSeriesVisibility,
} = usePlotContext();
const rafId = useRef<number | null>(null); // requestAnimationFrame id
const getLegendItemIdFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
const target = e.target as HTMLElement | null;
if (!target) {
return undefined;
}
const legendItemElement = target.closest<HTMLElement>(
'[data-legend-item-id]',
);
return legendItemElement?.dataset.legendItemId;
},
[],
);
const onLegendClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
if (!legendItemId) {
return;
}
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const seriesIndex = Number(legendItemId);
if (isLegendMarker) {
onToggleSeriesOnOff(seriesIndex);
return;
}
onToggleSeriesVisibility(seriesIndex);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
);
const onFocusSeries = useCallback(
(seriesIndex: number | null): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(() => {
setFocusedSeriesIndex(seriesIndex);
onFocusSeriesPlot(seriesIndex);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeriesPlot],
);
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
const seriesIndex = legendItemId ? Number(legendItemId) : null;
if (seriesIndex === focusedSeriesIndex) {
return;
}
onFocusSeries(seriesIndex);
};
const onLegendMouseLeave = useCallback(
(): void => {
// Cancel any pending RAF from handleFocusSeries to prevent race condition
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
setFocusedSeriesIndex(null);
onFocusSeries(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeries],
);
// Cleanup pending animation frames on unmount
useEffect(
() => (): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
},
[],
);
return {
onLegendClick,
onFocusSeries,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -0,0 +1,59 @@
.uplot-tooltip-container {
font-family: 'Inter';
font-size: 12px;
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
border-radius: 6px;
padding: 1rem 1rem 0.5rem 1rem;
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
gap: 8px;
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
.uplot-tooltip-list-container {
height: 100%;
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
}
}
}
.uplot-tooltip-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.uplot-tooltip-item-marker {
border-radius: 50%;
border-width: 2px;
width: 12px;
height: 12px;
flex-shrink: 0;
}
.uplot-tooltip-item-content {
white-space: wrap;
word-break: break-all;
}
}
}

View File

@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TooltipContentItem, TooltipProps } from '../types';
import { buildTooltipContent } from './utils';
import './Tooltip.styles.scss';
const TOOLTIP_LIST_MAX_HEIGHT = 330;
const TOOLTIP_ITEM_HEIGHT = 38;
export default function Tooltip({
seriesIndex,
dataIndexes,
uPlotInstance,
timezone,
yAxisUnit = '',
decimalPrecision,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const headerTitle = useMemo(() => {
const data = uPlotInstance.data;
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
return dayjs(data[0][cursorIdx] * 1000)
.tz(timezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
const content = useMemo(
(): TooltipContentItem[] =>
buildTooltipContent({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIndex: seriesIndex,
uPlotInstance,
yAxisUnit,
decimalPrecision,
}),
[uPlotInstance, seriesIndex, dataIndexes, yAxisUnit, decimalPrecision],
);
return (
<div
className={cx(
'uplot-tooltip-container',
isDarkMode ? 'darkMode' : 'lightMode',
)}
>
<div className="uplot-tooltip-header">
<span>{headerTitle}</span>
</div>
<div
style={{
height: Math.min(
content.length * TOOLTIP_ITEM_HEIGHT,
TOOLTIP_LIST_MAX_HEIGHT,
),
minHeight: 0,
}}
>
{content.length > 0 ? (
<Virtuoso
className="uplot-tooltip-list"
data={content}
defaultItemHeight={TOOLTIP_ITEM_HEIGHT}
itemContent={(_, item): JSX.Element => (
<div className="uplot-tooltip-item">
<div
className="uplot-tooltip-item-marker"
style={{ borderColor: item.color }}
data-is-legend-marker={true}
/>
<div
className="uplot-tooltip-item-content"
style={{ color: item.color, fontWeight: item.isActive ? 700 : 400 }}
>
{item.label}: {item.tooltipValue}
</div>
</div>
)}
/>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot, { AlignedData, Series } from 'uplot';
import { TooltipContentItem } from '../types';
const FALLBACK_SERIES_COLOR = '#000000';
export function resolveSeriesColor(
stroke: Series.Stroke | undefined,
u: uPlot,
seriesIndex: number,
): string {
if (typeof stroke === 'function') {
return String(stroke(u, seriesIndex));
}
if (typeof stroke === 'string') {
return stroke;
}
return FALLBACK_SERIES_COLOR;
}
export function buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIndex,
uPlotInstance,
yAxisUnit,
decimalPrecision,
}: {
data: AlignedData;
series: Series[];
dataIndexes: Array<number | null>;
activeSeriesIndex: number | null;
uPlotInstance: uPlot;
yAxisUnit: string;
decimalPrecision?: PrecisionOption;
}): TooltipContentItem[] {
const active: TooltipContentItem[] = [];
const rest: TooltipContentItem[] = [];
for (let index = 1; index < series.length; index += 1) {
const s = series[index];
if (!s?.show) {
continue;
}
const dataIndex = dataIndexes[index];
// Skip series with no data at the current cursor position
if (dataIndex === null) {
continue;
}
const raw = data[index]?.[dataIndex];
const value = Number(raw);
const displayValue = Number.isNaN(value) ? 0 : value;
const isActive = index === activeSeriesIndex;
const item: TooltipContentItem = {
label: String(s.label ?? ''),
value: displayValue,
tooltipValue: getToolTipValue(displayValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
isActive,
};
if (isActive) {
active.push(item);
} else {
rest.push(item);
}
}
return [...active, ...rest];
}

View File

@@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import * as Sentry from '@sentry/react';
import { Typography } from 'antd';
import { isEqual } from 'lodash-es';
import { LineChart } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import uPlot, { AlignedData, Options } from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { usePlotContext } from '../context/PlotContext';
import { UPlotChartProps } from './types';
/**
* Check if dimensions have changed
*/
function sameDimensions(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return next.width === prev.width && next.height === prev.height;
}
/**
* Check if data has changed (value equality)
*/
function sameData(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return isEqual(next.data, prev.data);
}
/**
* Check if config builder has changed (value equality)
*/
function sameConfig(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return isEqual(next.config, prev.config);
}
/**
* Plot component for rendering uPlot charts using the builder pattern
* Manages uPlot instance lifecycle and handles updates efficiently
*/
export default function UPlotChart({
config,
data,
width,
height,
plotRef,
onDestroy,
children,
'data-testid': testId = 'uplot-main-div',
}: UPlotChartProps): JSX.Element {
const { setPlotContextInitialState } = usePlotContext();
const containerRef = useRef<HTMLDivElement>(null);
const plotInstanceRef = useRef<uPlot | null>(null);
const prevPropsRef = useRef<UPlotChartProps | null>(null);
const configUsedForPlotRef = useRef<UPlotConfigBuilder | null>(null);
/**
* Destroy the existing plot instance if present.
*/
const destroyPlot = useCallback((): void => {
if (plotInstanceRef.current) {
onDestroy?.(plotInstanceRef.current);
// Clean up the config builder that was used to create this plot (not the current prop)
if (configUsedForPlotRef.current) {
configUsedForPlotRef.current.destroy();
}
configUsedForPlotRef.current = null;
plotInstanceRef.current.destroy();
plotInstanceRef.current = null;
setPlotContextInitialState({ uPlotInstance: null });
plotRef?.(null);
}
}, [onDestroy, plotRef, setPlotContextInitialState]);
/**
* Initialize or reinitialize the plot
*/
const createPlot = useCallback(() => {
// Destroy existing plot first
destroyPlot();
if (!containerRef.current || width === 0 || height === 0) {
return;
}
// Build configuration from builder
const configOptions = config.getConfig();
// Merge with dimensions
const plotConfig: Options = {
width: Math.floor(width),
height: Math.floor(height),
...configOptions,
} as Options;
// Create new plot instance
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
if (plotRef) {
plotRef(plot);
}
setPlotContextInitialState({
uPlotInstance: plot,
widgetId: config.getWidgetId(),
});
plotInstanceRef.current = plot;
configUsedForPlotRef.current = config;
}, [
config,
data,
width,
height,
plotRef,
destroyPlot,
setPlotContextInitialState,
]);
/**
* Destroy plot when data becomes empty to prevent memory leaks.
* When the "No Data" UI is shown, the container div is unmounted,
* but without this effect the plot instance would remain in memory.
*/
const isDataEmpty = useMemo(() => {
return !!(data && data[0] && data[0].length === 0);
}, [data]);
useEffect(() => {
if (isDataEmpty) {
destroyPlot();
}
}, [isDataEmpty, destroyPlot]);
/**
* Handle initialization and prop changes
*/
useEffect(() => {
const prevProps = prevPropsRef.current;
const currentProps = { config, data, width, height };
// First render - initialize
if (!prevProps) {
createPlot();
prevPropsRef.current = currentProps;
return;
}
// Check if the plot instance's container has been unmounted (e.g., after "No Data" state)
// If so, we need to recreate the plot with the new container
const isPlotOrphaned =
plotInstanceRef.current &&
plotInstanceRef.current.root !== containerRef.current;
// Update dimensions without reinitializing if only size changed
if (
!sameDimensions(prevProps, currentProps) &&
plotInstanceRef.current &&
!isPlotOrphaned
) {
plotInstanceRef.current.setSize({
width: Math.floor(width),
height: Math.floor(height),
});
}
// Reinitialize if config changed or if the plot was orphaned (container changed)
if (!sameConfig(prevProps, currentProps) || isPlotOrphaned) {
createPlot();
}
// Update data if only data changed
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
plotInstanceRef.current.setData(data as AlignedData);
}
prevPropsRef.current = currentProps;
}, [config, data, width, height, createPlot]);
if (isDataEmpty) {
return (
<div
className="uplot-no-data not-found"
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
<LineChart size={48} strokeWidth={0.5} />
<Typography>No Data</Typography>
</div>
);
}
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div style={{ position: 'relative' }}>
<div ref={containerRef} data-testid={testId} />
{children}
</div>
</Sentry.ErrorBoundary>
);
}

View File

@@ -0,0 +1,87 @@
import { ReactNode } from 'react';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
/**
* Props for the Plot component
*/
export interface UPlotChartProps {
/**
* uPlot configuration builder
*/
config: UPlotConfigBuilder;
/**
* Chart data in uPlot.AlignedData format
*/
data: uPlot.AlignedData;
/**
* Chart width in pixels
*/
width: number;
/**
* Chart height in pixels
*/
height: number;
/**
* Optional callback when plot instance is created or destroyed.
* Called with the uPlot instance on create, and with null when the plot is destroyed.
*/
plotRef?: (u: uPlot | null) => void;
/**
* Optional callback when plot is destroyed
*/
onDestroy?: (u: uPlot) => void;
/**
* Children elements (typically plugins)
*/
children?: ReactNode;
/**
* Test ID for the container div
*/
'data-testid'?: string;
}
export interface TooltipRenderArgs {
uPlotInstance: uPlot;
dataIndexes: Array<number | null>;
seriesIndex: number | null;
isPinned: boolean;
dismiss: () => void;
viaSync: boolean;
}
export type TooltipProps = TooltipRenderArgs & {
timezone: string;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
};
export enum LegendPosition {
BOTTOM = 'bottom',
RIGHT = 'right',
}
export interface LegendConfig {
position: LegendPosition;
}
export interface LegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
legendsPerSet?: number;
}
export interface TooltipContentItem {
label: string;
value: number;
tooltipValue: string;
color: string;
isActive: boolean;
}

View File

@@ -0,0 +1,284 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
import getGridColor from '../../uPlotLib/utils/getGridColor';
import { AxisProps, ConfigBuilder } from './types';
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.BAR,
PANEL_TYPES.PIE,
];
/**
* Builder for uPlot axis configuration
* Handles creation and merging of axis settings
* Based on getAxes utility function patterns
*/
export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
/**
* Build grid configuration based on theme and scale type.
* Supports partial grid config: provided values override defaults.
*/
private buildGridConfig(): uPlot.Axis.Grid | undefined {
const { grid, isDarkMode, isLogScale } = this.props;
const defaultStroke = getGridColor(isDarkMode ?? false);
const defaultWidth = isLogScale ? 0.1 : 0.2;
const defaultShow = true;
// Merge partial or full grid config with defaults
if (grid) {
return {
stroke: grid.stroke ?? defaultStroke,
width: grid.width ?? defaultWidth,
show: grid.show ?? defaultShow,
};
}
return {
stroke: defaultStroke,
width: defaultWidth,
show: defaultShow,
};
}
/**
* Build ticks configuration
*/
private buildTicksConfig(): uPlot.Axis.Ticks | undefined {
const { ticks } = this.props;
// If explicit ticks config provided, use it
if (ticks) {
return ticks;
}
// Build default ticks config
return {
width: 0.3,
show: true,
};
}
/**
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { panelType } = this.props;
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
return undefined;
}
/**
* Build values formatter for Y-axis (values with units)
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
}
/**
* Build values formatter based on axis type and props
*/
private buildValuesFormatter(): uPlot.Axis.Values | undefined {
const { values, scaleKey } = this.props;
// If explicit values formatter provided, use it
if (values) {
return values;
}
// Route to appropriate formatter based on scale key
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
: undefined;
}
/**
* Calculate axis size from existing size property
*/
private getExistingAxisSize(
self: uPlot,
axis: Axis,
values: string[] | undefined,
axisIdx: number,
cycleNum: number,
): number {
const internalSize = (axis as { _size?: number })._size;
if (internalSize !== undefined) {
return internalSize;
}
const existingSize = axis.size;
if (typeof existingSize === 'function') {
return existingSize(self, values ?? [], axisIdx, cycleNum);
}
return existingSize ?? 0;
}
/**
* Calculate text width for longest value
*/
private calculateTextWidth(
self: uPlot,
axis: Axis,
values: string[] | undefined,
): number {
if (!values || values.length === 0) {
return 0;
}
// Find longest value
const longestVal = values.reduce(
(acc, val) => (val.length > acc.length ? val : acc),
'',
);
if (longestVal === '' || !axis.font?.[0]) {
return 0;
}
// eslint-disable-next-line prefer-destructuring, no-param-reassign
self.ctx.font = axis.font[0];
return self.ctx.measureText(longestVal).width / devicePixelRatio;
}
/**
* Build Y-axis dynamic size calculator
*/
private buildYAxisSizeCalculator(): uPlot.Axis.Size {
return (
self: uPlot,
values: string[] | undefined,
axisIdx: number,
cycleNum: number,
): number => {
const axis = self.axes[axisIdx];
// Bail out, force convergence
if (cycleNum > 1) {
return this.getExistingAxisSize(self, axis, values, axisIdx, cycleNum);
}
const gap = this.props.gap ?? 5;
let axisSize = (axis.ticks?.size ?? 0) + gap;
axisSize += this.calculateTextWidth(self, axis, values);
return Math.ceil(axisSize);
};
}
/**
* Build dynamic size calculator for Y-axis
*/
private buildSizeCalculator(): uPlot.Axis.Size | undefined {
const { size, scaleKey } = this.props;
// If explicit size calculator provided, use it
if (size) {
return size;
}
// Y-axis needs dynamic sizing based on text width
if (scaleKey === 'y') {
return this.buildYAxisSizeCalculator();
}
return undefined;
}
/**
* Build stroke color based on props
*/
private buildStrokeColor(): string | undefined {
const { stroke, isDarkMode } = this.props;
if (stroke !== undefined) {
return stroke;
}
if (isDarkMode !== undefined) {
return isDarkMode ? 'white' : 'black';
}
return undefined;
}
getConfig(): Axis {
const {
scaleKey,
label,
show = true,
side = 2, // bottom by default
space,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
const stroke = this.buildStrokeColor();
const axisConfig: Axis = {
scale: scaleKey,
show,
side,
};
// Add properties conditionally
if (label) {
axisConfig.label = label;
}
if (stroke) {
axisConfig.stroke = stroke;
}
if (grid) {
axisConfig.grid = grid;
}
if (ticks) {
axisConfig.ticks = ticks;
}
if (values) {
axisConfig.values = values;
}
if (gap !== undefined) {
axisConfig.gap = gap;
}
if (space !== undefined) {
axisConfig.space = space;
}
if (size) {
axisConfig.size = size;
}
return axisConfig;
}
merge(props: Partial<AxisProps>): void {
this.props = { ...this.props, ...props };
}
}
export type { AxisProps };

View File

@@ -0,0 +1,293 @@
import { getStoredSeriesVisibility } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
import { merge } from 'lodash-es';
import noop from 'lodash-es/noop';
import uPlot, { Cursor, Hooks, Options } from 'uplot';
import {
ConfigBuilder,
ConfigBuilderProps,
DEFAULT_CURSOR_CONFIG,
DEFAULT_PLOT_CONFIG,
LegendItem,
} from './types';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
/**
* Type definitions for uPlot option objects
*/
type LegendConfig = {
show?: boolean;
live?: boolean;
isolate?: boolean;
[key: string]: unknown;
};
/**
* Main builder orchestrator for uPlot configuration
* Manages axes, scales, series, and hooks in a composable way
*/
export class UPlotConfigBuilder extends ConfigBuilder<
ConfigBuilderProps,
Partial<Options>
> {
series: UPlotSeriesBuilder[] = [];
private axes: Record<string, UPlotAxisBuilder> = {};
readonly scales: UPlotScaleBuilder[] = [];
private bands: uPlot.Band[] = [];
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
private plugins: uPlot.Plugin[] = [];
private padding: [number, number, number, number] | undefined;
private legend: LegendConfig | undefined;
private focus: uPlot.Focus | undefined;
private select: uPlot.Select | undefined;
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
private tzDate: ((timestamp: number) => Date) | undefined;
private widgetId: string | undefined;
private onDragSelect: (startTime: number, endTime: number) => void;
private cleanups: Array<() => void> = [];
constructor(args?: ConfigBuilderProps) {
super(args ?? {});
const { widgetId, onDragSelect, tzDate } = args ?? {};
if (widgetId) {
this.widgetId = widgetId;
}
if (tzDate) {
this.tzDate = tzDate;
}
this.onDragSelect = noop;
if (onDragSelect) {
this.onDragSelect = onDragSelect;
// Add a hook to handle the select event
const cleanup = this.addHook('setSelect', (self: uPlot): void => {
const selection = self.select;
// Only trigger onDragSelect when there's an actual drag range (width > 0)
// A click without dragging produces width === 0, which should be ignored
if (selection && selection.width > 0) {
const startTime = self.posToVal(selection.left, 'x');
const endTime = self.posToVal(selection.left + selection.width, 'x');
this.onDragSelect(startTime * 1000, endTime * 1000);
}
});
this.cleanups.push(cleanup);
}
}
/**
* Add or merge an axis configuration
*/
addAxis(props: AxisProps): void {
const { scaleKey } = props;
if (this.axes[scaleKey]) {
this.axes[scaleKey].merge?.(props);
return;
}
this.axes[scaleKey] = new UPlotAxisBuilder(props);
}
/**
* Add or merge a scale configuration
*/
addScale(props: ScaleProps): void {
const current = this.scales.find((v) => v.props.scaleKey === props.scaleKey);
if (current) {
current.merge?.(props);
return;
}
this.scales.push(new UPlotScaleBuilder(props));
}
/**
* Add a series configuration
*/
addSeries(props: SeriesProps): void {
this.series.push(new UPlotSeriesBuilder(props));
}
/**
* Add a hook for extensibility
*/
addHook<T extends keyof Hooks.Defs>(type: T, hook: Hooks.Defs[T]): () => void {
if (!this.hooks[type]) {
this.hooks[type] = [];
}
(this.hooks[type] as Hooks.Defs[T][]).push(hook);
// Return a function to remove the hook when the component unmounts
return (): void => {
const idx = (this.hooks[type] as Hooks.Defs[T][]).indexOf(hook);
if (idx !== -1) {
(this.hooks[type] as Hooks.Defs[T][]).splice(idx, 1);
}
};
}
/**
* Add a plugin
*/
addPlugin(plugin: uPlot.Plugin): void {
this.plugins.push(plugin);
}
/**
* Add thresholds configuration
*/
addThresholds(options: ThresholdsDrawHookOptions): void {
if (!this.thresholds[options.scaleKey]) {
this.thresholds[options.scaleKey] = options;
const cleanup = this.addHook('draw', thresholdsDrawHook(options));
this.cleanups.push(cleanup);
}
}
/**
* Set bands for stacked charts
*/
setBands(bands: uPlot.Band[]): void {
this.bands = bands;
}
/**
* Set cursor configuration
*/
setCursor(cursor: Cursor): void {
this.cursor = merge({}, this.cursor, cursor);
}
/**
* Set padding
*/
setPadding(padding: [number, number, number, number]): void {
this.padding = padding;
}
/**
* Set legend configuration
*/
setLegend(legend: LegendConfig): void {
this.legend = legend;
}
/**
* Set focus configuration
*/
setFocus(focus: uPlot.Focus): void {
this.focus = focus;
}
/**
* Set select configuration
*/
setSelect(select: uPlot.Select): void {
this.select = select;
}
/**
* Set timezone date function
*/
setTzDate(tzDate: (timestamp: number) => Date): void {
this.tzDate = tzDate;
}
/**
* Get legend items with visibility state restored from localStorage if available
*/
getLegendItems(): Record<number, LegendItem> {
const visibilityMap = this.widgetId
? getStoredSeriesVisibility(this.widgetId)
: null;
return this.series.reduce((acc, s: UPlotSeriesBuilder, index: number) => {
const seriesConfig = s.getConfig();
const label = seriesConfig.label ?? '';
const seriesIndex = index + 1; // +1 because the first series is the timestamp
// Priority: stored visibility > series config > default (true)
const show = visibilityMap?.get(label) ?? seriesConfig.show ?? true;
acc[seriesIndex] = {
seriesIndex,
color: seriesConfig.stroke,
label,
show,
};
return acc;
}, {} as Record<number, LegendItem>);
}
/**
* Remove all hooks and cleanup functions
*/
destroy(): void {
this.cleanups.forEach((cleanup) => cleanup());
}
/**
* Get the widget id
*/
getWidgetId(): string | undefined {
return this.widgetId;
}
/**
* Build the final uPlot.Options configuration
*/
getConfig(): Partial<Options> {
const config: Partial<Options> = {
...DEFAULT_PLOT_CONFIG,
};
config.series = [
{ value: (): string => '' }, // Base series for timestamp
...this.series.map((s) => s.getConfig()),
];
config.axes = Object.values(this.axes).map((a) => a.getConfig());
config.scales = this.scales.reduce(
(acc, s) => ({ ...acc, ...s.getConfig() }),
{} as Record<string, uPlot.Scale>,
);
config.hooks = this.hooks;
config.select = this.select;
config.cursor = merge({}, DEFAULT_CURSOR_CONFIG, this.cursor);
config.tzDate = this.tzDate;
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
config.bands = this.bands.length > 0 ? this.bands : undefined;
if (Array.isArray(this.padding)) {
config.padding = this.padding;
}
if (this.legend) {
config.legend = this.legend;
}
if (this.focus) {
config.focus = this.focus;
}
return config;
}
}

View File

@@ -0,0 +1,157 @@
import { Scale } from 'uplot';
import {
adjustSoftLimitsWithThresholds,
createRangeFunction,
getDistributionConfig,
getFallbackMinMaxTimeStamp,
getRangeConfig,
normalizeLogScaleLimits,
} from '../utils/scale';
import { ConfigBuilder, ScaleProps } from './types';
/**
* Builder for uPlot scale configuration
* Handles creation and merging of scale settings
*/
export class UPlotScaleBuilder extends ConfigBuilder<
ScaleProps,
Record<string, Scale>
> {
private softMin: number | null;
private softMax: number | null;
private min: number | null;
private max: number | null;
constructor(props: ScaleProps) {
super(props);
// By default while creating a widget we set the softMin and softMax to 0, so we need to handle this case separately
const isDefaultSoftMinMax = props.softMin === 0 && props.softMax === 0;
this.softMin = isDefaultSoftMinMax ? null : props.softMin ?? null;
this.softMax = isDefaultSoftMinMax ? null : props.softMax ?? null;
this.min = props.min ?? null;
this.max = props.max ?? null;
}
getConfig(): Record<string, Scale> {
const {
scaleKey,
time,
range,
thresholds,
logBase = 10,
padMinBy = 0,
padMaxBy = 0.05,
} = this.props;
// Special handling for time scales (X axis)
if (time) {
let minTime = this.min ?? 0;
let maxTime = this.max ?? 0;
// Fallback when min/max are not provided
if (!minTime || !maxTime) {
const { fallbackMin, fallbackMax } = getFallbackMinMaxTimeStamp();
minTime = fallbackMin;
maxTime = fallbackMax;
}
// Align max time to "endTime - 1 minute", rounded down to minute precision
// This matches legacy getXAxisScale behavior and avoids empty space at the right edge
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
maxTime = unixTimestampSeconds;
return {
[scaleKey]: {
time: true,
auto: false,
range: [minTime, maxTime],
},
};
}
const distr = this.props.distribution;
// Adjust softMin/softMax to include threshold values
// This ensures threshold lines are visible within the scale range
const thresholdList = thresholds?.thresholds;
const {
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
} = adjustSoftLimitsWithThresholds(
this.softMin,
this.softMax,
thresholdList,
thresholds?.yAxisUnit,
);
const { min, max, softMin, softMax } = normalizeLogScaleLimits({
distr,
logBase,
limits: {
min: this.min,
max: this.max,
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
},
});
const distribution = getDistributionConfig({
time,
distr,
logBase,
});
const {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
} = getRangeConfig(min, max, softMin, softMax, padMinBy, padMaxBy);
const rangeFn = createRangeFunction({
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
min,
max,
});
let auto = this.props.auto;
auto ??= !time && !hasFixedRange;
return {
[scaleKey]: {
time,
auto,
range: range ?? rangeFn,
...distribution,
},
};
}
merge(props: Partial<ScaleProps>): void {
this.props = { ...this.props, ...props };
if (props.softMin !== undefined) {
this.softMin = props.softMin ?? null;
}
if (props.softMax !== undefined) {
this.softMax = props.softMax ?? null;
}
if (props.min !== undefined) {
this.min = props.min ?? null;
}
if (props.max !== undefined) {
this.max = props.max ?? null;
}
}
}
export type { ScaleProps };

View File

@@ -0,0 +1,244 @@
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import uPlot, { Series } from 'uplot';
import {
ConfigBuilder,
DrawStyle,
LineInterpolation,
LineStyle,
SeriesProps,
VisibilityMode,
} from './types';
/**
* Builder for uPlot series configuration
* Handles creation of series settings
*/
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
private buildLineConfig({
lineColor,
lineWidth,
lineStyle,
lineCap,
}: {
lineColor: string;
lineWidth?: number;
lineStyle?: LineStyle;
lineCap?: Series.Cap;
}): Partial<Series> {
const lineConfig: Partial<Series> = {
stroke: lineColor,
width: lineWidth ?? 2,
};
if (lineStyle === LineStyle.Dashed) {
lineConfig.dash = [10, 10];
}
if (lineCap) {
lineConfig.cap = lineCap;
}
return lineConfig;
}
/**
* Build path configuration
*/
private buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
}: {
pathBuilder?: Series.PathBuilder | null;
drawStyle: DrawStyle;
lineInterpolation?: LineInterpolation;
}): Partial<Series> {
if (pathBuilder) {
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
return { paths: (): null => null };
}
if (drawStyle !== null) {
return {
paths: (
self: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
): Series.Paths | null => {
const pathsBuilder = getPathBuilder(drawStyle, lineInterpolation);
return pathsBuilder(self, seriesIdx, idx0, idx1);
},
};
}
return {};
}
/**
* Build points configuration
*/
private buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder,
pointsFilter,
drawStyle,
showPoints,
}: {
lineColor: string;
lineWidth?: number;
pointSize?: number;
pointsBuilder: Series.Points.Show | null;
pointsFilter: Series.Points.Filter | null;
drawStyle: DrawStyle;
showPoints?: VisibilityMode;
}): Partial<Series.Points> {
const pointsConfig: Partial<Series.Points> = {
stroke: lineColor,
fill: lineColor,
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
filter: pointsFilter || undefined,
};
if (pointsBuilder) {
pointsConfig.show = pointsBuilder;
} else if (drawStyle === DrawStyle.Points) {
pointsConfig.show = true;
} else if (showPoints === VisibilityMode.Never) {
pointsConfig.show = false;
} else if (showPoints === VisibilityMode.Always) {
pointsConfig.show = true;
}
return pointsConfig;
}
private getLineColor(): string {
const { colorMapping, label, lineColor, isDarkMode } = this.props;
if (!label) {
return lineColor ?? (isDarkMode ? themeColors.white : themeColors.black);
}
return (
lineColor ??
colorMapping[label] ??
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
)
);
}
getConfig(): Series {
const {
drawStyle,
pathBuilder,
pointsBuilder,
pointsFilter,
lineInterpolation,
lineWidth,
lineStyle,
lineCap,
showPoints,
pointSize,
scaleKey,
label,
spanGaps,
show = true,
} = this.props;
const lineColor = this.getLineColor();
const lineConfig = this.buildLineConfig({
lineColor,
lineWidth,
lineStyle,
lineCap,
});
const pathConfig = this.buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
});
const pointsConfig = this.buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder: pointsBuilder ?? null,
pointsFilter: pointsFilter ?? null,
drawStyle,
showPoints,
});
return {
scale: scaleKey,
label,
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
value: (): string => '',
pxAlign: true,
show,
...lineConfig,
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
};
}
}
interface PathBuilders {
linear: Series.PathBuilder;
spline: Series.PathBuilder;
stepBefore: Series.PathBuilder;
stepAfter: Series.PathBuilder;
[key: string]: Series.PathBuilder;
}
let builders: PathBuilders | null = null;
/**
* Get path builder based on draw style and interpolation
*/
function getPathBuilder(
style: DrawStyle,
lineInterpolation?: LineInterpolation,
): Series.PathBuilder {
const pathBuilders = uPlot.paths;
if (!builders) {
const linearBuilder = pathBuilders.linear;
const splineBuilder = pathBuilders.spline;
const steppedBuilder = pathBuilders.stepped;
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
throw new Error('Required uPlot path builders are not available');
}
builders = {
linear: linearBuilder(),
spline: splineBuilder(),
stepBefore: steppedBuilder({ align: -1 }),
stepAfter: steppedBuilder({ align: 1 }),
};
}
if (style === DrawStyle.Line) {
if (lineInterpolation === LineInterpolation.StepBefore) {
return builders.stepBefore;
}
if (lineInterpolation === LineInterpolation.StepAfter) {
return builders.stepAfter;
}
if (lineInterpolation === LineInterpolation.Linear) {
return builders.linear;
}
}
return builders.spline;
}
export type { SeriesProps };

View File

@@ -0,0 +1,194 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Cursor, Options, Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
/**
* Base abstract class for all configuration builders
* Provides a common interface for building uPlot configuration components
*/
export abstract class ConfigBuilder<P, T> {
constructor(public props: P) {}
/**
* Builds and returns the configuration object
*/
abstract getConfig(): T;
/**
* Merges additional properties into the existing configuration
*/
merge?(props: Partial<P>): void;
}
/**
* Props for configuring the uPlot config builder
*/
export interface ConfigBuilderProps {
widgetId?: string;
onDragSelect?: (startTime: number, endTime: number) => void;
tzDate?: uPlot.LocalDateFromUnix;
}
/**
* Props for configuring an axis
*/
export interface AxisProps {
scaleKey: string;
label?: string;
show?: boolean;
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
stroke?: string;
grid?: {
stroke?: string;
width?: number;
show?: boolean;
};
ticks?: {
stroke?: string;
width?: number;
show?: boolean;
size?: number;
};
values?: uPlot.Axis.Values;
gap?: number;
size?: uPlot.Axis.Size;
formatValue?: (v: number) => string;
space?: number; // Space for log scale axes
isDarkMode?: boolean;
isLogScale?: boolean;
yAxisUnit?: string;
panelType?: PANEL_TYPES;
decimalPrecision?: PrecisionOption;
}
/**
* Props for configuring a scale
*/
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
}
export interface ScaleProps {
scaleKey: string;
time?: boolean;
min?: number;
max?: number;
softMin?: number;
softMax?: number;
thresholds?: ThresholdsDrawHookOptions;
padMinBy?: number;
padMaxBy?: number;
range?: uPlot.Scale.Range;
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
}
/**
* Props for configuring a series
*/
export enum LineStyle {
Solid = 'solid',
Dashed = 'dashed',
}
export enum DrawStyle {
Line = 'line',
Points = 'points',
}
export enum LineInterpolation {
Linear = 'linear',
Spline = 'spline',
StepAfter = 'stepAfter',
StepBefore = 'stepBefore',
}
export enum VisibilityMode {
Always = 'always',
Auto = 'auto',
Never = 'never',
}
export interface SeriesProps {
scaleKey: string;
label?: string;
colorMapping: Record<string, string>;
drawStyle: DrawStyle;
pathBuilder?: Series.PathBuilder;
pointsFilter?: Series.Points.Filter;
pointsBuilder?: Series.Points.Show;
show?: boolean;
spanGaps?: boolean;
isDarkMode?: boolean;
// Line config
lineColor?: string;
lineInterpolation?: LineInterpolation;
lineStyle?: LineStyle;
lineWidth?: number;
lineCap?: Series.Cap;
// Points config
pointColor?: string;
pointSize?: number;
showPoints?: VisibilityMode;
}
export interface LegendItem {
seriesIndex: number;
label: uPlot.Series['label'];
color: uPlot.Series['stroke'];
show: boolean;
}
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,
},
cursor: {
focus: {
prox: 30,
},
},
legend: {
show: false,
},
padding: [16, 16, 8, 8],
series: [],
hooks: {},
};
const POINTS_FILL_COLOR = '#FFFFFF';
export const DEFAULT_CURSOR_CONFIG: Cursor = {
drag: { setScale: true },
points: {
one: true,
size: (u, seriesIdx) => (u.series[seriesIdx]?.points?.size ?? 0) * 3,
width: (_u, _seriesIdx, size) => size / 4,
stroke: (u, seriesIdx): string => {
const points = u.series[seriesIdx]?.points;
const strokeFn =
typeof points?.stroke === 'function' ? points.stroke : undefined;
const strokeValue =
strokeFn !== undefined
? strokeFn(u, seriesIdx)
: typeof points?.stroke === 'string'
? points.stroke
: '';
return `${strokeValue}90`;
},
fill: (): string => POINTS_FILL_COLOR,
},
focus: {
prox: 30,
},
};

View File

@@ -0,0 +1,136 @@
import {
createContext,
PropsWithChildren,
useCallback,
useContext,
useMemo,
useRef,
} from 'react';
import type { SeriesVisibilityItem } from 'container/DashboardContainer/visualization/panels/types';
import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import type uPlot from 'uplot';
export interface PlotContextInitialState {
uPlotInstance: uPlot | null;
widgetId?: string;
}
export interface IPlotContext {
setPlotContextInitialState: (state: PlotContextInitialState) => void;
onToggleSeriesVisibility: (seriesIndex: number) => void;
onToggleSeriesOnOff: (seriesIndex: number) => void;
onFocusSeries: (seriesIndex: number | null) => void;
}
export const PlotContext = createContext<IPlotContext | null>(null);
export const PlotContextProvider = ({
children,
}: PropsWithChildren): JSX.Element => {
const uPlotInstanceRef = useRef<uPlot | null>(null);
const activeSeriesIndex = useRef<number | undefined>(undefined);
const widgetIdRef = useRef<string | undefined>(undefined);
const setPlotContextInitialState = useCallback(
({ uPlotInstance, widgetId }: PlotContextInitialState): void => {
uPlotInstanceRef.current = uPlotInstance;
widgetIdRef.current = widgetId;
activeSeriesIndex.current = undefined;
},
[],
);
const onToggleSeriesVisibility = useCallback((seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
const isReset = activeSeriesIndex.current === seriesIndex;
activeSeriesIndex.current = isReset ? undefined : seriesIndex;
plot.batch(() => {
plot.series.forEach((_, index) => {
if (index === 0) {
return;
}
const currentSeriesIndex = index;
plot.setSeries(currentSeriesIndex, {
show: isReset || currentSeriesIndex === seriesIndex,
});
});
if (widgetIdRef.current) {
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
(series) => ({
label: series.label ?? '',
show: series.show ?? true,
}),
);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
}
});
}, []);
const onToggleSeriesOnOff = useCallback((seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
const series = plot.series[seriesIndex];
if (!series) {
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (widgetIdRef.current) {
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
(series) => ({
label: series.label ?? '',
show: series.show ?? true,
}),
);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
}
}, []);
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
plot.setSeries(
seriesIndex,
{
focus: true,
},
false,
);
}, []);
const value = useMemo(
() => ({
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onFocusSeries,
}),
[
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onFocusSeries,
],
);
return <PlotContext.Provider value={value}>{children}</PlotContext.Provider>;
};
export const usePlotContext = (): IPlotContext => {
const context = useContext(PlotContext);
if (!context) {
throw new Error('Should be used inside the context');
}
return context;
};

View File

@@ -0,0 +1,12 @@
export interface Threshold {
thresholdValue: number;
thresholdColor?: string;
thresholdUnit?: string;
thresholdLabel?: string;
}
export interface ThresholdsDrawHookOptions {
scaleKey: string;
thresholds: Threshold[];
yAxisUnit?: string;
}

View File

@@ -0,0 +1,142 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
/**
* Syncs legend UI state with the uPlot chart: which series is focused and each series' visibility.
* Subscribes to the config's setSeries hook so legend items stay in sync when series are toggled
* from the chart or from the Legend component.
*
* @param config - UPlot config builder; used to read legend items and to register the setSeries hook
* @param subscribeToFocusChange - When true, updates focusedSeriesIndex when a series gains focus via setSeries
* @returns focusedSeriesIndex, setFocusedSeriesIndex, and legendItemsMap for the Legend component
*/
export default function useLegendsSync({
config,
subscribeToFocusChange = true,
}: {
config: UPlotConfigBuilder;
subscribeToFocusChange?: boolean;
}): {
focusedSeriesIndex: number | null;
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
legendItemsMap: Record<number, LegendItem>;
} {
const [legendItemsMap, setLegendItemsMap] = useState<
Record<number, LegendItem>
>({});
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
null,
);
/** Pending visibility updates (series index -> show) to apply in the next RAF. */
const visibilityUpdatesRef = useRef<Record<number, boolean>>({});
/** RAF id for the batched visibility update; null when no update is scheduled. */
const visibilityRafIdRef = useRef<number | null>(null);
/**
* Applies a batch of visibility updates to legendItemsMap.
* Only updates entries that exist and whose show value changed; returns prev state if nothing changed.
*/
const applyVisibilityUpdates = useCallback(
(updates: Record<number, boolean>): void => {
setLegendItemsMap(
(prev): Record<number, LegendItem> => {
let hasChanges = false;
const next = { ...prev };
for (const [idxStr, show] of Object.entries(updates)) {
const idx = Number(idxStr);
const current = next[idx];
if (!current || current.show === show) {
continue;
}
next[idx] = { ...current, show };
hasChanges = true;
}
return hasChanges ? next : prev;
},
);
},
[],
);
/**
* Queues a single series visibility update and schedules at most one state update per frame.
* Batches multiple visibility changes (e.g. from setSeries) into one setLegendItemsMap call.
*/
const queueVisibilityUpdate = useCallback(
(seriesIndex: number, show: boolean): void => {
visibilityUpdatesRef.current[seriesIndex] = show;
if (visibilityRafIdRef.current !== null) {
return;
}
visibilityRafIdRef.current = requestAnimationFrame(() => {
const updates = visibilityUpdatesRef.current;
visibilityUpdatesRef.current = {};
visibilityRafIdRef.current = null;
applyVisibilityUpdates(updates);
});
},
[applyVisibilityUpdates],
);
/**
* Handler for uPlot's setSeries hook. Updates focused series when opts.focus is set,
* and queues legend visibility updates when opts.show changes so the legend stays in sync.
*/
const handleSetSeries = useCallback(
(_u: uPlot, seriesIndex: number | null, opts: uPlot.Series): void => {
if (subscribeToFocusChange && get(opts, 'focus', false)) {
setFocusedSeriesIndex(seriesIndex);
}
if (!seriesIndex || typeof opts.show !== 'boolean') {
return;
}
queueVisibilityUpdate(seriesIndex, opts.show);
},
[queueVisibilityUpdate, subscribeToFocusChange],
);
// Initialize legend items from config and subscribe to setSeries; cleanup on unmount or config change.
useLayoutEffect(() => {
setLegendItemsMap(config.getLegendItems());
const removeHook = config.addHook('setSeries', handleSetSeries);
return (): void => {
removeHook();
};
}, [config, handleSetSeries]);
// Cancel any pending RAF on unmount to avoid state updates after unmount.
useEffect(
() => (): void => {
if (visibilityRafIdRef.current != null) {
cancelAnimationFrame(visibilityRafIdRef.current);
}
},
[],
);
return {
focusedSeriesIndex,
setFocusedSeriesIndex,
legendItemsMap,
};
}

View File

@@ -0,0 +1,65 @@
import { convertValue } from 'lib/getConvertedValue';
import uPlot, { Hooks } from 'uplot';
import { Threshold, ThresholdsDrawHookOptions } from './types';
export function thresholdsDrawHook(
options: ThresholdsDrawHookOptions,
): Hooks.Defs['draw'] {
const dashSegments = [10, 5];
function addLines(u: uPlot, scaleKey: string, thresholds: Threshold[]): void {
const ctx = u.ctx;
ctx.save();
ctx.lineWidth = 2;
ctx.setLineDash(dashSegments);
const threshold90Percent = ctx.canvas.height * 0.9;
for (let idx = 0; idx < thresholds.length; idx++) {
const threshold = thresholds[idx];
const color = threshold.thresholdColor || 'red';
const yValue = convertValue(
threshold.thresholdValue,
threshold.thresholdUnit,
options.yAxisUnit,
);
const scaleVal = u.valToPos(Number(yValue), scaleKey, true);
const x0 = Math.round(u.bbox.left);
const y0 = Math.round(scaleVal);
const x1 = Math.round(u.bbox.left + u.bbox.width);
const y1 = Math.round(scaleVal);
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
// Draw threshold label if present
if (threshold.thresholdLabel) {
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
const textX = x1 - textWidth - 20;
const yposHeight = ctx.canvas.height - y1;
const textY = yposHeight > threshold90Percent ? y0 + 15 : y0 - 15;
ctx.fillStyle = color;
ctx.fillText(threshold.thresholdLabel, textX, textY);
}
}
}
const { scaleKey, thresholds } = options;
return (u: uPlot): void => {
const ctx = u.ctx;
addLines(u, scaleKey, thresholds);
ctx.restore();
};
}

View File

@@ -0,0 +1,13 @@
.tooltip-plugin-container {
top: 0;
left: 0;
z-index: 1070;
white-space: pre;
border-radius: 4px;
position: fixed;
overflow: auto;
&.pinned {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
}

View File

@@ -0,0 +1,359 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import uPlot from 'uplot';
import {
createInitialControllerState,
createSetCursorHandler,
createSetLegendHandler,
createSetSeriesHandler,
isScrollEventInPlot,
updatePlotVisibility,
updateWindowSize,
} from './tooltipController';
import {
DashboardCursorSync,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
TooltipPluginProps,
TooltipViewState,
} from './types';
import { createInitialViewState, createLayoutObserver } from './utils';
import './TooltipPlugin.styles.scss';
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
const HOVER_DISMISS_DELAY_MS = 100;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function TooltipPlugin({
config,
render,
maxWidth = 300,
maxHeight = 400,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
const portalRoot = useRef<HTMLElement>(document.body);
const rafId = useRef<number | null>(null);
const layoutRef = useRef<TooltipLayoutInfo>();
const renderRef = useRef(render);
renderRef.current = render;
// React-managed snapshot of what should be rendered. The controller
// owns the interaction state and calls `updateState` when a visible
// change should trigger a React re-render.
const [viewState, setState] = useState<TooltipViewState>(
createInitialViewState,
);
const { plot, isHovering, isPinned, contents, style } = viewState;
/**
* Merge a partial view update into the current React state.
* Style is merged shallowly so callers can update transform /
* pointerEvents without having to rebuild the whole object.
*/
function updateState(updates: Partial<TooltipViewState>): void {
setState((prev) => ({
...prev,
...updates,
style: { ...prev.style, ...updates.style },
}));
}
useLayoutEffect((): (() => void) => {
layoutRef.current?.observer.disconnect();
layoutRef.current = createLayoutObserver(layoutRef);
// Controller holds the mutable interaction state for this tooltip
// instance. It is intentionally *not* React state so uPlot hooks
// and DOM listeners can update it freely without triggering a
// render on every mouse move.
const controller: TooltipControllerState = createInitialControllerState();
const syncTooltipWithDashboard = syncMode === DashboardCursorSync.Tooltip;
// Enable uPlot's built-in cursor sync when requested so that
// crosshair / tooltip can follow the dashboard-wide cursor.
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
config.setCursor({
sync: { key: syncKey, scales: ['x', null] },
});
}
// Dismiss the tooltip when the user clicks / presses a key
// outside the tooltip container while it is pinned.
const onOutsideInteraction = (event: Event): void => {
const target = event.target as HTMLElement;
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
dismissTooltip();
}
};
// When pinned we want the tooltip to be mouse-interactive
// (for copying values etc.), otherwise it should ignore
// pointer events so the chart remains fully clickable.
function updatePointerEvents(): void {
controller.style = {
...controller.style,
pointerEvents: controller.pinned ? 'all' : 'none',
};
}
// Lock uPlot's internal cursor when the tooltip is pinned so
// subsequent mouse moves do not move the crosshair.
function updateCursorLock(): void {
if (controller.plot) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore uPlot cursor lock is not working as expected
controller.plot.cursor._lock = controller.pinned;
}
}
// Attach / detach global listeners when pin state changes so
// we can detect when the user interacts outside the tooltip.
function toggleOutsideListeners(enable: boolean): void {
if (enable) {
document.addEventListener('mousedown', onOutsideInteraction, true);
document.addEventListener('keydown', onOutsideInteraction, true);
} else {
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
}
}
// Centralised helper that applies all side effects that depend
// on whether the tooltip is currently pinned.
function applyPinnedSideEffects(): void {
updatePointerEvents();
updateCursorLock();
toggleOutsideListeners(controller.pinned);
}
// Hide the tooltip and reset the uPlot cursor. This is used
// both when the user unpins and when interaction ends.
function dismissTooltip(): void {
const isPinnedBeforeDismiss = controller.pinned;
controller.pinned = false;
controller.hoverActive = false;
if (controller.plot) {
controller.plot.setCursor({ left: -10, top: -10 });
}
scheduleRender(isPinnedBeforeDismiss);
}
// Build the React node to be rendered inside the tooltip by
// delegating to the caller-provided `render` function.
function createTooltipContents(): React.ReactNode {
if (!controller.hoverActive || !controller.plot) {
return null;
}
return renderRef.current({
uPlotInstance: controller.plot,
dataIndexes: controller.seriesIndexes,
seriesIndex: controller.focusedSeriesIndex,
isPinned: controller.pinned,
dismiss: dismissTooltip,
viaSync: controller.cursorDrivenBySync,
});
}
// Push the latest controller state into React so the tooltip's
// DOM representation catches up with the interaction state.
function performRender(): void {
controller.renderScheduled = false;
rafId.current = null;
if (controller.pendingPinnedUpdate) {
applyPinnedSideEffects();
controller.pendingPinnedUpdate = false;
}
updateState({
style: controller.style,
isPinned: controller.pinned,
isHovering: controller.hoverActive,
contents: createTooltipContents(),
dismiss: dismissTooltip,
});
}
// Throttle React re-renders:
// - use rAF while hovering for smooth updates
// - use a small timeout when hiding to avoid flicker when
// briefly leaving and re-entering the plot.
function scheduleRender(updatePinned = false): void {
if (!controller.renderScheduled) {
if (!controller.hoverActive) {
setTimeout(performRender, HOVER_DISMISS_DELAY_MS);
} else {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(performRender);
}
controller.renderScheduled = true;
}
if (updatePinned) {
controller.pendingPinnedUpdate = true;
}
}
// Keep controller's windowWidth / windowHeight in sync so that
// tooltip positioning can respect the current viewport size.
const handleWindowResize = (): void => {
updateWindowSize(controller);
};
// When the user scrolls, recompute plot visibility and hide
// the tooltip if the scroll originated from inside the plot.
const handleScroll = (event: Event): void => {
updatePlotVisibility(controller);
if (controller.hoverActive && isScrollEventInPlot(event, controller)) {
dismissTooltip();
}
};
// When pinning is enabled, a click on the plot overlay while
// hovering converts the transient tooltip into a pinned one.
const handleUPlotOverClick = (u: uPlot, event: MouseEvent): void => {
if (
event.target === u.over &&
controller.hoverActive &&
!controller.pinned &&
controller.focusedSeriesIndex != null
) {
setTimeout(() => {
controller.pinned = true;
scheduleRender(true);
}, 0);
}
};
let overClickHandler: ((event: MouseEvent) => void) | null = null;
// Called once per uPlot instance; used to store the instance
// on the controller and optionally attach the pinning handler.
const handleInit = (u: uPlot): void => {
controller.plot = u;
updateState({ plot: u });
if (canPinTooltip) {
overClickHandler = (event: MouseEvent): void =>
handleUPlotOverClick(u, event);
u.over.addEventListener('click', overClickHandler);
}
};
// If the underlying data changes we drop any pinned tooltip,
// since the contents may no longer match the new series data.
const handleSetData = (): void => {
if (controller.pinned) {
dismissTooltip();
}
};
// Shared context object passed down into all uPlot hook
// handlers so they can interact with the controller and
// schedule React updates when needed.
const ctx: TooltipControllerContext = {
controller,
layoutRef,
containerRef,
rafId,
updateState,
renderRef,
syncMode,
syncKey,
canPinTooltip,
createTooltipContents,
scheduleRender,
dismissTooltip,
};
const handleSetSeries = createSetSeriesHandler(ctx, syncTooltipWithDashboard);
const handleSetLegend = createSetLegendHandler(ctx, syncTooltipWithDashboard);
const handleSetCursor = createSetCursorHandler(ctx);
handleWindowResize();
const removeReadyHook = config.addHook('ready', (): void =>
updatePlotVisibility(controller),
);
const removeInitHook = config.addHook('init', handleInit);
const removeSetDataHook = config.addHook('setData', handleSetData);
const removeSetSeriesHook = config.addHook('setSeries', handleSetSeries);
const removeSetLegendHook = config.addHook('setLegend', handleSetLegend);
const removeSetCursorHook = config.addHook('setCursor', handleSetCursor);
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleScroll, true);
return (): void => {
layoutRef.current?.observer.disconnect();
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
removeReadyHook();
removeInitHook();
removeSetDataHook();
removeSetSeriesHook();
removeSetLegendHook();
removeSetCursorHook();
if (controller.plot && overClickHandler) {
controller.plot.over.removeEventListener('click', overClickHandler);
overClickHandler = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
useLayoutEffect((): void => {
if (!plot || !layoutRef.current) {
return;
}
const layout = layoutRef.current;
if (containerRef.current) {
layout.observer.disconnect();
layout.observer.observe(containerRef.current);
const { width, height } = containerRef.current.getBoundingClientRect();
layout.width = width;
layout.height = height;
} else {
layout.width = 0;
layout.height = 0;
}
}, [isHovering, plot]);
if (!plot || !isHovering) {
return null;
}
return createPortal(
<div
className={cx('tooltip-plugin-container', { pinned: isPinned })}
style={{
...style,
maxWidth: `${maxWidth}px`,
maxHeight: `${maxHeight}px`,
width: '100%',
}}
aria-live="polite"
aria-atomic="true"
ref={containerRef}
>
{contents}
</div>,
portalRoot.current,
);
}

View File

@@ -0,0 +1,206 @@
import uPlot from 'uplot';
import { TooltipControllerContext, TooltipControllerState } from './types';
import {
buildTransform,
calculateTooltipOffset,
isPlotInViewport,
} from './utils';
const WINDOW_OFFSET = 16;
export function createInitialControllerState(): TooltipControllerState {
return {
plot: null,
hoverActive: false,
anySeriesActive: false,
pinned: false,
style: { transform: '', pointerEvents: 'none' },
horizontalOffset: 0,
verticalOffset: 0,
seriesIndexes: [],
focusedSeriesIndex: null,
cursorDrivenBySync: false,
plotWithinViewport: false,
windowWidth: window.innerWidth - WINDOW_OFFSET,
windowHeight: window.innerHeight - WINDOW_OFFSET,
renderScheduled: false,
pendingPinnedUpdate: false,
};
}
/**
* Keep track of the current window size and clear hover state
* when the user resizes while hovering (to avoid an orphan tooltip).
*/
export function updateWindowSize(controller: TooltipControllerState): void {
if (controller.hoverActive && !controller.pinned) {
controller.hoverActive = false;
}
controller.windowWidth = window.innerWidth - WINDOW_OFFSET;
controller.windowHeight = window.innerHeight - WINDOW_OFFSET;
}
/**
* Mark whether the plot is currently inside the viewport.
* This is used to decide if a synced tooltip should be shown at all.
*/
export function updatePlotVisibility(controller: TooltipControllerState): void {
if (!controller.plot) {
controller.plotWithinViewport = false;
return;
}
controller.plotWithinViewport = isPlotInViewport(
controller.plot.rect,
controller.windowWidth,
controller.windowHeight,
);
}
/**
* Helper to detect whether a scroll event actually happened inside
* the plot container. Used so we only dismiss the tooltip when the
* user scrolls the chart, not the whole page.
*/
export function isScrollEventInPlot(
event: Event,
controller: TooltipControllerState,
): boolean {
return (
event.target instanceof Node &&
controller.plot !== null &&
event.target.contains(controller.plot.root)
);
}
export function shouldShowTooltipForSync(
controller: TooltipControllerState,
syncTooltipWithDashboard: boolean,
): boolean {
return (
controller.plotWithinViewport &&
controller.anySeriesActive &&
syncTooltipWithDashboard
);
}
export function shouldShowTooltipForInteraction(
controller: TooltipControllerState,
): boolean {
return controller.focusedSeriesIndex != null || controller.anySeriesActive;
}
export function updateHoverState(
controller: TooltipControllerState,
syncTooltipWithDashboard: boolean,
): void {
// When the cursor is driven by dashboardlevel sync, we only show
// the tooltip if the plot is in viewport and at least one series
// is active. Otherwise we fall back to local interaction logic.
controller.hoverActive = controller.cursorDrivenBySync
? shouldShowTooltipForSync(controller, syncTooltipWithDashboard)
: shouldShowTooltipForInteraction(controller);
}
export function createSetCursorHandler(
ctx: TooltipControllerContext,
): (u: uPlot) => void {
return (u: uPlot): void => {
const { controller, layoutRef, containerRef } = ctx;
controller.cursorDrivenBySync = u.cursor.event == null;
if (!controller.hoverActive) {
return;
}
const { left = -10, top = -10 } = u.cursor;
if (left < 0 && top < 0) {
return;
}
const clientX = u.rect.left + left;
const clientY = u.rect.top + top;
const layout = layoutRef.current;
if (!layout) {
return;
}
const { width: layoutWidth, height: layoutHeight } = layout;
const offsets = calculateTooltipOffset(
clientX,
clientY,
layoutWidth,
layoutHeight,
controller.horizontalOffset,
controller.verticalOffset,
controller.windowWidth,
controller.windowHeight,
);
controller.horizontalOffset = offsets.horizontalOffset;
controller.verticalOffset = offsets.verticalOffset;
const transform = buildTransform(
clientX,
clientY,
controller.horizontalOffset,
controller.verticalOffset,
);
// If the DOM node is mounted we move it directly to avoid
// going through React; otherwise we cache the transform in
// controller.style and ask the plugin to rerender.
if (containerRef.current) {
containerRef.current.style.transform = transform;
} else {
controller.style = { ...controller.style, transform };
ctx.scheduleRender();
}
};
}
export function createSetLegendHandler(
ctx: TooltipControllerContext,
syncTooltipWithDashboard: boolean,
): (u: uPlot) => void {
return (u: uPlot): void => {
const { controller } = ctx;
if (!controller.plot?.cursor?.idxs) {
return;
}
controller.seriesIndexes = controller.plot.cursor.idxs.slice();
controller.anySeriesActive = controller.seriesIndexes.some(
(v, i) => i > 0 && v != null,
);
controller.cursorDrivenBySync = u.cursor.event == null;
// Track transitions into / out of hover so we can avoid
// unnecessary renders when nothing visible has changed.
const previousHover = controller.hoverActive;
updateHoverState(controller, syncTooltipWithDashboard);
if (controller.hoverActive || controller.hoverActive !== previousHover) {
ctx.scheduleRender();
}
};
}
export function createSetSeriesHandler(
ctx: TooltipControllerContext,
syncTooltipWithDashboard: boolean,
): (u: uPlot, seriesIdx: number | null, opts: uPlot.Series) => void {
return (u: uPlot, seriesIdx: number | null, opts: uPlot.Series): void => {
const { controller } = ctx;
if (!('focus' in opts)) {
return;
}
// Remember which series is focused so we can drive hover
// logic even when the tooltip is being synced externally.
controller.focusedSeriesIndex = seriesIdx ?? null;
controller.cursorDrivenBySync = u.cursor.event == null;
updateHoverState(controller, syncTooltipWithDashboard);
ctx.scheduleRender();
};
}

View File

@@ -0,0 +1,92 @@
import { CSSProperties } from 'react';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
export const TOOLTIP_OFFSET = 10;
export enum DashboardCursorSync {
Crosshair,
None,
Tooltip,
}
export interface TooltipViewState {
plot?: uPlot | null;
style: Partial<CSSProperties>;
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
contents?: React.ReactNode;
}
export interface TooltipLayoutInfo {
observer: ResizeObserver;
width: number;
height: number;
}
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
render: (args: TooltipRenderArgs) => React.ReactNode;
maxWidth?: number;
maxHeight?: number;
}
/**
* Mutable, non-React state that drives tooltip behaviour:
* - whether the tooltip is active / pinned
* - where it should be positioned
* - which series / data indexes are active
*
* This state lives outside of React so that uPlot hooks and DOM
* event handlers can update it freely without causing rerenders
* on every tiny interaction. React is only updated when a render
* is explicitly scheduled from the plugin.
*/
export interface TooltipControllerState {
plot: uPlot | null;
hoverActive: boolean;
anySeriesActive: boolean;
pinned: boolean;
style: TooltipViewState['style'];
horizontalOffset: number;
verticalOffset: number;
seriesIndexes: Array<number | null>;
focusedSeriesIndex: number | null;
cursorDrivenBySync: boolean;
plotWithinViewport: boolean;
windowWidth: number;
windowHeight: number;
renderScheduled: boolean;
pendingPinnedUpdate: boolean;
}
/**
* Context passed to uPlot hook handlers.
*
* It gives the handlers access to:
* - the shared controller state
* - layout / container refs
* - the React `updateState` function
* - render & dismiss helpers from the plugin
*/
export interface TooltipControllerContext {
controller: TooltipControllerState;
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>;
containerRef: React.RefObject<HTMLDivElement | null>;
rafId: React.MutableRefObject<number | null>;
updateState: (updates: Partial<TooltipViewState>) => void;
renderRef: React.MutableRefObject<
(args: TooltipRenderArgs) => React.ReactNode
>;
syncMode: DashboardCursorSync;
syncKey: string;
canPinTooltip: boolean;
createTooltipContents: () => React.ReactNode;
scheduleRender: (updatePinned?: boolean) => void;
dismissTooltip: () => void;
}

View File

@@ -0,0 +1,159 @@
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
export function isPlotInViewport(
rect: uPlot.BBox,
windowWidth: number,
windowHeight: number,
): boolean {
return (
rect.top + rect.height <= windowHeight &&
rect.top >= 0 &&
rect.left >= 0 &&
rect.left + rect.width <= windowWidth
);
}
export function calculateVerticalOffset(
currentOffset: number,
clientY: number,
tooltipHeight: number,
windowHeight: number,
): number {
const height = tooltipHeight + TOOLTIP_OFFSET;
if (currentOffset !== 0) {
if (clientY + height < windowHeight || clientY - height < 0) {
return 0;
}
if (currentOffset !== -height) {
return -height;
}
return currentOffset;
}
if (clientY + height > windowHeight && clientY - height >= 0) {
return -height;
}
return 0;
}
export function calculateHorizontalOffset(
currentOffset: number,
clientX: number,
tooltipWidth: number,
windowWidth: number,
): number {
const width = tooltipWidth + TOOLTIP_OFFSET;
if (currentOffset !== 0) {
if (clientX + width < windowWidth || clientX - width < 0) {
return 0;
}
if (currentOffset !== -width) {
return -width;
}
return currentOffset;
}
if (clientX + width > windowWidth && clientX - width >= 0) {
return -width;
}
return 0;
}
export function calculateTooltipOffset(
clientX: number,
clientY: number,
tooltipWidth: number,
tooltipHeight: number,
currentHorizontalOffset: number,
currentVerticalOffset: number,
windowWidth: number,
windowHeight: number,
): { horizontalOffset: number; verticalOffset: number } {
return {
horizontalOffset: calculateHorizontalOffset(
currentHorizontalOffset,
clientX,
tooltipWidth,
windowWidth,
),
verticalOffset: calculateVerticalOffset(
currentVerticalOffset,
clientY,
tooltipHeight,
windowHeight,
),
};
}
export function buildTransform(
clientX: number,
clientY: number,
hOffset: number,
vOffset: number,
): string {
const translateX =
clientX + (hOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const translateY =
clientY + (vOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const reflectX = hOffset === 0 ? '' : 'translateX(-100%)';
const reflectY = vOffset === 0 ? '' : 'translateY(-100%)';
return `translateX(${translateX}px) ${reflectX} translateY(${translateY}px) ${reflectY}`;
}
/**
* React view state for the tooltip.
*
* This is the minimal data needed to render:
* - current position / CSS style
* - whether the tooltip is visible or pinned
* - the React node to show as contents
* - the associated uPlot instance (for children)
*
* All interaction logic lives in the controller; that logic calls
* `updateState` to push the latest snapshot into React.
*/
export function createInitialViewState(): TooltipViewState {
return {
style: { transform: '', pointerEvents: 'none' },
isHovering: false,
isPinned: false,
contents: null,
plot: null,
dismiss: (): void => {},
};
}
/**
* Creates and wires a ResizeObserver that keeps track of the rendered
* tooltip size. This is used by the controller to place the tooltip
* on the correct side of the cursor and avoid clipping the viewport.
*/
export function createLayoutObserver(
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>,
): TooltipLayoutInfo {
const layout: TooltipLayoutInfo = {
width: 0,
height: 0,
observer: new ResizeObserver((entries) => {
const current = layoutRef.current;
if (!current) {
return;
}
for (const entry of entries) {
if (entry.borderBoxSize?.length) {
current.width = entry.borderBoxSize[0].inlineSize;
current.height = entry.borderBoxSize[0].blockSize;
} else {
current.width = entry.contentRect.width;
current.height = entry.contentRect.height;
}
}
}),
};
return layout;
}

View File

@@ -0,0 +1,53 @@
/**
* Checks if a value is invalid for plotting
*
* @param value - The value to check
* @returns true if the value is invalid (should be replaced with null), false otherwise
*/
export function isInvalidPlotValue(value: unknown): boolean {
// Check for null or undefined
if (value === null || value === undefined) {
return true;
}
// Handle number checks
if (typeof value === 'number') {
// Check for NaN, Infinity, -Infinity
return !Number.isFinite(value);
}
// Handle string values
if (typeof value === 'string') {
// Check for string representations of infinity
if (['+Inf', '-Inf', 'Infinity', '-Infinity', 'NaN'].includes(value)) {
return true;
}
// Try to parse the string as a number
const numValue = parseFloat(value);
// If parsing failed or resulted in a non-finite number, it's invalid
if (Number.isNaN(numValue) || !Number.isFinite(numValue)) {
return true;
}
}
// Value is valid for plotting
return false;
}
export function normalizePlotValue(
value: number | string | null | undefined,
): number | null {
if (isInvalidPlotValue(value)) {
return null;
}
// Convert string numbers to actual numbers
if (typeof value === 'string') {
return parseFloat(value);
}
// Already a valid number
return value as number;
}

View File

@@ -0,0 +1,415 @@
/**
* Scale utilities for uPlot Y-axis configuration.
* Handles linear/log distribution, range computation (with padding and soft/hard limits),
* log-scale snapping, and threshold-aware soft limits.
*/
import uPlot, { Range, Scale } from 'uplot';
import { DistributionType, ScaleProps } from '../config/types';
import { Threshold } from '../hooks/types';
import { findMinMaxThresholdValues } from './threshold';
import { LogScaleLimits, RangeFunctionParams } from './types';
/**
* Rounds a number down to the nearest multiple of incr.
* Used for linear scale min so the axis starts on a clean tick.
*/
export function incrRoundDn(num: number, incr: number): number {
return Math.floor(num / incr) * incr;
}
/**
* Rounds a number up to the nearest multiple of incr.
* Used for linear scale max so the axis ends on a clean tick.
*/
export function incrRoundUp(num: number, incr: number): number {
return Math.ceil(num / incr) * incr;
}
/**
* Snaps min/max/softMin/softMax to valid log-scale values (powers of logBase).
* Only applies when distribution is logarithmic; otherwise returns limits unchanged.
* Ensures axis bounds align to log "magnitude" for readable tick labels.
*/
export function normalizeLogScaleLimits({
distr,
logBase,
limits,
}: {
distr?: DistributionType;
logBase: number;
limits: LogScaleLimits;
}): LogScaleLimits {
if (distr !== DistributionType.Logarithmic) {
return limits;
}
const logFn = logBase === 2 ? Math.log2 : Math.log10;
return {
min: normalizeLogLimit(limits.min, logBase, logFn, Math.floor),
max: normalizeLogLimit(limits.max, logBase, logFn, Math.ceil),
softMin: normalizeLogLimit(limits.softMin, logBase, logFn, Math.floor),
softMax: normalizeLogLimit(limits.softMax, logBase, logFn, Math.ceil),
};
}
/**
* Converts a single limit value to the nearest valid log-scale value.
* Rounds the log(value) with roundFn, then returns logBase^exp.
* Values <= 0 or null are returned as-is (log scale requires positive values).
*/
function normalizeLogLimit(
value: number | null,
logBase: number,
logFn: (v: number) => number,
roundFn: (v: number) => number,
): number | null {
if (value == null || value <= 0) {
return value;
}
const exp = roundFn(logFn(value));
return logBase ** exp;
}
/**
* Returns uPlot scale distribution options for the Y axis.
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
*/
export function getDistributionConfig({
time,
distr,
logBase,
}: {
time: ScaleProps['time'];
distr?: DistributionType;
logBase?: number;
}): Partial<Scale> {
if (time) {
return {};
}
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
return {
distr: distr === DistributionType.Logarithmic ? 3 : 1,
log: resolvedLogBase,
};
}
/**
* Builds uPlot range config and flags for the range function.
* - rangeConfig: pad, hard, soft, mode for min and max (used by uPlot.rangeNum / rangeLog).
* - hardMinOnly / hardMaxOnly: true when only a hard limit is set (no soft), so range uses that bound.
* - hasFixedRange: true when both min and max are hard-only (fully fixed axis).
*/
export function getRangeConfig(
min: number | null,
max: number | null,
softMin: number | null,
softMax: number | null,
padMinBy: number,
padMaxBy: number,
): {
rangeConfig: Range.Config;
hardMinOnly: boolean;
hardMaxOnly: boolean;
hasFixedRange: boolean;
} {
// uPlot: mode 3 = auto pad from data; mode 1 = respect soft limit
const softMinMode: Range.SoftMode = softMin == null ? 3 : 1;
const softMaxMode: Range.SoftMode = softMax == null ? 3 : 1;
const rangeConfig: Range.Config = {
min: {
pad: padMinBy,
hard: min ?? -Infinity,
soft: softMin !== null ? softMin : undefined,
mode: softMinMode,
},
max: {
pad: padMaxBy,
hard: max ?? Infinity,
soft: softMax !== null ? softMax : undefined,
mode: softMaxMode,
},
};
const hardMinOnly = softMin == null && min != null;
const hardMaxOnly = softMax == null && max != null;
const hasFixedRange = hardMinOnly && hardMaxOnly;
return {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
};
}
/**
* Initial [min, max] for the range pipeline. Returns null when we have no data and no fixed range
* (so the caller can bail and return [dataMin, dataMax] unchanged).
*/
function getInitialMinMax(
dataMin: number | null,
dataMax: number | null,
hasFixedRange: boolean,
): Range.MinMax | null {
if (!hasFixedRange && dataMin == null && dataMax == null) {
return null;
}
return [dataMin, dataMax];
}
/**
* Computes the linear-scale range using uPlot.rangeNum.
* Uses hard min/max when hardMinOnly/hardMaxOnly; otherwise uses data min/max. Applies padding via rangeConfig.
*/
function getLinearScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
): Range.MinMax {
const { rangeConfig, hardMinOnly, hardMaxOnly, min, max } = params;
const resolvedMin = hardMinOnly ? min : dataMin;
const resolvedMax = hardMaxOnly ? max : dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeNum(resolvedMin, resolvedMax, rangeConfig);
}
/**
* Computes the log-scale range using uPlot.rangeLog.
* Resolves min/max from params or data, then delegates to uPlot's log range helper.
*/
function getLogScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
logBase?: uPlot.Scale['log'],
): Range.MinMax {
const { min, max } = params;
const resolvedMin = min ?? dataMin;
const resolvedMax = max ?? dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeLog(
resolvedMin,
resolvedMax,
(logBase ?? 10) as 2 | 10,
true,
);
}
/**
* Snaps linear scale min down and max up to whole numbers so axis bounds are clean.
*/
function roundLinearRange(minMax: Range.MinMax): Range.MinMax {
const [currentMin, currentMax] = minMax;
let roundedMin = currentMin;
let roundedMax = currentMax;
if (roundedMin != null) {
roundedMin = incrRoundDn(roundedMin, 1);
}
if (roundedMax != null) {
roundedMax = incrRoundUp(roundedMax, 1);
}
return [roundedMin, roundedMax];
}
/**
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
*/
function adjustLogRange(
minMax: Range.MinMax,
logBase: number,
logFn: (v: number) => number,
): Range.MinMax {
let [currentMin, currentMax] = minMax;
if (currentMin != null) {
const minExp = Math.floor(logFn(currentMin));
currentMin = logBase ** minExp;
}
if (currentMax != null) {
const maxExp = Math.ceil(logFn(currentMax));
currentMax = logBase ** maxExp;
if (currentMin === currentMax) {
currentMax *= logBase;
}
}
return [currentMin, currentMax];
}
/**
* For linear scales (distr === 1), clamps the computed range to the configured hard min/max when
* hardMinOnly/hardMaxOnly are set. No-op for log scales.
*/
function applyHardLimits(
minMax: Range.MinMax,
params: RangeFunctionParams,
distr: number,
): Range.MinMax {
let [currentMin, currentMax] = minMax;
if (distr !== 1) {
return [currentMin, currentMax];
}
const { hardMinOnly, hardMaxOnly, min, max } = params;
if (hardMinOnly && min != null) {
currentMin = min;
}
if (hardMaxOnly && max != null) {
currentMax = max;
}
return [currentMin, currentMax];
}
/**
* If the range is invalid (min >= max), returns a safe default: [1, 100] for log (distr 3), [0, 100] for linear.
*/
function enforceValidRange(minMax: Range.MinMax, distr: number): Range.MinMax {
const [currentMin, currentMax] = minMax;
if (currentMin != null && currentMax != null && currentMin >= currentMax) {
return [distr === 3 ? 1 : 0, 100];
}
return minMax;
}
/**
* Creates the uPlot range function for a scale. Called by uPlot with (u, dataMin, dataMax, scaleKey).
* Pipeline: initial min/max -> linear or log range (with padding) -> rounding/snapping -> hard limits -> valid range.
*/
export function createRangeFunction(
params: RangeFunctionParams,
): Range.Function {
return (
u: uPlot,
dataMin: number | null,
dataMax: number | null,
scaleKey: string,
): Range.MinMax => {
const scale = u.scales[scaleKey];
const initialMinMax = getInitialMinMax(
dataMin,
dataMax,
params.hasFixedRange,
);
if (!initialMinMax) {
return [dataMin, dataMax];
}
let minMax: Range.MinMax = initialMinMax;
const logBase = scale.log;
if (scale.distr === 1) {
minMax = getLinearScaleRange(minMax, params, dataMin, dataMax);
minMax = roundLinearRange(minMax);
} else if (scale.distr === 3) {
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
}
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);
return enforceValidRange(minMax, scale.distr ?? 1);
};
}
/**
* Expands softMin/softMax so that all threshold lines fall within the soft range and stay visible.
* Converts threshold values to yAxisUnit, then takes the min/max; softMin is lowered (or set) to
* include the smallest threshold, softMax is raised (or set) to include the largest.
*/
export function adjustSoftLimitsWithThresholds(
softMin: number | null,
softMax: number | null,
thresholds?: Threshold[],
yAxisUnit?: string,
): {
softMin: number | null;
softMax: number | null;
} {
if (!thresholds || thresholds.length === 0) {
return { softMin, softMax };
}
const [minThresholdValue, maxThresholdValue] = findMinMaxThresholdValues(
thresholds,
yAxisUnit,
);
if (minThresholdValue === null && maxThresholdValue === null) {
return { softMin, softMax };
}
const adjustedSoftMin =
minThresholdValue !== null
? softMin !== null
? Math.min(softMin, minThresholdValue)
: minThresholdValue
: softMin;
const adjustedSoftMax =
maxThresholdValue !== null
? softMax !== null
? Math.max(softMax, maxThresholdValue)
: maxThresholdValue
: softMax;
return {
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
};
}
/**
* Returns fallback time bounds (min/max) as Unix timestamps in seconds when no
* data range is available. Uses the last 24 hours: from one day ago to now.
*/
export function getFallbackMinMaxTimeStamp(): {
fallbackMin: number;
fallbackMax: number;
} {
const currentDate = new Date();
// Get the Unix timestamp (milliseconds since January 1, 1970)
const currentTime = currentDate.getTime();
const currentUnixTimestamp = Math.floor(currentTime / 1000);
// Calculate the date and time one day ago
const oneDayAgoUnixTimestamp = Math.floor(
(currentDate.getTime() - 86400000) / 1000,
); // 86400000 milliseconds in a day
return {
fallbackMin: oneDayAgoUnixTimestamp,
fallbackMax: currentUnixTimestamp,
};
}

View File

@@ -0,0 +1,39 @@
import { convertValue } from 'lib/getConvertedValue';
import { Threshold } from '../hooks/types';
/**
* Find min and max threshold values after converting to the target unit
*/
export function findMinMaxThresholdValues(
thresholds: Threshold[],
yAxisUnit?: string,
): [number | null, number | null] {
if (!thresholds || thresholds.length === 0) {
return [null, null];
}
let minThresholdValue: number | null = null;
let maxThresholdValue: number | null = null;
thresholds.forEach((threshold) => {
const { thresholdValue, thresholdUnit } = threshold;
if (thresholdValue === undefined) {
return;
}
const compareValue = convertValue(thresholdValue, thresholdUnit, yAxisUnit);
if (compareValue === null) {
return;
}
if (minThresholdValue === null || compareValue < minThresholdValue) {
minThresholdValue = compareValue;
}
if (maxThresholdValue === null || compareValue > maxThresholdValue) {
maxThresholdValue = compareValue;
}
});
return [minThresholdValue, maxThresholdValue];
}

View File

@@ -0,0 +1,17 @@
import { Range } from 'uplot';
export type LogScaleLimits = {
min: number | null;
max: number | null;
softMin: number | null;
softMax: number | null;
};
export type RangeFunctionParams = {
rangeConfig: Range.Config;
hardMinOnly: boolean;
hardMaxOnly: boolean;
hasFixedRange: boolean;
min: number | null;
max: number | null;
};

View File

@@ -1,7 +1,7 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { MemoryRouter, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import * as dashboardUtils from 'container/DashboardContainer/DashboardDescription';
import { sanitizeDashboardData } from 'container/DashboardContainer/DashboardDescription/utils';
import DashboardsList from 'container/ListOfDashboard';
import {
dashboardEmptyState,
@@ -12,8 +12,9 @@ import { rest } from 'msw';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { fireEvent, render, waitFor } from 'tests/test-utils';
jest.mock('container/DashboardContainer/DashboardDescription', () => ({
sanitizeDashboardData: jest.fn(),
jest.mock('container/DashboardContainer/DashboardDescription/utils', () => ({
sanitizeDashboardData: jest.fn((data) => data),
downloadObjectAsJson: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
@@ -232,7 +233,7 @@ describe('dashboard list page', () => {
expect(exportJsonBtn).toBeInTheDocument();
fireEvent.click(exportJsonBtn);
const firstDashboardData = dashboardSuccessResponse.data[0];
expect(dashboardUtils.sanitizeDashboardData).toHaveBeenCalledWith(
expect(sanitizeDashboardData).toHaveBeenCalledWith(
expect.objectContaining({
title: firstDashboardData.data.title,
createdAt: firstDashboardData.createdAt,

View File

@@ -33,6 +33,19 @@
height: calc(100vh - 48px);
border-right: 1px solid var(--Slate-500, #161922);
background: var(--Ink-500, #0b0c0e);
margin-top: 4px;
.nav-item {
.nav-item-data {
margin: 0px 8px 0px 4px;
}
&.active {
.nav-item-data .nav-item-label {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
.settings-page-content {
@@ -81,6 +94,14 @@
.settings-page-sidenav {
border-right: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.nav-item {
&.active {
.nav-item-data .nav-item-label {
color: var(--bg-ink-500);
}
}
}
}
.settings-page-content {

View File

@@ -13,7 +13,7 @@ import { SidebarItem } from 'container/SideNav/sideNav.types';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { Wrench } from 'lucide-react';
import { Cog } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
@@ -236,7 +236,7 @@ function SettingsPage(): JSX.Element {
className="settings-page-header-title"
data-testid="settings-page-title"
>
<Wrench size={16} />
<Cog size={16} />
Settings
</div>
</header>

View File

@@ -46,7 +46,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { useDashboardVariables } from '../../hooks/dashboard/useDashboardVariables';
import { updateDashboardVariablesStore } from './store/dashboardVariablesStore';
import { setDashboardVariablesStore } from './store/dashboardVariablesStore';
import {
DashboardSortOrder,
IDashboardContext,
@@ -205,7 +205,7 @@ export function DashboardProvider({
const updatedVariables = selectedDashboard?.data.variables || {};
if (!isEqual(existingVariables, updatedVariables)) {
updateDashboardVariablesStore(updatedVariables);
setDashboardVariablesStore(updatedVariables);
}
}, [selectedDashboard]);

View File

@@ -7,11 +7,8 @@ export type IDashboardVariables = Record<string, IDashboardVariable>;
export const dashboardVariablesStore = createStore<IDashboardVariables>({});
export function updateDashboardVariablesStore(
export function setDashboardVariablesStore(
variables: Partial<IDashboardVariables>,
): void {
dashboardVariablesStore.update((currentVariables) => ({
...currentVariables,
...variables,
}));
dashboardVariablesStore.set(() => ({ ...variables }));
}

2
go.mod
View File

@@ -231,7 +231,7 @@ require (
github.com/natefinch/wrap v0.2.0 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/oklog/ulid/v2 v2.1.1
github.com/open-feature/go-sdk v1.17.0
github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.128.0 // indirect
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.128.0 // indirect

View File

@@ -152,7 +152,7 @@ func (provider *provider) BatchCheck(ctx context.Context, tupleReq []*openfgav1.
}
}
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "none of the subjects are allowed for requested access")
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subjects are not authorized for requested access")
}

View File

@@ -5,10 +5,13 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -34,14 +37,48 @@ func NewAuthZ(logger *slog.Logger, orgGetter organization.Getter, authzService a
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozEditorRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozViewerRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
if errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
render.Error(rw, errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "only viewers/editors/admins can access this resource"))
return
}
render.Error(rw, err)
return
}
@@ -52,14 +89,47 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozEditorRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
if errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
render.Error(rw, errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "only editors/admins can access this resource"))
return
}
render.Error(rw, err)
return
}
@@ -70,14 +140,46 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
if errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
render.Error(rw, errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "only admins can access this resource"))
return
}
render.Error(rw, err)
return
}
@@ -120,30 +222,18 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
return
}
orgId, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
selectors, err := cb(req, claims)
if err != nil {
render.Error(rw, err)
return
}
roles, err := middleware.roleGetter.ListByOrgIDAndNames(req.Context(), orgId, roles)
if err != nil {
render.Error(rw, err)
return
}
roleSelectors := []authtypes.Selector{}
for _, role := range roles {
selectors = append(selectors, authtypes.MustNewSelector(authtypes.TypeRole, role.ID.String()))
roleSelectors = append(roleSelectors, authtypes.MustNewSelector(authtypes.TypeRole, role))
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgId, relation, typeable, selectors, roleSelectors)
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, valuer.MustNewUUID(claims.OrgID), relation, typeable, selectors, roleSelectors)
if err != nil {
render.Error(rw, err)
return
@@ -162,13 +252,18 @@ func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation auth
return
}
selectors, orgID, err := cb(req, orgs)
selectors, orgId, err := cb(req, orgs)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, typeable, selectors, selectors)
roleSelectors := []authtypes.Selector{}
for _, role := range roles {
roleSelectors = append(roleSelectors, authtypes.MustNewSelector(authtypes.TypeRole, role))
}
err = middleware.authzService.CheckWithTupleCreationWithoutClaims(ctx, orgId, relation, typeable, selectors, roleSelectors)
if err != nil {
render.Error(rw, err)
return

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
@@ -19,7 +20,7 @@ func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertman
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization) error {
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
if err := module.store.Create(ctx, organization); err != nil {
return err
}
@@ -32,6 +33,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := createManagedRoles(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -18,7 +18,7 @@ type Getter interface {
type Setter interface {
// Create creates the given organization
Create(context.Context, *types.Organization) error
Create(context.Context, *types.Organization, func(context.Context, valuer.UUID) error) error
// Update updates the given organization
Update(context.Context, *types.Organization) error

View File

@@ -20,31 +20,11 @@ func NewGranter(store roletypes.Store, authz authz.AuthZ) role.Granter {
}
func (granter *granter) Grant(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
role, err := granter.store.GetByOrgIDAndName(ctx, orgID, name)
if err != nil {
return err
}
tuples, err := authtypes.TypeableRole.Tuples(
subject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, role.ID.StringValue()),
},
orgID,
)
if err != nil {
return err
}
return granter.authz.Write(ctx, tuples, nil)
}
func (granter *granter) GrantByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID, subject string) error {
tuples, err := authtypes.TypeableRole.Tuples(
subject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, id.StringValue()),
authtypes.MustNewSelector(authtypes.TypeRole, name),
},
orgID,
)
@@ -69,16 +49,11 @@ func (granter *granter) ModifyGrant(ctx context.Context, orgID valuer.UUID, exis
}
func (granter *granter) Revoke(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
role, err := granter.store.GetByOrgIDAndName(ctx, orgID, name)
if err != nil {
return err
}
tuples, err := authtypes.TypeableRole.Tuples(
subject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, role.ID.StringValue()),
authtypes.MustNewSelector(authtypes.TypeRole, name),
},
orgID,
)

View File

@@ -169,7 +169,7 @@ func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
return
}
err = role.PatchMetadata(req.Name, req.Description)
err = role.PatchMetadata(req.Description)
if err != nil {
render.Error(rw, err)
return
@@ -222,7 +222,7 @@ func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
return
}
err = handler.setter.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), id, relation, patchableObjects.Additions, patchableObjects.Deletions)
err = handler.setter.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), role.Name, relation, patchableObjects.Additions, patchableObjects.Deletions)
if err != nil {
render.Error(rw, err)
return

View File

@@ -40,7 +40,7 @@ func (setter *setter) Patch(_ context.Context, _ valuer.UUID, _ *roletypes.Role)
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (setter *setter) PatchObjects(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ authtypes.Relation, _, _ []*authtypes.Object) error {
func (setter *setter) PatchObjects(_ context.Context, _ valuer.UUID, _ string, _ authtypes.Relation, _, _ []*authtypes.Object) error {
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -26,7 +26,7 @@ type Setter interface {
Patch(context.Context, valuer.UUID, *roletypes.Role) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -52,9 +52,6 @@ type Granter interface {
// Grants a role to the subject based on role name.
Grant(context.Context, valuer.UUID, string, string) error
// Grants a role to the subject based on role id.
GrantByID(context.Context, valuer.UUID, valuer.UUID, string) error
// Revokes a granted role from the subject based on role name.
Revoke(context.Context, valuer.UUID, string, string) error

View File

@@ -17,7 +17,9 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/dustin/go-humanize"
"golang.org/x/text/cases"
@@ -169,6 +171,12 @@ func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID)
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
// since assign is idempotant multiple calls to assign won't cause issues in case of retries.
err := module.granter.Grant(ctx, input.OrgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(input.Role), authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
if err != nil {
return err
}
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
@@ -229,6 +237,18 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
}
}
if user.Role != existingUser.Role {
err = m.granter.ModifyGrant(ctx,
orgID,
roletypes.MustGetSigNozManagedRoleFromExistingRole(existingUser.Role),
roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role),
authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil),
)
if err != nil {
return nil, err
}
}
user.UpdatedAt = time.Now()
updatedUser, err := m.store.UpdateUser(ctx, orgID, id, user)
if err != nil {
@@ -280,6 +300,12 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin")
}
// since revoke is idempotant multiple calls to revoke won't cause issues in case of retries
err = module.granter.Revoke(ctx, orgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role), authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
if err != nil {
return err
}
if err := module.store.DeleteUser(ctx, orgID.String(), user.ID.StringValue()); err != nil {
return err
}
@@ -477,13 +503,26 @@ func (module *Module) CreateFirstUser(ctx context.Context, organization *types.O
return nil, err
}
managedRoles := roletypes.NewManagedRoles(organization.ID)
err = module.granter.Grant(ctx, organization.ID, roletypes.SigNozAdminRoleName, authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil))
if err != nil {
return nil, err
}
if err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err = module.orgSetter.Create(ctx, organization)
err = module.orgSetter.Create(ctx, organization, func(ctx context.Context, orgID valuer.UUID) error {
err = module.granter.CreateManagedRoles(ctx, orgID, managedRoles)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
err = module.CreateUser(ctx, user, root.WithFactorPassword(password))
err = module.createUserWithoutGrant(ctx, user, root.WithFactorPassword(password))
if err != nil {
return err
}
@@ -510,3 +549,28 @@ func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
}
if createUserOpts.FactorPassword != nil {
if err := module.store.CreatePassword(ctx, createUserOpts.FactorPassword); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
traitsOrProperties := types.NewTraitsFromUser(input)
module.analytics.IdentifyUser(ctx, input.OrgID.String(), input.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, input.OrgID.String(), input.ID.String(), "User Created", traitsOrProperties)
return nil
}

View File

@@ -8,9 +8,11 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
qslabels "github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -741,3 +743,26 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
return filteredSeries, nil
}
// HandleMissingDataAlert handles missing data alert logic by tracking the last timestamp
// with data points and checking if a missing data alert should be sent based on the
// [ruletypes.RuleCondition.AlertOnAbsent] and [ruletypes.RuleCondition.AbsentFor] conditions.
//
// Returns a pointer to the missing data alert if conditions are met, nil otherwise.
func (r *BaseRule) HandleMissingDataAlert(ctx context.Context, ts time.Time, hasData bool) *ruletypes.Sample {
// Track the last timestamp with data points for missing data alerts
if hasData {
r.lastTimestampWithDatapoints = ts
}
if !r.ruleCondition.AlertOnAbsent || ts.Before(r.lastTimestampWithDatapoints.Add(time.Duration(r.ruleCondition.AbsentFor)*time.Minute)) {
return nil
}
r.logger.InfoContext(ctx, "no data found for rule condition", "rule_id", r.ID())
lbls := labels.NewBuilder(labels.Labels{})
if !r.lastTimestampWithDatapoints.IsZero() {
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(constants.AlertTimeFormat))
}
return &ruletypes.Sample{Metric: lbls.Labels(), IsMissing: true}
}

View File

@@ -17,7 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/prometheus/promql"
)
@@ -142,6 +142,12 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
}
matrixToProcess := r.matrixToV3Series(res)
hasData := len(matrixToProcess) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
// Filter out new series if newGroupEvalDelay is configured
if r.ShouldSkipNewGroups() {
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, matrixToProcess)
@@ -154,6 +160,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
}
var resultVector ruletypes.Vector
for _, series := range matrixToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(
@@ -243,6 +250,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
for name, value := range r.annotations.Map() {
annotations = append(annotations, qslabels.Label{Name: name, Value: expand(value)})
}
if result.IsMissing {
lb.Set(qslabels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(qslabels.NoDataLabel, "true")
}
lbs := lb.Labels()
h := lbs.Hash()
@@ -265,6 +276,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
Value: result.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
Missing: result.IsMissing,
IsRecovering: result.IsRecovering,
}
}

View File

@@ -1345,6 +1345,275 @@ func TestMultipleThresholdPromRule(t *testing.T) {
}
}
func TestPromRule_NoData(t *testing.T) {
evalTime := time.Now()
postableRule := ruletypes.PostableRule{
AlertName: "Test no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL,
PromQueries: map[string]*v3.PromQuery{
"A": {Query: "test_metric"},
},
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
},
},
}
// time_series_v4 cols of interest
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// samples_v4 columns
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// see Timestamps on base_rule
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
cases := []struct {
description string
alertOnAbsent bool
values []any
target float64
expectAlerts int
}{
{
description: "AlertOnAbsent=false",
alertOnAbsent: false,
values: []any{},
target: 200,
expectAlerts: 0,
},
{
description: "AlertOnAbsent=true",
alertOnAbsent: true,
values: []any{},
target: 200,
expectAlerts: 1,
},
}
logger := instrumentationtest.New().Logger()
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// single fingerprint with labels JSON
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{},
telemetryStore,
)
defer func() {
_ = promProvider.Close()
}()
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(nil, telemetryStore, promProvider, "", time.Second, nil, nil, options)
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, reader, promProvider)
require.NoError(t, err)
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
assert.Equal(t, c.expectAlerts, alertsFound)
})
}
}
func TestPromRule_NoData_AbsentFor(t *testing.T) {
// 1. Call Eval with data at time t1, to populate lastTimestampWithDatapoints
// 2. Call Eval without data at time t2
// 3. Alert fires only if t2 - t1 > AbsentFor
baseTime := time.Unix(1700000000, 0)
evalWindow := 5 * time.Minute
// Set target higher than test data (100.0) so regular threshold alerts don't fire
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: "Test no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(evalWindow),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL,
PromQueries: map[string]*v3.PromQuery{
"A": {Query: "test_metric"},
},
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
}},
},
},
}
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
cases := []struct {
description string
absentFor uint64 // grace period in minutes
timeBetweenEvals time.Duration // time between first eval (with data) and second eval (no data)
expectAlertOnEval2 int
}{
{
description: "WithinGracePeriod",
absentFor: 5,
timeBetweenEvals: 4 * time.Minute,
expectAlertOnEval2: 0,
},
{
description: "AfterGracePeriod",
absentFor: 5,
timeBetweenEvals: 6 * time.Minute,
expectAlertOnEval2: 1,
},
}
logger := instrumentationtest.New().Logger()
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AbsentFor = c.absentFor
// Timestamps for two evaluations
// t1 is the eval time for first eval, data points are in the past
t1 := baseTime.Add(5 * time.Minute) // first eval with data
t2 := t1.Add(c.timeBetweenEvals) // second eval without data
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
// Helper to calculate query time range for an eval time
calcQueryRange := func(evalTime time.Time) (int64, int64) {
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindow.Milliseconds())/60000)*60000 + 1
queryEnd := (evalTimeMs / 60000) * 60000
return queryStart, queryEnd
}
// First eval (t1) - with data
queryStart1, queryEnd1 := calcQueryRange(t1)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
// Data points in the past relative to t1
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
{"test_metric", fingerprint, baseTime.Add(1 * time.Minute).UnixMilli(), 100.0, uint32(0)},
{"test_metric", fingerprint, baseTime.Add(2 * time.Minute).UnixMilli(), 100.0, uint32(0)},
}))
// Second eval (t2) - no data
queryStart2, queryEnd2 := calcQueryRange(t2)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{},
telemetryStore,
)
defer func() {
_ = promProvider.Close()
}()
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(nil, telemetryStore, promProvider, "", time.Second, nil, nil, options)
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, reader, promProvider)
require.NoError(t, err)
// First eval with data - should NOT alert, but populates lastTimestampWithDatapoints
alertsFound1, err := rule.Eval(context.Background(), t1)
require.NoError(t, err)
assert.Equal(t, 0, alertsFound1, "First eval with data should not alert")
// Second eval without data - should alert based on AbsentFor
alertsFound2, err := rule.Eval(context.Background(), t2)
require.NoError(t, err)
assert.Equal(t, c.expectAlertOnEval2, alertsFound2)
})
}
}
func TestPromRuleEval_RequireMinPoints(t *testing.T) {
// fixed base time for deterministic tests
baseTime := time.Unix(1700000000, 0)

View File

@@ -24,7 +24,6 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/querier"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
@@ -462,26 +461,13 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
}
}
if queryResult != nil && len(queryResult.Series) > 0 {
r.lastTimestampWithDatapoints = time.Now()
hasData := queryResult != nil && len(queryResult.Series) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
// if the data is missing for `For` duration then we should send alert
if r.ruleCondition.AlertOnAbsent && r.lastTimestampWithDatapoints.Add(time.Duration(r.Condition().AbsentFor)*time.Minute).Before(time.Now()) {
r.logger.InfoContext(ctx, "no data found for rule condition", "rule_id", r.ID())
lbls := labels.NewBuilder(labels.Labels{})
if !r.lastTimestampWithDatapoints.IsZero() {
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(constants.AlertTimeFormat))
}
resultVector = append(resultVector, ruletypes.Sample{
Metric: lbls.Labels(),
IsMissing: true,
})
return resultVector, nil
}
if queryResult == nil {
r.logger.WarnContext(ctx, "query result is nil", "rule_name", r.Name(), "query_name", selectedQuery)
return resultVector, nil
@@ -538,26 +524,13 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
}
}
if queryResult != nil && len(queryResult.Series) > 0 {
r.lastTimestampWithDatapoints = time.Now()
hasData := queryResult != nil && len(queryResult.Series) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
// if the data is missing for `For` duration then we should send alert
if r.ruleCondition.AlertOnAbsent && r.lastTimestampWithDatapoints.Add(time.Duration(r.Condition().AbsentFor)*time.Minute).Before(time.Now()) {
r.logger.InfoContext(ctx, "no data found for rule condition", "rule_id", r.ID())
lbls := labels.NewBuilder(labels.Labels{})
if !r.lastTimestampWithDatapoints.IsZero() {
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(constants.AlertTimeFormat))
}
resultVector = append(resultVector, ruletypes.Sample{
Metric: lbls.Labels(),
IsMissing: true,
})
return resultVector, nil
}
if queryResult == nil {
r.logger.WarnContext(ctx, "query result is nil", "rule_name", r.Name(), "query_name", selectedQuery)
return resultVector, nil

View File

@@ -162,6 +162,10 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewUpdateOrgPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRenameOrgDomainsFactory(sqlstore, sqlschema),
sqlmigration.NewAddResetPasswordTokenExpiryFactory(sqlstore, sqlschema),
sqlmigration.NewAddManagedRolesFactory(sqlstore, sqlschema),
sqlmigration.NewAddAuthzIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRbacToAuthzFactory(sqlstore),
sqlmigration.NewMigratePublicDashboardsFactory(sqlstore),
)
}

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