mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 17:24:16 +00:00
Compare commits
88 Commits
feat/intro
...
fix/valida
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec969c897 | ||
|
|
75e67a7e35 | ||
|
|
8c67f6ff7a | ||
|
|
d62ed6f003 | ||
|
|
ef4ef47634 | ||
|
|
0a42c77ca7 | ||
|
|
a89bb71f2c | ||
|
|
521e5d92e7 | ||
|
|
09b7360513 | ||
|
|
0fd926b8a1 | ||
|
|
e4214309f4 | ||
|
|
297383ddca | ||
|
|
6871eccd28 | ||
|
|
0a272b5b43 | ||
|
|
4c4387b6d2 | ||
|
|
cb242e2d4c | ||
|
|
c98cdc174b | ||
|
|
6fc38bac79 | ||
|
|
ddba7e71b7 | ||
|
|
23f9ff50a7 | ||
|
|
55e5c871fe | ||
|
|
511bb176dd | ||
|
|
4e0c0319d0 | ||
|
|
9e5ea4de9c | ||
|
|
81e0df09b8 | ||
|
|
a522f39b9b | ||
|
|
affb6eee05 | ||
|
|
13a5e9dd24 | ||
|
|
f620767876 | ||
|
|
9fb8b2bb1b | ||
|
|
30494c9196 | ||
|
|
cae4cf0777 | ||
|
|
c9538b0604 | ||
|
|
204cc4e5c5 | ||
|
|
6dd2ffcb64 | ||
|
|
13c15249c5 | ||
|
|
8419ca7982 | ||
|
|
6b189b14c6 | ||
|
|
550c49fab0 | ||
|
|
5b6ff92648 | ||
|
|
45954b38fa | ||
|
|
ceade6c7d7 | ||
|
|
f15c88836c | ||
|
|
9af45643a9 | ||
|
|
d15e974e9f | ||
|
|
71e752a015 | ||
|
|
3407760585 | ||
|
|
58a0e36869 | ||
|
|
5d688eb919 | ||
|
|
c0f237a7c4 | ||
|
|
8ce8bc940a | ||
|
|
abce05b289 | ||
|
|
ccd25c3b67 | ||
|
|
ddb98da217 | ||
|
|
18d63d2e66 | ||
|
|
67c108f021 | ||
|
|
02939cafa4 | ||
|
|
e62b070c1e | ||
|
|
be0a7d8fd4 | ||
|
|
419044dc9e | ||
|
|
223465d6d5 | ||
|
|
cec99674fa | ||
|
|
0ccf58ac7a | ||
|
|
b08d636d6a | ||
|
|
f6141bc6c5 | ||
|
|
bfe49f0f1b | ||
|
|
8e8064c5c1 | ||
|
|
4392341467 | ||
|
|
521d8e4f4d | ||
|
|
b6103f371f | ||
|
|
43283506db | ||
|
|
694d9958db | ||
|
|
addee4c0a5 | ||
|
|
f10cf7ac04 | ||
|
|
b336678639 | ||
|
|
c438b3444e | ||
|
|
b624414507 | ||
|
|
bde7963444 | ||
|
|
2df93ff217 | ||
|
|
f496a6ecde | ||
|
|
599e230a72 | ||
|
|
9a0e32ff3b | ||
|
|
5fe2732698 | ||
|
|
4993a44ecc | ||
|
|
ebd575a16b | ||
|
|
666582337e | ||
|
|
23512ab05c | ||
|
|
1423749529 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,7 +49,6 @@ ee/query-service/tests/test-deploy/data/
|
||||
# local data
|
||||
*.backup
|
||||
*.db
|
||||
**/db
|
||||
/deploy/docker/clickhouse-setup/data/
|
||||
/deploy/docker-swarm/clickhouse-setup/data/
|
||||
bin/
|
||||
|
||||
6
Makefile
6
Makefile
@@ -72,12 +72,6 @@ devenv-up: devenv-clickhouse devenv-signoz-otel-collector ## Start both clickhou
|
||||
@echo " - ClickHouse: http://localhost:8123"
|
||||
@echo " - Signoz OTel Collector: grpc://localhost:4317, http://localhost:4318"
|
||||
|
||||
.PHONY: devenv-clickhouse-clean
|
||||
devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
|
||||
@echo "Removing ClickHouse data..."
|
||||
@rm -rf .devenv/docker/clickhouse/fs/tmp/*
|
||||
@echo "ClickHouse data cleaned!"
|
||||
|
||||
##############################################################
|
||||
# go commands
|
||||
##############################################################
|
||||
|
||||
@@ -278,13 +278,3 @@ tokenizer:
|
||||
token:
|
||||
# The maximum number of tokens a user can have. This limits the number of concurrent sessions a user can have.
|
||||
max_per_user: 5
|
||||
|
||||
##################### Flagger #####################
|
||||
flagger:
|
||||
# Config are the overrides for the feature flags which come directly from the config file.
|
||||
config:
|
||||
boolean:
|
||||
string:
|
||||
float:
|
||||
integer:
|
||||
object:
|
||||
|
||||
@@ -849,71 +849,6 @@ paths:
|
||||
summary: Deprecated create session by email password
|
||||
tags:
|
||||
- sessions
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: ListPromotedAndIndexedPaths
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- logs
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: HandlePromoteAndIndexPaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- logs
|
||||
/api/v1/org/preferences:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -1726,51 +1661,6 @@ paths:
|
||||
summary: Update user preference
|
||||
tags:
|
||||
- preferences
|
||||
/api/v2/features:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the supported features and their details
|
||||
operationId: GetFeatures
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/FeaturetypesGettableFeature'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get features
|
||||
tags:
|
||||
- features
|
||||
/api/v2/orgs/me:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -2218,24 +2108,6 @@ components:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
FeaturetypesGettableFeature:
|
||||
properties:
|
||||
defaultVariant:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
resolvedValue: {}
|
||||
stage:
|
||||
type: string
|
||||
variants:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
PreferencetypesPreference:
|
||||
properties:
|
||||
allowedScopes:
|
||||
@@ -2265,26 +2137,6 @@ components:
|
||||
type: object
|
||||
PreferencetypesValue:
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
indexes:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesWrappedIndex'
|
||||
type: array
|
||||
path:
|
||||
type: string
|
||||
promote:
|
||||
type: boolean
|
||||
type: object
|
||||
PromotetypesWrappedIndex:
|
||||
properties:
|
||||
column_type:
|
||||
type: string
|
||||
granularity:
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
RenderErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
|
||||
@@ -376,6 +376,7 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SqlStore: sqlstore,
|
||||
QueryParser: queryParser,
|
||||
}
|
||||
|
||||
// create Manager
|
||||
|
||||
@@ -207,6 +207,42 @@ func (r *AnomalyRule) GetSelectedQuery() string {
|
||||
return r.Condition().GetSelectedQueryName()
|
||||
}
|
||||
|
||||
// filterNewSeries filters out new series based on the first_seen timestamp.
|
||||
func (r *AnomalyRule) filterNewSeries(ctx context.Context, ts time.Time, series []*v3.Series) ([]*v3.Series, error) {
|
||||
// Convert []*v3.Series to []v3.Series for filtering
|
||||
v3Series := make([]v3.Series, 0, len(series))
|
||||
for _, s := range series {
|
||||
v3Series = append(v3Series, *s)
|
||||
}
|
||||
|
||||
// Get indexes to skip
|
||||
skipIndexes, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, v3Series)
|
||||
if filterErr != nil {
|
||||
r.logger.ErrorContext(ctx, "Error filtering new series, ", "error", filterErr, "rule_name", r.Name())
|
||||
return nil, filterErr
|
||||
}
|
||||
|
||||
// if no series are skipped, return the original series
|
||||
if len(skipIndexes) == 0 {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// Create a map of skip indexes for efficient lookup
|
||||
skippedIdxMap := make(map[int]struct{}, len(skipIndexes))
|
||||
for _, idx := range skipIndexes {
|
||||
skippedIdxMap[idx] = struct{}{}
|
||||
}
|
||||
|
||||
// Filter out skipped series
|
||||
oldSeries := make([]*v3.Series, 0, len(series)-len(skipIndexes))
|
||||
for i, s := range series {
|
||||
if _, shouldSkip := skippedIdxMap[i]; !shouldSkip {
|
||||
oldSeries = append(oldSeries, s)
|
||||
}
|
||||
}
|
||||
return oldSeries, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
|
||||
|
||||
params, err := r.prepareQueryRange(ctx, ts)
|
||||
@@ -239,7 +275,18 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
|
||||
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
|
||||
|
||||
for _, series := range queryResult.AnomalyScores {
|
||||
// Filter out new series if newGroupEvalDelay is configured
|
||||
seriesToProcess := queryResult.AnomalyScores
|
||||
if r.ShouldSkipNewGroups() {
|
||||
filteredSeries, filterErr := r.filterNewSeries(ctx, ts, seriesToProcess)
|
||||
if filterErr != nil {
|
||||
r.logger.ErrorContext(ctx, "Error filtering new series, ", "error", filterErr, "rule_name", r.Name())
|
||||
return nil, filterErr
|
||||
}
|
||||
seriesToProcess = filteredSeries
|
||||
}
|
||||
|
||||
for _, series := range seriesToProcess {
|
||||
if r.Condition() != nil && r.Condition().RequireMinPoints {
|
||||
if len(series.Points) < r.Condition().RequiredNumPoints {
|
||||
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
|
||||
@@ -291,7 +338,18 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
|
||||
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
|
||||
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
|
||||
|
||||
for _, series := range queryResult.AnomalyScores {
|
||||
// Filter out new series if newGroupEvalDelay is configured
|
||||
seriesToProcess := queryResult.AnomalyScores
|
||||
if r.ShouldSkipNewGroups() {
|
||||
filteredSeries, filterErr := r.filterNewSeries(ctx, ts, seriesToProcess)
|
||||
if filterErr != nil {
|
||||
r.logger.ErrorContext(ctx, "Error filtering new series, ", "error", filterErr, "rule_name", r.Name())
|
||||
return nil, filterErr
|
||||
}
|
||||
seriesToProcess = filteredSeries
|
||||
}
|
||||
|
||||
for _, series := range seriesToProcess {
|
||||
if r.Condition().RequireMinPoints {
|
||||
if len(series.Points) < r.Condition().RequiredNumPoints {
|
||||
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
|
||||
|
||||
@@ -37,6 +37,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.SLogger,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -59,6 +60,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.Reader,
|
||||
opts.ManagerOpts.Prometheus,
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -82,6 +84,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.Cache,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
)
|
||||
if err != nil {
|
||||
return task, err
|
||||
@@ -140,6 +143,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -160,6 +164,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -179,6 +184,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to prepare a new anomaly rule for test", zap.String("name", alertname), zap.Error(err))
|
||||
|
||||
@@ -6,7 +6,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -369,9 +368,6 @@ function App(): JSX.Element {
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{isLoggedInState && <CmdKPalette userRole={user.role} />}
|
||||
{isLoggedInState && (
|
||||
<ShiftHoldOverlayController userRole={user.role} />
|
||||
)}
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
|
||||
export const getMetricMetadata = async (
|
||||
metricName: string,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.get(
|
||||
`/metrics/metadata?metricName=${encodedMetricName}`,
|
||||
{
|
||||
signal,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,11 @@
|
||||
.log-field-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
align-items: baseline;
|
||||
}
|
||||
.log-field-key,
|
||||
.log-field-key-colon {
|
||||
.log-field-key {
|
||||
padding-right: 5px;
|
||||
color: var(--text-vanilla-400, #c0c1c3);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&.small {
|
||||
font-size: 11px;
|
||||
@@ -26,20 +22,6 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
.log-field-key {
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
max-width: 20vw;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
.log-field-key-colon {
|
||||
min-width: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-value {
|
||||
color: var(--text-vanilla-400, #c0c1c3);
|
||||
font-size: 14px;
|
||||
@@ -176,8 +158,7 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.log-field-key,
|
||||
.log-field-key-colon {
|
||||
.log-field-key {
|
||||
color: var(--text-slate-400);
|
||||
}
|
||||
.log-value {
|
||||
@@ -189,10 +170,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.log-field-key,
|
||||
.log-field-key-colon {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,13 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
||||
// styles
|
||||
import { Container, LogContainer, LogText } from './styles';
|
||||
import {
|
||||
Container,
|
||||
LogContainer,
|
||||
LogText,
|
||||
Text,
|
||||
TextContainer,
|
||||
} from './styles';
|
||||
import { isValidLogField } from './util';
|
||||
|
||||
interface LogFieldProps {
|
||||
@@ -52,18 +58,16 @@ function LogGeneralField({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="log-field-container">
|
||||
<p className={cx('log-field-key', fontSize)} title={fieldKey}>
|
||||
{fieldKey}
|
||||
</p>
|
||||
<span className={cx('log-field-key-colon', fontSize)}> : </span>
|
||||
<TextContainer>
|
||||
<Text ellipsis type="secondary" className={cx('log-field-key', fontSize)}>
|
||||
{`${fieldKey} : `}
|
||||
</Text>
|
||||
<LogText
|
||||
dangerouslySetInnerHTML={html}
|
||||
className={cx('log-value', fontSize)}
|
||||
title={fieldValue}
|
||||
linesPerRow={linesPerRow > 1 ? linesPerRow : undefined}
|
||||
/>
|
||||
</div>
|
||||
</TextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Card } from 'antd';
|
||||
import { Card, Typography } from 'antd';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import styled from 'styled-components';
|
||||
import { getActiveLogBackground } from 'utils/logs';
|
||||
@@ -46,6 +46,19 @@ export const Container = styled(Card)<{
|
||||
getActiveLogBackground($isActiveLog, $isDarkMode, $logType)}
|
||||
`;
|
||||
|
||||
export const Text = styled(Typography.Text)`
|
||||
&&& {
|
||||
min-width: 2.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TextContainer = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const LogContainer = styled.div<LogContainerProps>`
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
|
||||
@@ -560,10 +560,6 @@
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,10 +569,6 @@
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
|
||||
@@ -169,10 +169,6 @@
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ const ADD_ONS_KEYS = {
|
||||
ORDER_BY: 'order_by',
|
||||
LIMIT: 'limit',
|
||||
LEGEND_FORMAT: 'legend_format',
|
||||
REDUCE_TO: 'reduce_to',
|
||||
};
|
||||
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
@@ -41,14 +40,13 @@ const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
|
||||
[ADD_ONS_KEYS.LIMIT]: 'limit',
|
||||
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
|
||||
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
|
||||
};
|
||||
|
||||
const ADD_ONS = [
|
||||
{
|
||||
icon: <BarChart2 size={14} />,
|
||||
label: 'Group By',
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
key: 'group_by',
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
|
||||
@@ -56,7 +54,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: ADD_ONS_KEYS.HAVING,
|
||||
key: 'having',
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
@@ -65,7 +63,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: ADD_ONS_KEYS.ORDER_BY,
|
||||
key: 'order_by',
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
@@ -74,7 +72,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: ADD_ONS_KEYS.LIMIT,
|
||||
key: 'limit',
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
@@ -83,7 +81,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
key: 'legend_format',
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
@@ -94,7 +92,7 @@ const ADD_ONS = [
|
||||
const REDUCE_TO = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: ADD_ONS_KEYS.REDUCE_TO,
|
||||
key: 'reduce_to',
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
@@ -220,9 +218,10 @@ function QueryAddOns({
|
||||
);
|
||||
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
|
||||
|
||||
// Filter and set selected views: add-ons that are both active and available
|
||||
setSelectedViews(
|
||||
filteredAddOns.filter(
|
||||
ADD_ONS.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/* eslint-disable */
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -61,7 +55,16 @@ jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// ReduceToFilter is not mocked - we test the actual Ant Design Select component
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter',
|
||||
() => ({
|
||||
ReduceToFilter: ({ onChange }: any) => (
|
||||
<button data-testid="reduce-to" onClick={() => onChange('sum')}>
|
||||
ReduceToFilter
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
function baseQuery(overrides: Partial<any> = {}): any {
|
||||
return {
|
||||
@@ -137,7 +140,7 @@ describe('QueryAddOns', () => {
|
||||
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('limit input auto-opens when limit is set and changing it calls handler', async () => {
|
||||
it('limit input auto-opens when limit is set and changing it calls handler', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ limit: 5 })}
|
||||
@@ -180,88 +183,4 @@ describe('QueryAddOns', () => {
|
||||
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
|
||||
expect(limitInput.value).toBe('7');
|
||||
});
|
||||
|
||||
it('shows reduce-to add-on when showReduceTo is true', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('query-add-on-reduce_to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-opens reduce-to content when reduceTo is set', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ reduceTo: 'sum' })}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleSetQueryData when reduce-to value changes', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const query = baseQuery({
|
||||
reduceTo: 'avg',
|
||||
aggregations: [{ id: 'a', operator: 'count', reduceTo: 'avg' }],
|
||||
});
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for the reduce-to content section to be visible (it auto-opens when reduceTo is set)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the Select component by its role (combobox)
|
||||
// The Select is within the reduce-to-content section
|
||||
const reduceToContent = screen.getByTestId('reduce-to-content');
|
||||
const selectCombobox = within(reduceToContent).getByRole('combobox');
|
||||
|
||||
// Open the dropdown by clicking on the combobox
|
||||
await user.click(selectCombobox);
|
||||
|
||||
// Wait for the dropdown listbox to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// Find and click the "Sum" option
|
||||
const sumOption = await screen.findByText('Sum of values in timeframe');
|
||||
await user.click(sumOption);
|
||||
|
||||
// Verify the handler was called with the correct value
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(0, {
|
||||
...query,
|
||||
aggregations: [
|
||||
{
|
||||
...(query.aggregations?.[0] as any),
|
||||
reduceTo: 'sum',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createShortcutActions } from '../../constants/shortcutActions';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
import { ShiftOverlay } from './ShiftOverlay';
|
||||
import { useShiftHoldOverlay } from './useShiftHoldOverlay';
|
||||
|
||||
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export function ShiftHoldOverlayController({
|
||||
userRole,
|
||||
}: {
|
||||
userRole: UserRole;
|
||||
}): JSX.Element | null {
|
||||
const { open: isCmdKOpen } = useCmdK();
|
||||
const noop = (): void => undefined;
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: noop,
|
||||
handleThemeChange: noop,
|
||||
});
|
||||
|
||||
const visible = useShiftHoldOverlay({
|
||||
isModalOpen: isCmdKOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<ShiftOverlay visible={visible} actions={actions} userRole={userRole} />
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import './shiftOverlay.scss';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { formatShortcut } from './formatShortcut';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
roles?: UserRole[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
interface ShortcutProps {
|
||||
label: string;
|
||||
keyHint: React.ReactNode;
|
||||
}
|
||||
|
||||
function Shortcut({ label, keyHint }: ShortcutProps): JSX.Element {
|
||||
return (
|
||||
<div className="shift-overlay__item">
|
||||
<span className="shift-overlay__label">{label}</span>
|
||||
<kbd className="shift-overlay__kbd">{keyHint}</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShiftOverlayProps {
|
||||
visible: boolean;
|
||||
actions: CmdAction[];
|
||||
userRole: UserRole;
|
||||
}
|
||||
|
||||
export function ShiftOverlay({
|
||||
visible,
|
||||
actions,
|
||||
userRole,
|
||||
}: ShiftOverlayProps): JSX.Element | null {
|
||||
const navigationActions = useMemo(() => {
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
const permitted = actions.filter(
|
||||
(a) => !a.roles || a.roles.includes(userRole),
|
||||
);
|
||||
|
||||
// Navigation only + must have shortcut
|
||||
return permitted.filter(
|
||||
(a) =>
|
||||
a.section?.toLowerCase() === 'navigation' &&
|
||||
a.shortcut &&
|
||||
a.shortcut.length > 0,
|
||||
);
|
||||
}, [actions, userRole]);
|
||||
|
||||
if (!visible || navigationActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="shift-overlay">
|
||||
<div className="shift-overlay__panel">
|
||||
{navigationActions.map((action) => (
|
||||
<Shortcut
|
||||
key={action.id}
|
||||
label={action.name.replace(/^Go to\s+/i, '')}
|
||||
keyHint={formatShortcut(action.shortcut)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import type { CmdAction } from '../ShiftOverlay';
|
||||
import { ShiftOverlay } from '../ShiftOverlay';
|
||||
|
||||
jest.mock('../formatShortcut', () => ({
|
||||
formatShortcut: (shortcut: string[]): string => shortcut.join('+'),
|
||||
}));
|
||||
|
||||
const baseActions: CmdAction[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Go to Traces',
|
||||
section: 'navigation',
|
||||
shortcut: ['Shift', 'T'],
|
||||
perform: jest.fn(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Go to Metrics',
|
||||
section: 'navigation',
|
||||
shortcut: ['Shift', 'M'],
|
||||
roles: ['ADMIN'], // ✅ now UserRole[]
|
||||
perform: jest.fn(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Create Alert',
|
||||
section: 'actions',
|
||||
shortcut: ['A'],
|
||||
perform: jest.fn(),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Go to Logs',
|
||||
section: 'navigation',
|
||||
perform: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
describe('ShiftOverlay', () => {
|
||||
it('renders nothing when not visible', () => {
|
||||
const { container } = render(
|
||||
<ShiftOverlay visible={false} actions={baseActions} userRole="ADMIN" />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when no navigation shortcuts exist', () => {
|
||||
const { container } = render(
|
||||
<ShiftOverlay
|
||||
visible
|
||||
actions={[
|
||||
{
|
||||
id: 'x',
|
||||
name: 'Create Alert',
|
||||
section: 'actions',
|
||||
perform: jest.fn(),
|
||||
},
|
||||
]}
|
||||
userRole="ADMIN"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders navigation shortcuts in a portal', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
|
||||
|
||||
expect(document.body.querySelector('.shift-overlay')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Traces')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Shift+T')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shift+M')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies RBAC filtering correctly', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="VIEWER" />);
|
||||
|
||||
expect(screen.getByText('Traces')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Metrics')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('strips "Go to" prefix from labels', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
|
||||
|
||||
expect(screen.getByText('Traces')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Go to Traces')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render actions without shortcuts', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
|
||||
|
||||
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useShiftHoldOverlay } from '../useShiftHoldOverlay';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
function pressShift(target: EventTarget = window): void {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Shift',
|
||||
bubbles: true,
|
||||
});
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function releaseShift(): void {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keyup', {
|
||||
key: 'Shift',
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('useShiftHoldOverlay', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
it('shows overlay after holding Shift for 600ms', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show overlay if Shift is released early', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(300);
|
||||
releaseShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('hides overlay on Shift key release', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
releaseShift();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('does not activate when modal is open', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useShiftHoldOverlay({ isModalOpen: true }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('does not activate in typing context (input)', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift(input);
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it('cleans up on window blur', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up on document visibility change', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({ disabled: true }));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import './shiftOverlay.scss';
|
||||
|
||||
import { ArrowUp, ChevronUp, Command, Option } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function formatShortcut(shortcut?: string[]): ReactNode {
|
||||
if (!shortcut || shortcut.length === 0) return null;
|
||||
|
||||
const combo = shortcut.find((s) => typeof s === 'string' && s.trim());
|
||||
if (!combo) return null;
|
||||
|
||||
return combo.split('+').map((key) => {
|
||||
const k = key.trim().toLowerCase();
|
||||
|
||||
let node: ReactNode;
|
||||
switch (k) {
|
||||
case 'shift':
|
||||
node = <ArrowUp size={14} />;
|
||||
break;
|
||||
case 'cmd':
|
||||
case 'meta':
|
||||
node = <Command size={14} />;
|
||||
break;
|
||||
case 'alt':
|
||||
node = <Option size={14} />;
|
||||
break;
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
node = <ChevronUp size={14} />;
|
||||
break;
|
||||
case 'arrowup':
|
||||
node = <ArrowUp size={14} />;
|
||||
break;
|
||||
default:
|
||||
node = k.toUpperCase();
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`shortcut-${k}`} className="shift-overlay__key">
|
||||
{node}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
.shift-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
|
||||
&__panel {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 8px 12px;
|
||||
|
||||
background: var(--bg-ink-500);
|
||||
color: var(--bg-vanilla-300);
|
||||
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
|
||||
box-shadow: 0 6px 20px var(--bg-ink-500);
|
||||
animation: shift-overlay-fade-in 120ms ease-out;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__label {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&__kbd {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
display: flex;
|
||||
|
||||
border-radius: 4px;
|
||||
background: var(--bg-slate-100);
|
||||
}
|
||||
|
||||
&__key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-width: 15px;
|
||||
height: 20px;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
background-color: var(--bg-slate-100);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--bg-vanilla-300);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shift-overlay-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const HOLD_DELAY_MS = 500;
|
||||
|
||||
function isTypingContext(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
|
||||
}
|
||||
|
||||
interface UseShiftHoldOverlayOptions {
|
||||
disabled?: boolean;
|
||||
isModalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function useShiftHoldOverlay({
|
||||
disabled = false,
|
||||
isModalOpen = false,
|
||||
}: UseShiftHoldOverlayOptions): boolean {
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const isHoldingRef = useRef<boolean>(false);
|
||||
|
||||
useEffect((): (() => void) | void => {
|
||||
if (disabled) return;
|
||||
|
||||
function cleanup(): void {
|
||||
isHoldingRef.current = false;
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Shift') return;
|
||||
if (e.repeat) return;
|
||||
|
||||
// Suppress in bad contexts
|
||||
if (
|
||||
isModalOpen ||
|
||||
e.metaKey ||
|
||||
e.ctrlKey ||
|
||||
e.altKey ||
|
||||
isTypingContext(e.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isHoldingRef.current = true;
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (isHoldingRef.current) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, HOLD_DELAY_MS);
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Shift') return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function onBlur(): void {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', cleanup);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', cleanup);
|
||||
};
|
||||
}, [disabled, isModalOpen]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -1,18 +1,11 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { WarningFilled } from '@ant-design/icons';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import { Select } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { UniversalYAxisUnitMappings } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
|
||||
import {
|
||||
getUniversalNameFromMetricUnit,
|
||||
getYAxisCategories,
|
||||
mapMetricUnitToUniversalUnit,
|
||||
} from './utils';
|
||||
import { getYAxisCategories, mapMetricUnitToUniversalUnit } from './utils';
|
||||
|
||||
function YAxisUnitSelector({
|
||||
value,
|
||||
@@ -21,24 +14,9 @@ function YAxisUnitSelector({
|
||||
loading = false,
|
||||
'data-testid': dataTestId,
|
||||
source,
|
||||
initialValue,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
|
||||
const incompatibleUnitMessage = useMemo(() => {
|
||||
if (!initialValue || !value || loading) return '';
|
||||
const initialUniversalUnit = mapMetricUnitToUniversalUnit(initialValue);
|
||||
const currentUniversalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
if (initialUniversalUnit !== currentUniversalUnit) {
|
||||
const initialUniversalUnitName = getUniversalNameFromMetricUnit(
|
||||
initialValue,
|
||||
);
|
||||
const currentUniversalUnitName = getUniversalNameFromMetricUnit(value);
|
||||
return `Unit mismatch. Saved unit is ${initialUniversalUnitName}, but ${currentUniversalUnitName} is selected.`;
|
||||
}
|
||||
return '';
|
||||
}, [initialValue, value, loading]);
|
||||
|
||||
const handleSearch = (
|
||||
searchTerm: string,
|
||||
currentOption: DefaultOptionType | undefined,
|
||||
@@ -71,16 +49,6 @@ function YAxisUnitSelector({
|
||||
placeholder={placeholder}
|
||||
filterOption={(input, option): boolean => handleSearch(input, option)}
|
||||
loading={loading}
|
||||
suffixIcon={
|
||||
incompatibleUnitMessage ? (
|
||||
<Tooltip title={incompatibleUnitMessage}>
|
||||
<WarningFilled />
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
className={classNames({
|
||||
'warning-state': incompatibleUnitMessage,
|
||||
})}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{categories.map((category) => (
|
||||
|
||||
@@ -91,36 +91,4 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(screen.getByText('Bytes (B)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning message when incompatible unit is selected', () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
source={YAxisSource.ALERTS}
|
||||
value="By"
|
||||
onChange={mockOnChange}
|
||||
initialValue="s"
|
||||
/>,
|
||||
);
|
||||
const warningIcon = screen.getByLabelText('warning');
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
fireEvent.mouseOver(warningIcon);
|
||||
return screen
|
||||
.findByText(
|
||||
'Unit mismatch. Saved unit is Seconds (s), but Bytes (B) is selected.',
|
||||
)
|
||||
.then((el) => expect(el).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not show warning message when compatible unit is selected', () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
source={YAxisSource.ALERTS}
|
||||
value="s"
|
||||
onChange={mockOnChange}
|
||||
initialValue="s"
|
||||
/>,
|
||||
);
|
||||
const warningIcon = screen.queryByLabelText('warning');
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,3 @@
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-state {
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-amber-400) !important;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
color: var(--bg-amber-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface YAxisUnitSelectorProps {
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
source: YAxisSource;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export enum UniversalYAxisUnit {
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('CmdKPalette', () => {
|
||||
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open Sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,12 +9,34 @@ import {
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@signozhq/command';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
BellDot,
|
||||
BugIcon,
|
||||
DraftingCompass,
|
||||
Expand,
|
||||
HardDrive,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { createShortcutActions } from '../../constants/shortcutActions';
|
||||
import { useAppContext } from '../../providers/App/App';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
type CmdAction = {
|
||||
@@ -36,8 +58,19 @@ export function CmdKPalette({
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { updateUserPreferenceInContext } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
{
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// toggle palette with ⌘/Ctrl+K
|
||||
function handleGlobalCmdK(
|
||||
e: KeyboardEvent,
|
||||
@@ -78,10 +111,164 @@ export function CmdKPalette({
|
||||
history.push(key);
|
||||
}
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: onClickHandler,
|
||||
handleThemeChange,
|
||||
});
|
||||
function handleOpenSidebar(): void {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
|
||||
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCloseSidebar(): void {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
|
||||
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
id: 'home',
|
||||
name: 'Go to Home',
|
||||
shortcut: ['shift + h'],
|
||||
keywords: 'home',
|
||||
section: 'Navigation',
|
||||
icon: <Home size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.HOME),
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
name: 'Go to Dashboards',
|
||||
shortcut: ['shift + d'],
|
||||
keywords: 'dashboards',
|
||||
section: 'Navigation',
|
||||
icon: <LayoutGrid size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Go to Services',
|
||||
shortcut: ['shift + s'],
|
||||
keywords: 'services monitoring',
|
||||
section: 'Navigation',
|
||||
icon: <HardDrive size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.APPLICATION),
|
||||
},
|
||||
{
|
||||
id: 'traces',
|
||||
name: 'Go to Traces',
|
||||
shortcut: ['shift + t'],
|
||||
keywords: 'traces',
|
||||
section: 'Navigation',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs',
|
||||
shortcut: ['shift + l'],
|
||||
keywords: 'logs',
|
||||
section: 'Navigation',
|
||||
icon: <ScrollText size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.LOGS),
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
name: 'Go to Alerts',
|
||||
shortcut: ['shift + a'],
|
||||
keywords: 'alerts',
|
||||
section: 'Navigation',
|
||||
icon: <BellDot size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
|
||||
},
|
||||
{
|
||||
id: 'exceptions',
|
||||
name: 'Go to Exceptions',
|
||||
shortcut: ['shift + e'],
|
||||
keywords: 'exceptions errors',
|
||||
section: 'Navigation',
|
||||
icon: <BugIcon size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
|
||||
},
|
||||
{
|
||||
id: 'messaging-queues',
|
||||
name: 'Go to Messaging Queues',
|
||||
shortcut: ['shift + m'],
|
||||
keywords: 'messaging queues mq',
|
||||
section: 'Navigation',
|
||||
icon: <ListMinus size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
|
||||
},
|
||||
{
|
||||
id: 'my-settings',
|
||||
name: 'Go to Account Settings',
|
||||
keywords: 'account settings',
|
||||
section: 'Navigation',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
|
||||
},
|
||||
|
||||
// Settings
|
||||
{
|
||||
id: 'open-sidebar',
|
||||
name: 'Open Sidebar',
|
||||
keywords: 'sidebar navigation menu expand',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleOpenSidebar(),
|
||||
},
|
||||
{
|
||||
id: 'collapse-sidebar',
|
||||
name: 'Collapse Sidebar',
|
||||
keywords: 'sidebar navigation menu collapse',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleCloseSidebar(),
|
||||
},
|
||||
{
|
||||
id: 'dark-mode',
|
||||
name: 'Switch to Dark Mode',
|
||||
keywords: 'theme dark mode appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.DARK),
|
||||
},
|
||||
{
|
||||
id: 'light-mode',
|
||||
name: 'Switch to Light Mode [Beta]',
|
||||
keywords: 'theme light mode appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
|
||||
},
|
||||
{
|
||||
id: 'system-theme',
|
||||
name: 'Switch to System Theme',
|
||||
keywords: 'system theme appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
|
||||
},
|
||||
];
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
const permitted = actions.filter(
|
||||
|
||||
@@ -55,7 +55,6 @@ export const REACT_QUERY_KEY = {
|
||||
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
||||
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
||||
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
|
||||
GET_METRIC_METADATA: 'GET_METRIC_METADATA',
|
||||
|
||||
// Traces Funnels Query Keys
|
||||
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import {
|
||||
BarChart2,
|
||||
BellDot,
|
||||
BugIcon,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
Expand,
|
||||
HardDrive,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
|
||||
export type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
icon?: React.ReactNode;
|
||||
roles?: UserRole[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
type ActionDeps = {
|
||||
navigate: (path: string) => void;
|
||||
handleThemeChange: (mode: string) => void;
|
||||
};
|
||||
|
||||
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
const { navigate, handleThemeChange } = deps;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
name: 'Go to Home',
|
||||
shortcut: [GlobalShortcutsName.NavigateToHome],
|
||||
keywords: 'home',
|
||||
section: 'Navigation',
|
||||
icon: <Home size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.HOME),
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
name: 'Go to Dashboards',
|
||||
shortcut: [GlobalShortcutsName.NavigateToDashboards],
|
||||
keywords: 'dashboards',
|
||||
section: 'Navigation',
|
||||
icon: <LayoutGrid size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.ALL_DASHBOARD),
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Go to Services',
|
||||
shortcut: [GlobalShortcutsName.NavigateToServices],
|
||||
keywords: 'services monitoring',
|
||||
section: 'Navigation',
|
||||
icon: <HardDrive size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.APPLICATION),
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
name: 'Go to Alerts',
|
||||
shortcut: [GlobalShortcutsName.NavigateToAlerts],
|
||||
keywords: 'alerts',
|
||||
section: 'Navigation',
|
||||
icon: <BellDot size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LIST_ALL_ALERT),
|
||||
},
|
||||
{
|
||||
id: 'exceptions',
|
||||
name: 'Go to Exceptions',
|
||||
shortcut: [GlobalShortcutsName.NavigateToExceptions],
|
||||
keywords: 'exceptions errors',
|
||||
section: 'Navigation',
|
||||
icon: <BugIcon size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.ALL_ERROR),
|
||||
},
|
||||
{
|
||||
id: 'messaging-queues',
|
||||
name: 'Go to Messaging Queues',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMessagingQueues],
|
||||
keywords: 'messaging queues mq',
|
||||
section: 'Navigation',
|
||||
icon: <ListMinus size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.MESSAGING_QUEUES_OVERVIEW),
|
||||
},
|
||||
|
||||
// logs
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogs],
|
||||
keywords: 'logs',
|
||||
section: 'Logs',
|
||||
icon: <ScrollText size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs Pipelines',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogsPipelines],
|
||||
keywords: 'logs pipelines',
|
||||
section: 'Logs',
|
||||
icon: <Workflow size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_PIPELINES),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogsViews],
|
||||
keywords: 'logs views',
|
||||
section: 'Logs',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_SAVE_VIEWS),
|
||||
},
|
||||
|
||||
// metrics
|
||||
{
|
||||
id: 'metrics-summary',
|
||||
name: 'Go to Metrics Summary',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsSummary],
|
||||
keywords: 'metrics summary',
|
||||
section: 'Metrics',
|
||||
icon: <BarChart2 size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'metrics-explorer',
|
||||
name: 'Go to Metrics Explorer',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsExplorer],
|
||||
keywords: 'metrics explorer',
|
||||
section: 'Metrics',
|
||||
icon: <Compass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'metrics-views',
|
||||
name: 'Go to Metrics Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsViews],
|
||||
keywords: 'metrics views',
|
||||
section: 'Metrics',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_VIEWS),
|
||||
},
|
||||
|
||||
// Traces
|
||||
{
|
||||
id: 'traces',
|
||||
name: 'Go to Traces',
|
||||
shortcut: [GlobalShortcutsName.NavigateToTraces],
|
||||
keywords: 'traces',
|
||||
section: 'Traces',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.TRACES_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'traces-funnel',
|
||||
name: 'Go to Traces Funnels',
|
||||
shortcut: [GlobalShortcutsName.NavigateToTracesFunnel],
|
||||
keywords: 'traces funnel',
|
||||
section: 'Traces',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.TRACES_FUNNELS),
|
||||
},
|
||||
|
||||
// Common actions
|
||||
{
|
||||
id: 'dark-mode',
|
||||
name: 'Switch to Dark Mode',
|
||||
keywords: 'theme dark mode appearance',
|
||||
section: 'Common',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.DARK),
|
||||
},
|
||||
{
|
||||
id: 'light-mode',
|
||||
name: 'Switch to Light Mode [Beta]',
|
||||
keywords: 'theme light mode appearance',
|
||||
section: 'Common',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
|
||||
},
|
||||
{
|
||||
id: 'system-theme',
|
||||
name: 'Switch to System Theme',
|
||||
keywords: 'system theme appearance',
|
||||
section: 'Common',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
|
||||
},
|
||||
|
||||
// settings sub-pages
|
||||
{
|
||||
id: 'my-settings',
|
||||
name: 'Go to Account Settings',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettings],
|
||||
keywords: 'account settings',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.MY_SETTINGS),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-ingestion',
|
||||
name: 'Go to Account Settings Ingestion',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsIngestion],
|
||||
keywords: 'account settings',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.INGESTION_SETTINGS),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'my-settings-billing',
|
||||
name: 'Go to Account Settings Billing',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsBilling],
|
||||
keywords: 'account settings billing',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.BILLING),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-api-keys',
|
||||
name: 'Go to Account Settings API Keys',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsAPIKeys],
|
||||
keywords: 'account settings api keys',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.API_KEYS),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,57 +1,25 @@
|
||||
export const GlobalShortcuts = {
|
||||
NavigateToServices: 'shift+s',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
NavigateToHome: 'shift+h',
|
||||
|
||||
// logs
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
|
||||
// traces
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
|
||||
// metrics
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
|
||||
// settings
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToServices: 's+shift',
|
||||
NavigateToTraces: 't+shift',
|
||||
NavigateToLogs: 'l+shift',
|
||||
NavigateToDashboards: 'd+shift',
|
||||
NavigateToAlerts: 'a+shift',
|
||||
NavigateToExceptions: 'e+shift',
|
||||
NavigateToMessagingQueues: 'm+shift',
|
||||
ToggleSidebar: 'b+shift',
|
||||
NavigateToHome: 'h+shift',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsName = {
|
||||
NavigateToServices: 'shift+s',
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
NavigateToMessagingQueues: 'shift+m',
|
||||
ToggleSidebar: 'shift+b',
|
||||
NavigateToHome: 'shift+h',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsDescription = {
|
||||
@@ -64,17 +32,4 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToExceptions: 'Navigate to Exceptions List',
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
NavigateToTracesFunnel: 'Navigate to Traces Funnel',
|
||||
NavigateToTracesViews: 'Navigate to Traces Views',
|
||||
NavigateToMetricsSummary: 'Navigate to Metrics Summary',
|
||||
NavigateToMetricsExplorer: 'Navigate to Metrics Explorer',
|
||||
NavigateToMetricsViews: 'Navigate to Metrics Views',
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsAPIKeys: 'Navigate to API Keys Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToLogsViews: 'Navigate to Logs Views',
|
||||
};
|
||||
|
||||
@@ -10,20 +10,6 @@ import {
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('providers/cmdKProvider', () => ({
|
||||
useCmdK: (): {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openCmdK: () => void;
|
||||
closeCmdK: () => void;
|
||||
} => ({
|
||||
open: false,
|
||||
setOpen: jest.fn(),
|
||||
openCmdK: jest.fn(),
|
||||
closeCmdK: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
// Mock the AppContext
|
||||
@@ -77,7 +63,7 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
|
||||
describe('Global Shortcuts Constants', () => {
|
||||
it('should have the correct shortcut key combination', () => {
|
||||
expect(GlobalShortcuts.ToggleSidebar).toBe('shift+b');
|
||||
expect(GlobalShortcuts.ToggleSidebar).toBe('b+shift');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,9 @@ import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
|
||||
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -20,13 +18,7 @@ export interface ChartPreviewProps {
|
||||
|
||||
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
|
||||
const {
|
||||
alertType,
|
||||
thresholdState,
|
||||
alertState,
|
||||
setAlertState,
|
||||
isEditMode,
|
||||
} = useCreateAlertState();
|
||||
const { thresholdState, alertState, setAlertState } = useCreateAlertState();
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
@@ -35,25 +27,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
|
||||
const yAxisUnit = alertState.yAxisUnit || '';
|
||||
|
||||
const fetchYAxisUnit =
|
||||
!isEditMode && alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
|
||||
const selectedQueryName = thresholdState.selectedQuery;
|
||||
const { yAxisUnit: initialYAxisUnit, isLoading } = useGetYAxisUnit(
|
||||
selectedQueryName,
|
||||
{
|
||||
enabled: fetchYAxisUnit,
|
||||
},
|
||||
);
|
||||
|
||||
// Every time a new metric is selected, set the y-axis unit to its unit value if present
|
||||
// Only for metrics-based alerts
|
||||
useEffect(() => {
|
||||
if (fetchYAxisUnit) {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: initialYAxisUnit });
|
||||
}
|
||||
}, [initialYAxisUnit, setAlertState, fetchYAxisUnit]);
|
||||
|
||||
const headline = (
|
||||
<div className="chart-preview-headline">
|
||||
<PlotTag
|
||||
@@ -61,13 +34,11 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
/>
|
||||
<YAxisUnitSelector
|
||||
value={yAxisUnit}
|
||||
initialValue={initialYAxisUnit}
|
||||
value={alertState.yAxisUnit}
|
||||
onChange={(value): void => {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
|
||||
}}
|
||||
source={YAxisSource.ALERTS}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -120,6 +120,7 @@ function FullView({
|
||||
originalGraphType: selectedPanelType,
|
||||
};
|
||||
}
|
||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||
return {
|
||||
query: updatedQuery,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
|
||||
@@ -67,6 +67,7 @@ function WidgetGraphComponent({
|
||||
}: WidgetGraphComponentProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { notifications } = useNotifications();
|
||||
const { pathname, search } = useLocation();
|
||||
|
||||
@@ -315,6 +316,18 @@ function WidgetGraphComponent({
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
onMouseOver={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onFocus={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseOut={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
onBlur={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
id={widget.id}
|
||||
className="widget-graph-component-container"
|
||||
>
|
||||
@@ -364,6 +377,7 @@ function WidgetGraphComponent({
|
||||
|
||||
<div className="drag-handle">
|
||||
<WidgetHeader
|
||||
parentHover={hovered}
|
||||
title={widget?.title}
|
||||
widget={widget}
|
||||
onView={handleOnView}
|
||||
|
||||
@@ -137,6 +137,7 @@ function GridCardGraph({
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}
|
||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
return {
|
||||
query: updatedQuery,
|
||||
|
||||
@@ -99,12 +99,6 @@
|
||||
height: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.widget-header-more-options {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.widget-full-view {
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.widget-header-hover {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.widget-api-actions {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ describe('WidgetHeader', () => {
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -203,6 +204,7 @@ describe('WidgetHeader', () => {
|
||||
title="Empty Widget"
|
||||
widget={emptyWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -225,6 +227,7 @@ describe('WidgetHeader', () => {
|
||||
title={TABLE_WIDGET_TITLE}
|
||||
widget={tableWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -252,6 +255,7 @@ describe('WidgetHeader', () => {
|
||||
title={TABLE_WIDGET_TITLE}
|
||||
widget={tableWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -294,6 +298,7 @@ describe('WidgetHeader', () => {
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={errorResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -335,6 +340,7 @@ describe('WidgetHeader', () => {
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={warningResponse}
|
||||
isWarning
|
||||
isFetchingResponse={false}
|
||||
@@ -364,6 +370,7 @@ describe('WidgetHeader', () => {
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={fetchingResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse
|
||||
@@ -382,6 +389,7 @@ describe('WidgetHeader', () => {
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -406,6 +414,7 @@ describe('WidgetHeader', () => {
|
||||
title={TABLE_WIDGET_TITLE}
|
||||
widget={tableWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -424,6 +433,7 @@ describe('WidgetHeader', () => {
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
@@ -444,6 +454,7 @@ describe('WidgetHeader', () => {
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
parentHover={false}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
|
||||
@@ -48,6 +48,7 @@ interface IWidgetHeaderProps {
|
||||
onView: VoidFunction;
|
||||
onDelete?: VoidFunction;
|
||||
onClone?: VoidFunction;
|
||||
parentHover: boolean;
|
||||
queryResponse: UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown> & {
|
||||
warning?: Warning;
|
||||
@@ -68,6 +69,7 @@ function WidgetHeader({
|
||||
onView,
|
||||
onDelete,
|
||||
onClone,
|
||||
parentHover,
|
||||
queryResponse,
|
||||
threshold,
|
||||
headerMenuList,
|
||||
@@ -313,6 +315,8 @@ function WidgetHeader({
|
||||
<MoreOutlined
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
parentHover ? 'widget-header-hover' : ''
|
||||
} ${
|
||||
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -92,14 +92,14 @@ function BodyTitleRenderer({
|
||||
|
||||
if (isObject) {
|
||||
// For objects/arrays, stringify the entire structure
|
||||
copyText = JSON.stringify(value, null, 2);
|
||||
copyText = `"${cleanedKey}": ${JSON.stringify(value, null, 2)}`;
|
||||
} else if (parentIsArray) {
|
||||
// array elements
|
||||
copyText = `${value}`;
|
||||
// For array elements, copy just the value
|
||||
copyText = `"${cleanedKey}": ${value}`;
|
||||
} else {
|
||||
// primitive values
|
||||
const valueStr = typeof value === 'string' ? value : String(value);
|
||||
copyText = valueStr;
|
||||
// For primitive values, format as JSON key-value pair
|
||||
const valueStr = typeof value === 'string' ? `"${value}"` : String(value);
|
||||
copyText = `"${cleanedKey}": ${valueStr}`;
|
||||
}
|
||||
|
||||
setCopy(copyText);
|
||||
|
||||
@@ -60,8 +60,7 @@ const BodyContent: React.FC<{
|
||||
fieldData: Record<string, string>;
|
||||
record: DataType;
|
||||
bodyHtml: { __html: string };
|
||||
textToCopy: string;
|
||||
}> = React.memo(({ fieldData, record, bodyHtml, textToCopy }) => {
|
||||
}> = React.memo(({ fieldData, record, bodyHtml }) => {
|
||||
const { isLoading, treeData, error } = useAsyncJSONProcessing(
|
||||
fieldData.value,
|
||||
record.field === 'body',
|
||||
@@ -93,13 +92,11 @@ const BodyContent: React.FC<{
|
||||
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<CopyClipboardHOC entityKey="body" textToCopy={textToCopy}>
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={bodyHtml} />
|
||||
</span>
|
||||
</CopyClipboardHOC>
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={bodyHtml} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,12 +172,7 @@ export default function TableViewActions(
|
||||
switch (record.field) {
|
||||
case 'body':
|
||||
return (
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
/>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
);
|
||||
|
||||
case 'timestamp':
|
||||
@@ -202,7 +194,6 @@ export default function TableViewActions(
|
||||
record,
|
||||
fieldData,
|
||||
bodyHtml,
|
||||
textToCopy,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
cleanTimestamp,
|
||||
]);
|
||||
@@ -211,12 +202,7 @@ export default function TableViewActions(
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
/>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
{!isListViewPanel && !RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value">
|
||||
|
||||
@@ -1,54 +1,16 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
|
||||
import TableViewActions from '../TableViewActions';
|
||||
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';
|
||||
|
||||
// Mock data for tests
|
||||
let mockCopyToClipboard: jest.Mock;
|
||||
let mockNotificationsSuccess: jest.Mock;
|
||||
|
||||
// Mock the components and hooks
|
||||
jest.mock('components/Logs/CopyClipboardHOC', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
textToCopy,
|
||||
entityKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
textToCopy: string;
|
||||
entityKey: string;
|
||||
}): JSX.Element => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
className="CopyClipboardHOC"
|
||||
data-testid={`copy-clipboard-${entityKey}`}
|
||||
data-text-to-copy={textToCopy}
|
||||
onClick={(): void => {
|
||||
if (mockCopyToClipboard) {
|
||||
mockCopyToClipboard(textToCopy);
|
||||
}
|
||||
if (mockNotificationsSuccess) {
|
||||
mockNotificationsSuccess({
|
||||
message: `${entityKey} copied to clipboard`,
|
||||
key: `${entityKey} copied to clipboard`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div className="CopyClipboardHOC">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../useAsyncJSONProcessing', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): {
|
||||
formatTimezoneAdjustedTimestamp: (timestamp: string) => string;
|
||||
@@ -91,19 +53,6 @@ describe('TableViewActions', () => {
|
||||
onGroupByAttribute: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockCopyToClipboard = jest.fn();
|
||||
mockNotificationsSuccess = jest.fn();
|
||||
|
||||
// Default mock for useAsyncJSONProcessing
|
||||
const mockUseAsyncJSONProcessing = jest.mocked(useAsyncJSONProcessing);
|
||||
mockUseAsyncJSONProcessing.mockReturnValue({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<TableViewActions
|
||||
@@ -178,60 +127,4 @@ describe('TableViewActions', () => {
|
||||
container.querySelector(ACTION_BUTTON_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy non-JSON body text without quotes when user clicks on body', () => {
|
||||
// Setup: body field with surrounding quotes
|
||||
const bodyValueWithQuotes =
|
||||
'"FeatureFlag \'kafkaQueueProblems\' is enabled, sleeping 1 second"';
|
||||
const expectedCopiedText =
|
||||
"FeatureFlag 'kafkaQueueProblems' is enabled, sleeping 1 second";
|
||||
|
||||
const bodyProps = {
|
||||
fieldData: {
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
record: {
|
||||
key: 'body-key',
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
isListViewPanel: false,
|
||||
isfilterInLoading: false,
|
||||
isfilterOutLoading: false,
|
||||
onClickHandler: jest.fn(),
|
||||
onGroupByAttribute: jest.fn(),
|
||||
};
|
||||
|
||||
// Render component with body field
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={bodyProps.fieldData}
|
||||
record={bodyProps.record}
|
||||
isListViewPanel={bodyProps.isListViewPanel}
|
||||
isfilterInLoading={bodyProps.isfilterInLoading}
|
||||
isfilterOutLoading={bodyProps.isfilterOutLoading}
|
||||
onClickHandler={bodyProps.onClickHandler}
|
||||
onGroupByAttribute={bodyProps.onGroupByAttribute}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the clickable copy area for body
|
||||
const copyArea = screen.getByTestId('copy-clipboard-body');
|
||||
|
||||
// Verify it has the correct text to copy (without quotes)
|
||||
expect(copyArea).toHaveAttribute('data-text-to-copy', expectedCopiedText);
|
||||
|
||||
// Action: User clicks on body content
|
||||
fireEvent.click(copyArea);
|
||||
|
||||
// Assert: Text was copied without surrounding quotes
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(expectedCopiedText);
|
||||
|
||||
// Assert: Success notification shown
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'body copied to clipboard',
|
||||
key: 'body copied to clipboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('BodyTitleRenderer', () => {
|
||||
await user.click(screen.getByText('name'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('John');
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"user.name": "John"');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('user.name'),
|
||||
@@ -75,7 +75,7 @@ describe('BodyTitleRenderer', () => {
|
||||
await user.click(screen.getByText('0'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('arrayElement');
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"items[*].0": arrayElement');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,8 +96,9 @@ describe('BodyTitleRenderer', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const callArg = mockSetCopy.mock.calls[0][0];
|
||||
const expectedJson = JSON.stringify(testObject, null, 2);
|
||||
expect(callArg).toBe(expectedJson);
|
||||
expect(callArg).toContain('"user.metadata":');
|
||||
expect(callArg).toContain('"id": 123');
|
||||
expect(callArg).toContain('"active": true');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('object copied'),
|
||||
|
||||
@@ -58,27 +58,6 @@
|
||||
.explore-content {
|
||||
padding: 0 8px;
|
||||
|
||||
.y-axis-unit-selector-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.save-unit-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 2px;
|
||||
.ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-space {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
@@ -96,14 +75,6 @@
|
||||
.time-series-view {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.no-unit-warning {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 40px;
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
|
||||
.time-series-container {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Switch, Tooltip } from 'antd';
|
||||
import { Switch } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -25,14 +25,10 @@ import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToD
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import MetricDetails from '../MetricDetails/MetricDetails';
|
||||
// import QuerySection from './QuerySection';
|
||||
import TimeSeries from './TimeSeries';
|
||||
import { ExplorerTabs } from './types';
|
||||
import {
|
||||
getMetricUnits,
|
||||
splitQueryIntoOneChartPerQuery,
|
||||
useGetMetrics,
|
||||
} from './utils';
|
||||
import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
const ONE_CHART_PER_QUERY_ENABLED_KEY = 'isOneChartPerQueryEnabled';
|
||||
|
||||
@@ -44,34 +40,6 @@ function Explorer(): JSX.Element {
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
|
||||
const metricNames = useMemo(() => {
|
||||
const currentMetricNames: string[] = [];
|
||||
stagedQuery?.builder.queryData.forEach((query) => {
|
||||
if (query.aggregateAttribute?.key) {
|
||||
currentMetricNames.push(query.aggregateAttribute?.key);
|
||||
}
|
||||
});
|
||||
return currentMetricNames;
|
||||
}, [stagedQuery]);
|
||||
|
||||
const {
|
||||
metrics,
|
||||
isLoading: isMetricUnitsLoading,
|
||||
isError: isMetricUnitsError,
|
||||
} = useGetMetrics(metricNames);
|
||||
|
||||
const units = useMemo(() => getMetricUnits(metrics), [metrics]);
|
||||
|
||||
const areAllMetricUnitsSame = useMemo(
|
||||
() =>
|
||||
!isMetricUnitsLoading &&
|
||||
!isMetricUnitsError &&
|
||||
units.length > 0 &&
|
||||
units.every((unit) => unit && unit === units[0]),
|
||||
[units, isMetricUnitsLoading, isMetricUnitsError],
|
||||
);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isOneChartPerQueryEnabled =
|
||||
@@ -80,66 +48,7 @@ function Explorer(): JSX.Element {
|
||||
const [showOneChartPerQuery, toggleShowOneChartPerQuery] = useState(
|
||||
isOneChartPerQueryEnabled,
|
||||
);
|
||||
const [disableOneChartPerQuery, toggleDisableOneChartPerQuery] = useState(
|
||||
false,
|
||||
);
|
||||
const [selectedTab] = useState<ExplorerTabs>(ExplorerTabs.TIME_SERIES);
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
|
||||
|
||||
const unitsLength = useMemo(() => units.length, [units]);
|
||||
const firstUnit = useMemo(() => units?.[0], [units]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the y axis unit to the first metric unit if
|
||||
// 1. There is one metric unit and it is not empty
|
||||
// 2. All metric units are the same and not empty
|
||||
// Else, set the y axis unit to empty if
|
||||
// 1. There are more than one metric units and they are not the same
|
||||
// 2. There are no metric units
|
||||
// 3. There is exactly one metric unit but it is empty/undefined
|
||||
if (unitsLength === 0) {
|
||||
setYAxisUnit(undefined);
|
||||
} else if (unitsLength === 1 && firstUnit) {
|
||||
setYAxisUnit(firstUnit);
|
||||
} else if (unitsLength === 1 && !firstUnit) {
|
||||
setYAxisUnit(undefined);
|
||||
} else if (areAllMetricUnitsSame) {
|
||||
if (firstUnit) {
|
||||
setYAxisUnit(firstUnit);
|
||||
} else {
|
||||
setYAxisUnit(undefined);
|
||||
}
|
||||
} else if (unitsLength > 1 && !areAllMetricUnitsSame) {
|
||||
setYAxisUnit(undefined);
|
||||
}
|
||||
}, [unitsLength, firstUnit, areAllMetricUnitsSame]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't apply logic during loading to avoid overwriting user preferences
|
||||
if (isMetricUnitsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable one chart per query if -
|
||||
// 1. There are more than one metric
|
||||
// 2. The metric units are not the same
|
||||
if (units.length > 1 && !areAllMetricUnitsSame) {
|
||||
toggleShowOneChartPerQuery(true);
|
||||
toggleDisableOneChartPerQuery(true);
|
||||
} else if (units.length <= 1) {
|
||||
toggleShowOneChartPerQuery(false);
|
||||
toggleDisableOneChartPerQuery(true);
|
||||
} else {
|
||||
// When units are the same and loading is complete, restore URL-based preference
|
||||
toggleShowOneChartPerQuery(isOneChartPerQueryEnabled);
|
||||
toggleDisableOneChartPerQuery(false);
|
||||
}
|
||||
}, [
|
||||
units,
|
||||
areAllMetricUnitsSame,
|
||||
isMetricUnitsLoading,
|
||||
isOneChartPerQueryEnabled,
|
||||
]);
|
||||
|
||||
const handleToggleShowOneChartPerQuery = (): void => {
|
||||
toggleShowOneChartPerQuery(!showOneChartPerQuery);
|
||||
@@ -159,20 +68,15 @@ function Explorer(): JSX.Element {
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const exportDefaultQuery = useMemo(() => {
|
||||
const query = updateAllQueriesOperators(
|
||||
currentQuery || initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
DataSource.METRICS,
|
||||
);
|
||||
if (yAxisUnit && !query.unit) {
|
||||
return {
|
||||
...query,
|
||||
unit: yAxisUnit,
|
||||
};
|
||||
}
|
||||
return query;
|
||||
}, [currentQuery, updateAllQueriesOperators, yAxisUnit]);
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
currentQuery || initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
DataSource.METRICS,
|
||||
),
|
||||
[currentQuery, updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
@@ -186,16 +90,8 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const widgetId = uuid();
|
||||
|
||||
let query = queryToExport || exportDefaultQuery;
|
||||
if (yAxisUnit && !query.unit) {
|
||||
query = {
|
||||
...query,
|
||||
unit: yAxisUnit,
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
query,
|
||||
query: queryToExport || exportDefaultQuery,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
@@ -203,33 +99,17 @@ function Explorer(): JSX.Element {
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[exportDefaultQuery, safeNavigate, yAxisUnit],
|
||||
[exportDefaultQuery, safeNavigate],
|
||||
);
|
||||
|
||||
const splitedQueries = useMemo(
|
||||
() =>
|
||||
splitQueryIntoOneChartPerQuery(
|
||||
stagedQuery || initialQueriesMap[DataSource.METRICS],
|
||||
metricNames,
|
||||
units,
|
||||
),
|
||||
[stagedQuery, metricNames, units],
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleOpenMetricDetails = (metricName: string): void => {
|
||||
setIsMetricDetailsOpen(true);
|
||||
setSelectedMetricName(metricName);
|
||||
};
|
||||
|
||||
const handleCloseMetricDetails = (): void => {
|
||||
setIsMetricDetailsOpen(false);
|
||||
setSelectedMetricName(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.TabChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
@@ -243,44 +123,17 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>(undefined);
|
||||
|
||||
const oneChartPerQueryDisabledTooltip = useMemo(() => {
|
||||
if (splitedQueries.length <= 1) {
|
||||
return 'One chart per query cannot be toggled for a single query.';
|
||||
}
|
||||
if (units.length <= 1) {
|
||||
return 'One chart per query cannot be toggled when there is only one metric.';
|
||||
}
|
||||
if (disableOneChartPerQuery) {
|
||||
return 'One chart per query cannot be disabled for multiple queries with different units.';
|
||||
}
|
||||
return undefined;
|
||||
}, [disableOneChartPerQuery, splitedQueries.length, units.length]);
|
||||
|
||||
// Show the y axis unit selector if -
|
||||
// 1. There is only one metric
|
||||
// 2. The metric has no saved unit
|
||||
const showYAxisUnitSelector = useMemo(
|
||||
() => !isMetricUnitsLoading && units.length === 1 && !units[0],
|
||||
[units, isMetricUnitsLoading],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
checked={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
@@ -321,16 +174,6 @@ function Explorer(): JSX.Element {
|
||||
<TimeSeries
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
isMetricUnitsLoading={isMetricUnitsLoading}
|
||||
isMetricUnitsError={isMetricUnitsError}
|
||||
metricUnits={units}
|
||||
metricNames={metricNames}
|
||||
metrics={metrics}
|
||||
handleOpenMetricDetails={handleOpenMetricDetails}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Enable once we have resolved all related metrics issues */}
|
||||
@@ -344,17 +187,9 @@ function Explorer(): JSX.Element {
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
{isMetricDetailsOpen && (
|
||||
<MetricDetails
|
||||
metricName={selectedMetricName}
|
||||
isOpen={isMetricDetailsOpen}
|
||||
onClose={handleCloseMetricDetails}
|
||||
isModalTimeSelection={false}
|
||||
/>
|
||||
)}
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { isAxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter/BuilderUnits';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -28,13 +24,6 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
function TimeSeries({
|
||||
showOneChartPerQuery,
|
||||
setWarning,
|
||||
isMetricUnitsLoading,
|
||||
metricUnits,
|
||||
metricNames,
|
||||
handleOpenMetricDetails,
|
||||
yAxisUnit,
|
||||
setYAxisUnit,
|
||||
showYAxisUnitSelector,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -67,14 +56,13 @@ function TimeSeries({
|
||||
showOneChartPerQuery
|
||||
? splitQueryIntoOneChartPerQuery(
|
||||
stagedQuery || initialQueriesMap[DataSource.METRICS],
|
||||
metricNames,
|
||||
metricUnits,
|
||||
)
|
||||
: [stagedQuery || initialQueriesMap[DataSource.METRICS]],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[showOneChartPerQuery, stagedQuery, JSON.stringify(metricUnits)],
|
||||
[showOneChartPerQuery, stagedQuery],
|
||||
);
|
||||
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string>('');
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [
|
||||
@@ -138,148 +126,32 @@ function TimeSeries({
|
||||
setYAxisUnit(value);
|
||||
};
|
||||
|
||||
// TODO: Enable once we have resolved all related metrics v2 api issues
|
||||
// Show the save unit button if
|
||||
// 1. There is only one metric
|
||||
// 2. The metric has no saved unit
|
||||
// 3. The user has selected a unit
|
||||
// const showSaveUnitButton = useMemo(
|
||||
// () =>
|
||||
// metricUnits.length === 1 &&
|
||||
// Boolean(metrics?.[0]) &&
|
||||
// !metricUnits[0] &&
|
||||
// yAxisUnit,
|
||||
// [metricUnits, metrics, yAxisUnit],
|
||||
// );
|
||||
|
||||
// const {
|
||||
// mutate: updateMetricMetadata,
|
||||
// isLoading: isUpdatingMetricMetadata,
|
||||
// } = useUpdateMetricMetadata();
|
||||
|
||||
// const handleSaveUnit = (): void => {
|
||||
// updateMetricMetadata(
|
||||
// {
|
||||
// metricName: metricNames[0],
|
||||
// payload: {
|
||||
// unit: yAxisUnit,
|
||||
// description: metrics[0]?.description ?? '',
|
||||
// metricType: metrics[0]?.type as MetricType,
|
||||
// temporality: metrics[0]?.temporality,
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// notifications.success({
|
||||
// message: 'Unit saved successfully',
|
||||
// });
|
||||
// queryClient.invalidateQueries([
|
||||
// REACT_QUERY_KEY.GET_METRIC_DETAILS,
|
||||
// metricNames[0],
|
||||
// ]);
|
||||
// },
|
||||
// onError: () => {
|
||||
// notifications.error({
|
||||
// message: 'Failed to save unit',
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
// };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="y-axis-unit-selector-container">
|
||||
{showYAxisUnitSelector && (
|
||||
<>
|
||||
<YAxisUnitSelector
|
||||
onChange={onUnitChangeHandler}
|
||||
value={yAxisUnit}
|
||||
source={YAxisSource.EXPLORER}
|
||||
data-testid="y-axis-unit-selector"
|
||||
/>
|
||||
{/* TODO: Enable once we have resolved all related metrics v2 api issues */}
|
||||
{/* {showSaveUnitButton && (
|
||||
<div className="save-unit-container">
|
||||
<Typography.Text>
|
||||
Save the selected unit for this metric?
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
disabled={isUpdatingMetricMetadata}
|
||||
onClick={handleSaveUnit}
|
||||
>
|
||||
<Typography.Paragraph>Yes</Typography.Paragraph>
|
||||
</Button>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
|
||||
<div
|
||||
className={classNames({
|
||||
'time-series-container': changeLayoutForOneChartPerQuery,
|
||||
})}
|
||||
>
|
||||
{responseData.map((datapoint, index) => {
|
||||
const isQueryDataItem = index < metricNames.length;
|
||||
const metricName = isQueryDataItem ? metricNames[index] : undefined;
|
||||
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
|
||||
|
||||
// Show the no unit warning if -
|
||||
// 1. The metric query is not loading
|
||||
// 2. The metric units are not loading
|
||||
// 3. There are more than one metric
|
||||
// 4. The current metric unit is empty
|
||||
// 5. Is a queryData item
|
||||
const isMetricUnitEmpty =
|
||||
isQueryDataItem &&
|
||||
!queries[index].isLoading &&
|
||||
!isMetricUnitsLoading &&
|
||||
metricUnits.length > 1 &&
|
||||
!metricUnit &&
|
||||
metricName;
|
||||
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="time-series-view"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
{isMetricUnitEmpty && metricName && (
|
||||
<Tooltip
|
||||
className="no-unit-warning"
|
||||
title={
|
||||
<Typography.Text>
|
||||
This metric does not have a unit. Please set one for it in the{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => handleOpenMetricDetails(metricName)}
|
||||
>
|
||||
metric details
|
||||
</Typography.Link>{' '}
|
||||
page.
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading || isMetricUnitsLoading}
|
||||
data={datapoint}
|
||||
yAxisUnit={currentYAxisUnit}
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
yAxisUnit={yAxisUnit}
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import * as useOptionsMenuHooks from 'container/OptionsMenu';
|
||||
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
|
||||
@@ -14,18 +12,13 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import store from 'store';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
import * as useGetMetricsHooks from '../utils';
|
||||
|
||||
const mockSetSearchParams = jest.fn();
|
||||
const queryClient = new QueryClient();
|
||||
const mockUpdateAllQueriesOperators = jest
|
||||
.fn()
|
||||
.mockReturnValue(initialQueriesMap[DataSource.METRICS]);
|
||||
const mockUpdateAllQueriesOperators = jest.fn();
|
||||
const mockUseQueryBuilderData = {
|
||||
handleRunQuery: jest.fn(),
|
||||
stagedQuery: initialQueriesMap[DataSource.METRICS],
|
||||
@@ -133,30 +126,6 @@ jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
...mockUseQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
|
||||
|
||||
const mockMetric: MetricMetadata = {
|
||||
type: MetricType.SUM,
|
||||
description: 'metric1 description',
|
||||
unit: 'metric1 unit',
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
isMonotonic: true,
|
||||
};
|
||||
|
||||
function renderExplorer(): void {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Explorer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -173,7 +142,17 @@ describe('Explorer', () => {
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
renderExplorer();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
@@ -187,13 +166,18 @@ describe('Explorer', () => {
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toBeChecked();
|
||||
@@ -204,132 +188,20 @@ describe('Explorer', () => {
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should not render y axis unit selector for single metric which has a unit', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render y axis unit selector for mutliple metrics with same unit', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValueOnce([
|
||||
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide y axis unit selector for multiple metrics with different units', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
|
||||
expect(yAxisUnitSelector).not.toBeInTheDocument();
|
||||
|
||||
// One chart per query toggle should be disabled
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
expect(oneChartPerQueryToggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render empty y axis unit selector for a single metric with no unit', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [
|
||||
{
|
||||
type: MetricType.SUM,
|
||||
description: 'metric1 description',
|
||||
unit: '',
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
isMonotonic: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
|
||||
expect(yAxisUnitSelector).toBeInTheDocument();
|
||||
expect(yAxisUnitSelector).toHaveTextContent('Please select a unit');
|
||||
});
|
||||
|
||||
it('one chart per query should be off and disabled when there is only one query', () => {
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
expect(oneChartPerQueryToggle).not.toBeChecked();
|
||||
expect(oneChartPerQueryToggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it('one chart per query should enabled by default when there are multiple metrics with the same unit', () => {
|
||||
const mockQueryData = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'metric1',
|
||||
},
|
||||
};
|
||||
const mockStagedQueryWithMultipleQueries = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [mockQueryData, mockQueryData],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
|
||||
...mockUseQueryBuilderData,
|
||||
stagedQuery: mockStagedQueryWithMultipleQueries,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [mockMetric, mockMetric],
|
||||
});
|
||||
|
||||
renderExplorer();
|
||||
|
||||
const oneChartPerQueryToggle = screen.getByRole('switch');
|
||||
expect(oneChartPerQueryToggle).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataResponse } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { UseUpdateMetricMetadataProps } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { UseMutationResult } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
|
||||
import TimeSeries from '../TimeSeries';
|
||||
import { TimeSeriesProps } from '../types';
|
||||
|
||||
type MockUpdateMetricMetadata = UseMutationResult<
|
||||
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
|
||||
Error,
|
||||
UseUpdateMetricMetadataProps
|
||||
>;
|
||||
const mockUpdateMetricMetadata = jest.fn();
|
||||
jest
|
||||
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
|
||||
.mockReturnValue(({
|
||||
mutate: mockUpdateMetricMetadata,
|
||||
isLoading: false,
|
||||
} as Partial<MockUpdateMetricMetadata>) as MockUpdateMetricMetadata);
|
||||
|
||||
jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue(
|
||||
<div role="img" aria-label="warning">
|
||||
TimeSeriesView
|
||||
</div>,
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: jest.fn().mockReturnValue({
|
||||
invalidateQueries: jest.fn(),
|
||||
}),
|
||||
useQueries: jest.fn().mockImplementation((queries: any[]) =>
|
||||
queries.map(() => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
})),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({
|
||||
globalTime: {
|
||||
selectedTime: '5min',
|
||||
maxTime: 1713738000000,
|
||||
minTime: 1713734400000,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockMetric: MetricMetadata = {
|
||||
type: MetricType.SUM,
|
||||
description: 'metric1 description',
|
||||
unit: 'metric1 unit',
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
isMonotonic: true,
|
||||
};
|
||||
|
||||
const mockSetWarning = jest.fn();
|
||||
const mockSetIsMetricDetailsOpen = jest.fn();
|
||||
const mockSetYAxisUnit = jest.fn();
|
||||
|
||||
function renderTimeSeries(
|
||||
overrides: Partial<TimeSeriesProps> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<TimeSeries
|
||||
showOneChartPerQuery={false}
|
||||
setWarning={mockSetWarning}
|
||||
areAllMetricUnitsSame={false}
|
||||
isMetricUnitsLoading={false}
|
||||
metricUnits={[]}
|
||||
metricNames={[]}
|
||||
metrics={[]}
|
||||
isMetricUnitsError={false}
|
||||
handleOpenMetricDetails={mockSetIsMetricDetailsOpen}
|
||||
yAxisUnit="count"
|
||||
setYAxisUnit={mockSetYAxisUnit}
|
||||
showYAxisUnitSelector={false}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('TimeSeries', () => {
|
||||
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [undefined, undefined],
|
||||
});
|
||||
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.findByText('This metric does not have a unit'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking on warning icon tooltip should open metric details modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTimeSeries({
|
||||
metricUnits: ['', 'count'],
|
||||
metricNames: ['metric1', 'metric2'],
|
||||
metrics: [mockMetric, mockMetric],
|
||||
yAxisUnit: 'seconds',
|
||||
});
|
||||
|
||||
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
|
||||
user.hover(alertIcon);
|
||||
|
||||
const metricDetailsLink = await screen.findByText('metric details');
|
||||
user.click(metricDetailsLink);
|
||||
|
||||
waitFor(() =>
|
||||
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: Unskip this test once the save unit button is implemented
|
||||
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
|
||||
it.skip('shows Save unit button when metric had no unit but one is selected', () => {
|
||||
const { findByText, getByRole } = renderTimeSeries({
|
||||
metricUnits: [undefined],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [mockMetric],
|
||||
yAxisUnit: 'seconds',
|
||||
});
|
||||
|
||||
expect(
|
||||
findByText('Save the selected unit for this metric?'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const yesButton = getByRole('button', { name: 'Yes' });
|
||||
expect(yesButton).toBeInTheDocument();
|
||||
expect(yesButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// TODO: Unskip this test once the save unit button is implemented
|
||||
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
|
||||
it.skip('clicking on save unit button shoould upated metric metadata', () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderTimeSeries({
|
||||
metricUnits: [''],
|
||||
metricNames: ['metric1'],
|
||||
metrics: [mockMetric],
|
||||
yAxisUnit: 'seconds',
|
||||
});
|
||||
|
||||
const yesButton = getByRole('button', { name: /Yes/i });
|
||||
user.click(yesButton);
|
||||
|
||||
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
{
|
||||
metricName: 'metric1',
|
||||
payload: expect.objectContaining({ unit: 'seconds' }),
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as useGetMultipleMetricsHook from 'hooks/metricsExplorer/useGetMultipleMetrics';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
MetricMetadata,
|
||||
MetricMetadataResponse,
|
||||
} from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
getMetricUnits,
|
||||
splitQueryIntoOneChartPerQuery,
|
||||
useGetMetrics,
|
||||
} from '../utils';
|
||||
|
||||
const MOCK_QUERY_DATA_1: IBuilderQuery = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'metric1',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_QUERY_DATA_2: IBuilderQuery = {
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
aggregateAttribute: {
|
||||
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
|
||||
.aggregateAttribute as BaseAutocompleteData),
|
||||
key: 'metric2',
|
||||
},
|
||||
};
|
||||
const MOCK_FORMULA_DATA: IBuilderFormula = {
|
||||
expression: '1 + 1',
|
||||
disabled: false,
|
||||
queryName: 'Mock Formula',
|
||||
legend: 'Mock Legend',
|
||||
};
|
||||
|
||||
const MOCK_QUERY_WITH_MULTIPLE_QUERY_DATA: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [MOCK_QUERY_DATA_1, MOCK_QUERY_DATA_2],
|
||||
queryFormulas: [MOCK_FORMULA_DATA, MOCK_FORMULA_DATA],
|
||||
},
|
||||
};
|
||||
|
||||
describe('splitQueryIntoOneChartPerQuery', () => {
|
||||
it('should split a query with multiple queryData to multiple distinct queries, each with a single queryData', () => {
|
||||
const result = splitQueryIntoOneChartPerQuery(
|
||||
MOCK_QUERY_WITH_MULTIPLE_QUERY_DATA,
|
||||
['metric1', 'metric2'],
|
||||
[undefined, 'unit2'],
|
||||
);
|
||||
expect(result).toHaveLength(4);
|
||||
// Verify query 1 has the correct data
|
||||
expect(result[0].builder.queryData).toHaveLength(1);
|
||||
expect(result[0].builder.queryData[0]).toEqual(MOCK_QUERY_DATA_1);
|
||||
expect(result[0].builder.queryFormulas).toHaveLength(0);
|
||||
expect(result[0].unit).toBeUndefined();
|
||||
// Verify query 2 has the correct data
|
||||
expect(result[1].builder.queryData).toHaveLength(1);
|
||||
expect(result[1].builder.queryData[0]).toEqual(MOCK_QUERY_DATA_2);
|
||||
expect(result[1].builder.queryFormulas).toHaveLength(0);
|
||||
expect(result[1].unit).toBe('unit2');
|
||||
// Verify query 3 has the correct data
|
||||
expect(result[2].builder.queryFormulas).toHaveLength(1);
|
||||
expect(result[2].builder.queryFormulas[0]).toEqual(MOCK_FORMULA_DATA);
|
||||
expect(result[2].builder.queryData).toHaveLength(2); // 2 disabled queries
|
||||
expect(result[2].builder.queryData[0].disabled).toBe(true);
|
||||
expect(result[2].builder.queryData[1].disabled).toBe(true);
|
||||
expect(result[2].unit).toBeUndefined();
|
||||
// Verify query 4 has the correct data
|
||||
expect(result[3].builder.queryFormulas).toHaveLength(1);
|
||||
expect(result[3].builder.queryFormulas[0]).toEqual(MOCK_FORMULA_DATA);
|
||||
expect(result[3].builder.queryData).toHaveLength(2); // 2 disabled queries
|
||||
expect(result[3].builder.queryData[0].disabled).toBe(true);
|
||||
expect(result[3].builder.queryData[1].disabled).toBe(true);
|
||||
expect(result[3].unit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const MOCK_METRIC_METADATA: MetricMetadata = {
|
||||
description: 'Metric 1 description',
|
||||
unit: 'unit1',
|
||||
type: MetricType.GAUGE,
|
||||
temporality: Temporality.DELTA,
|
||||
isMonotonic: true,
|
||||
};
|
||||
|
||||
describe('useGetMetrics', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
|
||||
.mockReturnValue([
|
||||
({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
httpStatusCode: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
data: MOCK_METRIC_METADATA,
|
||||
},
|
||||
},
|
||||
} as Partial<
|
||||
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
|
||||
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return the correct metrics data', () => {
|
||||
const { result } = renderHook(() => useGetMetrics(['metric1']));
|
||||
expect(result.current.metrics).toHaveLength(1);
|
||||
expect(result.current.metrics[0]).toBeDefined();
|
||||
expect(result.current.metrics[0]).toEqual(MOCK_METRIC_METADATA);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('should return array of undefined values of correct length when metrics data is not yet loaded', () => {
|
||||
jest
|
||||
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
|
||||
.mockReturnValue([
|
||||
({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
} as Partial<
|
||||
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
|
||||
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
|
||||
]);
|
||||
const { result } = renderHook(() => useGetMetrics(['metric1']));
|
||||
expect(result.current.metrics).toHaveLength(1);
|
||||
expect(result.current.metrics[0]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetricUnits', () => {
|
||||
it('should return the same unit for units that are not known to the universal unit mapper', () => {
|
||||
const result = getMetricUnits([MOCK_METRIC_METADATA]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(MOCK_METRIC_METADATA.unit);
|
||||
});
|
||||
|
||||
it('should return universal unit for units that are known to the universal unit mapper', () => {
|
||||
const result = getMetricUnits([{ ...MOCK_METRIC_METADATA, unit: 'seconds' }]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('s');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
|
||||
export enum ExplorerTabs {
|
||||
TIME_SERIES = 'time-series',
|
||||
@@ -13,16 +12,6 @@ export enum ExplorerTabs {
|
||||
export interface TimeSeriesProps {
|
||||
showOneChartPerQuery: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
areAllMetricUnitsSame: boolean;
|
||||
isMetricUnitsLoading: boolean;
|
||||
isMetricUnitsError: boolean;
|
||||
metricUnits: (string | undefined)[];
|
||||
metricNames: string[];
|
||||
metrics: (MetricMetadata | undefined)[];
|
||||
handleOpenMetricDetails: (metricName: string) => void;
|
||||
yAxisUnit: string | undefined;
|
||||
setYAxisUnit: (unit: string) => void;
|
||||
showYAxisUnitSelector: boolean;
|
||||
}
|
||||
|
||||
export interface RelatedMetricsProps {
|
||||
|
||||
@@ -1,40 +1,20 @@
|
||||
import { mapMetricUnitToUniversalUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { useGetMultipleMetrics } from 'hooks/metricsExplorer/useGetMultipleMetrics';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/**
|
||||
* Split a query with multiple queryData to multiple distinct queries, each with a single queryData.
|
||||
* @param query - The query to split
|
||||
* @param units - The units of the metrics, can be undefined if the metric has no unit
|
||||
* @returns The split queries
|
||||
*/
|
||||
export const splitQueryIntoOneChartPerQuery = (
|
||||
query: Query,
|
||||
metricNames: string[],
|
||||
units: (string | undefined)[],
|
||||
): Query[] => {
|
||||
export const splitQueryIntoOneChartPerQuery = (query: Query): Query[] => {
|
||||
const queries: Query[] = [];
|
||||
|
||||
query.builder.queryData.forEach((currentQuery) => {
|
||||
if (currentQuery.aggregateAttribute?.key) {
|
||||
const metricIndex = metricNames.indexOf(
|
||||
currentQuery.aggregateAttribute?.key,
|
||||
);
|
||||
const unit = metricIndex >= 0 ? units[metricIndex] : undefined;
|
||||
const newQuery = {
|
||||
...query,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [currentQuery],
|
||||
queryFormulas: [],
|
||||
},
|
||||
unit,
|
||||
};
|
||||
queries.push(newQuery);
|
||||
}
|
||||
const newQuery = {
|
||||
...query,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [currentQuery],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
queries.push(newQuery);
|
||||
});
|
||||
|
||||
query.builder.queryFormulas.forEach((currentFormula) => {
|
||||
@@ -55,43 +35,3 @@ export const splitQueryIntoOneChartPerQuery = (
|
||||
|
||||
return queries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get data for multiple metrics with a synchronous loading and error state
|
||||
* @param metricNames - The names of the metrics to get
|
||||
* @param isEnabled - Whether the hook is enabled
|
||||
* @returns The loading state, the metrics data, and the error state
|
||||
*/
|
||||
export function useGetMetrics(
|
||||
metricNames: string[],
|
||||
isEnabled = true,
|
||||
): {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
metrics: (MetricMetadata | undefined)[];
|
||||
} {
|
||||
const metricsData = useGetMultipleMetrics(metricNames, {
|
||||
enabled: metricNames.length > 0 && isEnabled,
|
||||
});
|
||||
return {
|
||||
isLoading: metricsData.some((metric) => metric.isLoading),
|
||||
metrics: metricsData
|
||||
.map((metric) => metric.data?.data)
|
||||
.map((data) => data?.data),
|
||||
isError: metricsData.some((metric) => metric.isError),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* To get the units of the metrics in the universal unit standard.
|
||||
* If the unit is not known to the universal unit mapper, it will return the unit as is.
|
||||
* @param metrics - The metrics to get the units for
|
||||
* @returns The units of the metrics, can be undefined if the metric has no unit
|
||||
*/
|
||||
export function getMetricUnits(
|
||||
metrics: (MetricMetadata | undefined)[],
|
||||
): (string | undefined)[] {
|
||||
return metrics
|
||||
.map((metric) => metric?.unit)
|
||||
.map((unit) => mapMetricUnitToUniversalUnit(unit) || undefined);
|
||||
}
|
||||
|
||||
@@ -131,8 +131,8 @@ function MetricDetails({
|
||||
>
|
||||
Open in Explorer
|
||||
</Button>
|
||||
{/* Show the inspect button if the metric type is GAUGE */}
|
||||
{showInspectFeature && openInspectModal && (
|
||||
{/* Show the based on the feature flag. Will remove before releasing the feature */}
|
||||
{showInspectFeature && (
|
||||
<Button
|
||||
className="inspect-metrics-button"
|
||||
aria-label="Inspect Metric"
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface MetricDetailsProps {
|
||||
isOpen: boolean;
|
||||
metricName: string | null;
|
||||
isModalTimeSelection: boolean;
|
||||
openInspectModal?: (metricName: string) => void;
|
||||
openInspectModal: (metricName: string) => void;
|
||||
}
|
||||
|
||||
export interface DashboardsAndAlertsPopoverProps {
|
||||
|
||||
@@ -370,6 +370,10 @@ function NewWidget({
|
||||
// this has been moved here from the left container
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||
const updatedQuery = cloneDeep(stagedQuery || initialQueriesMap.metrics);
|
||||
if (updatedQuery?.builder?.queryData?.[0]) {
|
||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||
}
|
||||
|
||||
if (selectedWidget) {
|
||||
if (selectedGraph === PANEL_TYPES.LIST) {
|
||||
return {
|
||||
@@ -415,12 +419,16 @@ function NewWidget({
|
||||
useEffect(() => {
|
||||
if (stagedQuery) {
|
||||
setIsLoadingPanelData(false);
|
||||
const updatedStagedQuery = cloneDeep(stagedQuery);
|
||||
if (updatedStagedQuery?.builder?.queryData?.[0]) {
|
||||
updatedStagedQuery.builder.queryData[0].pageSize = 10;
|
||||
}
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
selectedTime: selectedTime.enum || prev.selectedTime,
|
||||
globalSelectedInterval: customGlobalSelectedInterval,
|
||||
graphType: getGraphType(selectedGraph || selectedWidget.panelTypes),
|
||||
query: stagedQuery,
|
||||
query: updatedStagedQuery,
|
||||
fillGaps: selectedWidget.fillSpans || false,
|
||||
isLogScale: selectedWidget.isLogScale || false,
|
||||
formatForWeb:
|
||||
|
||||
@@ -132,20 +132,11 @@ function UplotPanelWrapper({
|
||||
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
getUPlotChartData(
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
),
|
||||
[
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
],
|
||||
const chartData = getUPlotChartData(
|
||||
queryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
stackedBarChart,
|
||||
hiddenGraph,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -302,7 +293,7 @@ function UplotPanelWrapper({
|
||||
)}
|
||||
{isFullViewMode && setGraphVisibility && !stackedBarChart && (
|
||||
<GraphManager
|
||||
data={chartData}
|
||||
data={getUPlotChartData(queryResponse?.data?.payload, widget.fillSpans)}
|
||||
name={widget.id}
|
||||
options={options}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
|
||||
@@ -206,10 +206,6 @@
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Select } from 'antd';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
|
||||
interface SpaceAggregationOptionsProps {
|
||||
panelType: PANEL_TYPES | null;
|
||||
@@ -20,13 +22,39 @@ export default function SpaceAggregationOptions({
|
||||
operators,
|
||||
qbVersion,
|
||||
}: SpaceAggregationOptionsProps): JSX.Element {
|
||||
const placeHolderText =
|
||||
panelType === PANEL_TYPES.VALUE || qbVersion === 'v3' ? 'Sum' : 'Sum By';
|
||||
const [defaultValue, setDefaultValue] = useState(
|
||||
selectedValue || placeHolderText,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValue) {
|
||||
if (
|
||||
aggregatorAttributeType === ATTRIBUTE_TYPES.HISTOGRAM ||
|
||||
aggregatorAttributeType === ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM
|
||||
) {
|
||||
setDefaultValue(MetricAggregateOperator.P90);
|
||||
onSelect(MetricAggregateOperator.P90);
|
||||
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.SUM) {
|
||||
setDefaultValue(MetricAggregateOperator.SUM);
|
||||
onSelect(MetricAggregateOperator.SUM);
|
||||
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.GAUGE) {
|
||||
setDefaultValue(MetricAggregateOperator.AVG);
|
||||
onSelect(MetricAggregateOperator.AVG);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [aggregatorAttributeType]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="spaceAggregationOptionsContainer"
|
||||
key={aggregatorAttributeType}
|
||||
>
|
||||
<Select
|
||||
defaultValue={selectedValue}
|
||||
defaultValue={defaultValue}
|
||||
style={{ minWidth: '5.625rem' }}
|
||||
disabled={disabled}
|
||||
onChange={onSelect}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
.selectOptionContainer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.2rem;
|
||||
height: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.option-renderer-tooltip {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import './OptionRenderer.styles.scss';
|
||||
import './QueryBuilderSearch.styles.scss';
|
||||
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
@@ -13,11 +13,7 @@ function OptionRenderer({
|
||||
return (
|
||||
<span className="option">
|
||||
{type ? (
|
||||
<Tooltip
|
||||
title={`${value}`}
|
||||
placement="topLeft"
|
||||
rootClassName="option-renderer-tooltip"
|
||||
>
|
||||
<Tooltip title={`${value}`} placement="topLeft">
|
||||
<div className="selectOptionContainer">
|
||||
<div className="option-value">{value}</div>
|
||||
<div className="option-meta-data-container">
|
||||
@@ -33,11 +29,7 @@ function OptionRenderer({
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={label}
|
||||
placement="topLeft"
|
||||
rootClassName="option-renderer-tooltip"
|
||||
>
|
||||
<Tooltip title={label} placement="topLeft">
|
||||
<span>{label}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selectOptionContainer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.2rem;
|
||||
height: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-popup {
|
||||
&.hide-scroll {
|
||||
.rc-virtual-list-holder {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ReduceToFilter } from './ReduceToFilter';
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
function baseQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
|
||||
return {
|
||||
dataSource: 'traces',
|
||||
aggregations: [],
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
having: { expression: '' },
|
||||
...overrides,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
describe('ReduceToFilter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with default avg when no reduceTo is set', () => {
|
||||
render(<ReduceToFilter query={baseQuery()} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId('reduce-to')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Average of values in timeframe'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes from query.aggregations[0].reduceTo', () => {
|
||||
render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregations: [{ reduceTo: 'sum' } as any],
|
||||
aggregateAttribute: { key: 'test', type: MetricType.SUM },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sum of values in timeframe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes from query.reduceTo when aggregations[0].reduceTo is not set', () => {
|
||||
render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
reduceTo: 'max',
|
||||
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Max of values in timeframe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates to sum when aggregateAttribute.type is SUM', async () => {
|
||||
const { rerender } = render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregateAttribute: { key: 'test2', type: MetricType.SUM },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const reduceToFilterText = (await screen.findByText(
|
||||
'Sum of values in timeframe',
|
||||
)) as HTMLElement;
|
||||
expect(reduceToFilterText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Select } from 'antd';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { REDUCE_TO_VALUES } from 'constants/queryBuilder';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
// ** Types
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -13,46 +12,19 @@ export const ReduceToFilter = memo(function ReduceToFilter({
|
||||
query,
|
||||
onChange,
|
||||
}: ReduceToFilterProps): JSX.Element {
|
||||
const isMounted = useRef<boolean>(false);
|
||||
const [currentValue, setCurrentValue] = useState<
|
||||
SelectOption<ReduceOperators, string>
|
||||
>(REDUCE_TO_VALUES[2]); // default to avg
|
||||
const reduceToValue =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
|
||||
|
||||
const currentValue =
|
||||
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
|
||||
REDUCE_TO_VALUES[0];
|
||||
|
||||
const handleChange = (
|
||||
newValue: SelectOption<ReduceOperators, string>,
|
||||
): void => {
|
||||
setCurrentValue(newValue);
|
||||
onChange(newValue.value);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!isMounted.current) {
|
||||
const reduceToValue =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
|
||||
|
||||
setCurrentValue(
|
||||
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
|
||||
REDUCE_TO_VALUES[2],
|
||||
);
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const aggregationAttributeType = query.aggregateAttribute?.type as
|
||||
| MetricType
|
||||
| undefined;
|
||||
|
||||
if (aggregationAttributeType === MetricType.SUM) {
|
||||
handleChange(REDUCE_TO_VALUES[1]);
|
||||
} else {
|
||||
handleChange(REDUCE_TO_VALUES[2]);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[query.aggregateAttribute?.key],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder="Reduce to"
|
||||
|
||||
@@ -363,6 +363,7 @@ export const WidgetHeaderProps: any = {
|
||||
title: 'Table - Panel',
|
||||
yAxisUnit: 'none',
|
||||
},
|
||||
parentHover: false,
|
||||
queryResponse: {
|
||||
status: 'success',
|
||||
isLoading: false,
|
||||
|
||||
@@ -679,42 +679,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToExceptions, () =>
|
||||
onClickHandler(ROUTES.ALL_ERROR, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToTracesFunnel, () =>
|
||||
onClickHandler(ROUTES.TRACES_FUNNELS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToTracesViews, () =>
|
||||
onClickHandler(ROUTES.TRACES_SAVE_VIEWS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsSummary, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsExplorer, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER_EXPLORER, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsViews, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER_VIEWS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettings, () =>
|
||||
onClickHandler(ROUTES.SETTINGS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsIngestion, () =>
|
||||
onClickHandler(ROUTES.INGESTION_SETTINGS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
|
||||
onClickHandler(ROUTES.BILLING, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys, () =>
|
||||
onClickHandler(ROUTES.API_KEYS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
|
||||
onClickHandler(ROUTES.ALL_CHANNELS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToLogsPipelines, () =>
|
||||
onClickHandler(ROUTES.LOGS_PIPELINES, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToLogsViews, () =>
|
||||
onClickHandler(ROUTES.LOGS_SAVE_VIEWS, null),
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToHome);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToServices);
|
||||
@@ -724,18 +689,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToAlerts);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToExceptions);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMessagingQueues);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToTracesFunnel);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsSummary);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsExplorer);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsViews);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToTracesViews);
|
||||
};
|
||||
}, [deregisterShortcut, onClickHandler, registerShortcut]);
|
||||
|
||||
|
||||
@@ -5,20 +5,16 @@
|
||||
&-virtuoso {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
&-list-container {
|
||||
&-list-container .logs-loading-skeleton {
|
||||
height: 100%;
|
||||
|
||||
.logs-loading-skeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-top: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-top: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
&-empty-content {
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import {
|
||||
KeyboardHotkeysProvider,
|
||||
useKeyboardHotkeys,
|
||||
} from '../useKeyboardHotkeys';
|
||||
|
||||
jest.mock('../../../providers/cmdKProvider', () => ({
|
||||
useCmdK: (): { open: boolean } => ({
|
||||
open: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function TestComponentWithRegister({
|
||||
handleShortcut,
|
||||
}: {
|
||||
@@ -20,13 +13,14 @@ function TestComponentWithRegister({
|
||||
}): JSX.Element {
|
||||
const { registerShortcut } = useKeyboardHotkeys();
|
||||
|
||||
useEffect(() => {
|
||||
registerShortcut('a', handleShortcut);
|
||||
}, [registerShortcut, handleShortcut]);
|
||||
registerShortcut('a', handleShortcut);
|
||||
|
||||
return <span>Test Component</span>;
|
||||
return (
|
||||
<div>
|
||||
<span>Test Component</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestComponentWithDeRegister({
|
||||
handleShortcut,
|
||||
}: {
|
||||
@@ -34,18 +28,21 @@ function TestComponentWithDeRegister({
|
||||
}): JSX.Element {
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
useEffect(() => {
|
||||
registerShortcut('b', handleShortcut);
|
||||
deregisterShortcut('b');
|
||||
}, [registerShortcut, deregisterShortcut, handleShortcut]);
|
||||
registerShortcut('b', handleShortcut);
|
||||
|
||||
return <span>Test Component</span>;
|
||||
// Deregister the shortcut before triggering it
|
||||
deregisterShortcut('b');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>Test Component</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('KeyboardHotkeysProvider', () => {
|
||||
it('registers and triggers shortcuts correctly', async () => {
|
||||
const handleShortcut = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<KeyboardHotkeysProvider>
|
||||
@@ -53,15 +50,15 @@ describe('KeyboardHotkeysProvider', () => {
|
||||
</KeyboardHotkeysProvider>,
|
||||
);
|
||||
|
||||
// fires on keyup
|
||||
await user.keyboard('{a}');
|
||||
// Trigger the registered shortcut
|
||||
await userEvent.keyboard('a');
|
||||
|
||||
expect(handleShortcut).toHaveBeenCalledTimes(1);
|
||||
// Assert that the handleShortcut function has been called
|
||||
expect(handleShortcut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not trigger deregistered shortcuts', async () => {
|
||||
it('deregisters shortcuts correctly', () => {
|
||||
const handleShortcut = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<KeyboardHotkeysProvider>
|
||||
@@ -69,8 +66,10 @@ describe('KeyboardHotkeysProvider', () => {
|
||||
</KeyboardHotkeysProvider>,
|
||||
);
|
||||
|
||||
await user.keyboard('{b}');
|
||||
// Try to trigger the deregistered shortcut
|
||||
userEvent.keyboard('b');
|
||||
|
||||
// Assert that the handleShortcut function has NOT been called
|
||||
expect(handleShortcut).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,21 +8,20 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
interface KeyboardHotkeysContextReturnValue {
|
||||
/**
|
||||
* @param keyCombo provide the string for which the subsequent callback should be triggered. Example 'ctrl+a'
|
||||
* @param keyCombination provide the string for which the subsequent callback should be triggered. Example 'ctrl+a'
|
||||
* @param callback the callback that should be triggered when the above key combination is being pressed
|
||||
* @returns void
|
||||
*/
|
||||
registerShortcut: (keyCombo: string, callback: () => void) => void;
|
||||
registerShortcut: (keyCombination: string, callback: () => void) => void;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param keyCombo provide the string for which we want to deregister the callback
|
||||
* @param keyCombination provide the string for which we want to deregister the callback
|
||||
* @returns void
|
||||
*/
|
||||
deregisterShortcut: (keyCombo: string) => void;
|
||||
deregisterShortcut: (keyCombination: string) => void;
|
||||
}
|
||||
|
||||
const KeyboardHotkeysContext = createContext<KeyboardHotkeysContextReturnValue>(
|
||||
@@ -34,7 +33,7 @@ const KeyboardHotkeysContext = createContext<KeyboardHotkeysContextReturnValue>(
|
||||
|
||||
const IGNORE_INPUTS = ['input', 'textarea', 'cm-editor']; // Inputs in which hotkey events will be ignored
|
||||
|
||||
export function useKeyboardHotkeys(): KeyboardHotkeysContextReturnValue {
|
||||
const useKeyboardHotkeys = (): KeyboardHotkeysContextReturnValue => {
|
||||
const context = useContext(KeyboardHotkeysContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
@@ -43,45 +42,21 @@ export function useKeyboardHotkeys(): KeyboardHotkeysContextReturnValue {
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a set of keys into a stable combo
|
||||
* { shift, m, e } → "e+m+shift"
|
||||
*/
|
||||
function normalizeChord(keys: Set<string>): string {
|
||||
return Array.from(keys).sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize registration strings
|
||||
* "shift+m+e" → "e+m+shift"
|
||||
*/
|
||||
function normalizeComboString(combo: string): string {
|
||||
return normalizeChord(new Set(combo.split('+')));
|
||||
}
|
||||
|
||||
export function KeyboardHotkeysProvider({
|
||||
function KeyboardHotkeysProvider({
|
||||
children,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
}): JSX.Element {
|
||||
const { open: cmdKOpen } = useCmdK();
|
||||
const shortcuts = useRef<Record<string, () => void>>({});
|
||||
const pressedKeys = useRef<Set<string>>(new Set());
|
||||
|
||||
// A detected valid shortcut waiting to fire
|
||||
const pendingCombo = useRef<string | null>(null);
|
||||
const handleKeyPress = (event: KeyboardEvent): void => {
|
||||
const { key, ctrlKey, altKey, shiftKey, metaKey, target } = event;
|
||||
|
||||
// Tracks whether user extended the combo
|
||||
const wasExtended = useRef(false);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.repeat) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const isCodeMirrorEditor =
|
||||
(target as HTMLElement).closest('.cm-editor') !== null;
|
||||
|
||||
if (
|
||||
IGNORE_INPUTS.includes((target as HTMLElement).tagName.toLowerCase()) ||
|
||||
isCodeMirrorEditor
|
||||
@@ -89,110 +64,61 @@ export function KeyboardHotkeysProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key?.toLowerCase();
|
||||
if (!key) return; // Skip if key is undefined
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
|
||||
const modifiers = { ctrlKey, altKey, shiftKey, metaKey };
|
||||
|
||||
// If a pending combo exists and a new key is pressed → extension
|
||||
if (pendingCombo.current && !pressedKeys.current.has(key)) {
|
||||
wasExtended.current = true;
|
||||
}
|
||||
let shortcutKey = `${key.toLowerCase()}`;
|
||||
|
||||
pressedKeys.current.add(key);
|
||||
const isAltKey = `${modifiers.altKey ? '+alt' : ''}`;
|
||||
const isShiftKey = `${modifiers.shiftKey ? '+shift' : ''}`;
|
||||
|
||||
if (event.shiftKey) pressedKeys.current.add('shift');
|
||||
if (event.metaKey || event.ctrlKey) pressedKeys.current.add('meta');
|
||||
if (event.altKey) pressedKeys.current.add('alt');
|
||||
// ctrl and cmd have the same functionality for mac and windows parity
|
||||
const isMetaKey = `${modifiers.metaKey || modifiers.ctrlKey ? '+meta' : ''}`;
|
||||
|
||||
const combo = normalizeChord(pressedKeys.current);
|
||||
shortcutKey = shortcutKey + isAltKey + isShiftKey + isMetaKey;
|
||||
|
||||
if (shortcuts.current[combo]) {
|
||||
if (shortcuts.current[shortcutKey]) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pendingCombo.current = combo;
|
||||
wasExtended.current = false;
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
shortcuts.current[shortcutKey]();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||
const key = event.key?.toLowerCase();
|
||||
if (!key) return; // Skip if key is undefined
|
||||
|
||||
pressedKeys.current.delete(key);
|
||||
|
||||
if (!event.shiftKey) pressedKeys.current.delete('shift');
|
||||
if (!event.metaKey && !event.ctrlKey) pressedKeys.current.delete('meta');
|
||||
if (!event.altKey) pressedKeys.current.delete('alt');
|
||||
|
||||
if (!pendingCombo.current) return;
|
||||
|
||||
// Fire only if user did NOT extend the combo
|
||||
if (!wasExtended.current) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
shortcuts.current[pendingCombo.current]?.();
|
||||
} catch (error) {
|
||||
console.error('Error executing hotkey callback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
pendingCombo.current = null;
|
||||
wasExtended.current = false;
|
||||
};
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
const reset = (): void => {
|
||||
pressedKeys.current.clear();
|
||||
pendingCombo.current = null;
|
||||
wasExtended.current = false;
|
||||
};
|
||||
|
||||
window.addEventListener('blur', reset);
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', reset);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cmdKOpen) {
|
||||
// Reset when palette closes
|
||||
pressedKeys.current.clear();
|
||||
pendingCombo.current = null;
|
||||
wasExtended.current = false;
|
||||
}
|
||||
}, [cmdKOpen]);
|
||||
|
||||
const registerShortcut = useCallback(
|
||||
(keyCombo: string, callback: () => void): void => {
|
||||
const normalized = normalizeComboString(keyCombo);
|
||||
|
||||
if (!shortcuts.current[normalized]) {
|
||||
shortcuts.current[normalized] = callback;
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `This shortcut is already present in current scope :- ${keyCombo}`;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
throw new Error(message);
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deregisterShortcut = useCallback((keyCombo: string) => {
|
||||
const normalized = normalizeComboString(keyCombo);
|
||||
unset(shortcuts.current, normalized);
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ctxValue = useMemo(
|
||||
const registerShortcut = useCallback(
|
||||
(keyCombination: string, callback: () => void): void => {
|
||||
if (!shortcuts.current[keyCombination]) {
|
||||
shortcuts.current[keyCombination] = callback;
|
||||
} else if (process.env.NODE_ENV === 'development') {
|
||||
throw new Error(
|
||||
`This shortcut is already present in current scope :- ${keyCombination}`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`This shortcut is already present in current scope :- ${keyCombination}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[shortcuts],
|
||||
);
|
||||
|
||||
const deregisterShortcut = useCallback(
|
||||
(keyCombination: string): void => {
|
||||
if (shortcuts.current[keyCombination]) {
|
||||
unset(shortcuts.current, keyCombination);
|
||||
}
|
||||
},
|
||||
[shortcuts],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
registerShortcut,
|
||||
deregisterShortcut,
|
||||
@@ -201,8 +127,10 @@ export function KeyboardHotkeysProvider({
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardHotkeysContext.Provider value={ctxValue}>
|
||||
<KeyboardHotkeysContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</KeyboardHotkeysContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { KeyboardHotkeysProvider, useKeyboardHotkeys };
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { getMetricMetadata } from 'api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
|
||||
type QueryResult = UseQueryResult<
|
||||
SuccessResponseV2<MetricMetadataResponse>,
|
||||
Error
|
||||
>;
|
||||
|
||||
type UseGetMultipleMetrics = (
|
||||
metricNames: string[],
|
||||
options?: UseQueryOptions<SuccessResponseV2<MetricMetadataResponse>, Error>,
|
||||
headers?: Record<string, string>,
|
||||
) => QueryResult[];
|
||||
|
||||
export const useGetMultipleMetrics: UseGetMultipleMetrics = (
|
||||
metricNames,
|
||||
options,
|
||||
headers,
|
||||
) =>
|
||||
useQueries(
|
||||
metricNames.map(
|
||||
(metricName) =>
|
||||
({
|
||||
queryKey: [REACT_QUERY_KEY.GET_METRIC_METADATA, metricName],
|
||||
queryFn: ({ signal }) => getMetricMetadata(metricName, signal, headers),
|
||||
...options,
|
||||
} as UseQueryOptions<SuccessResponseV2<MetricMetadataResponse>, Error>),
|
||||
),
|
||||
);
|
||||
@@ -5,7 +5,7 @@ import updateMetricMetadata, {
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface UseUpdateMetricMetadataProps {
|
||||
interface UseUpdateMetricMetadataProps {
|
||||
metricName: string;
|
||||
payload: UpdateMetricMetadataProps;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: 'new_sum_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -239,7 +239,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: 'new_sum_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -315,7 +315,7 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: 'new_gauge',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -317,7 +317,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
} else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
|
||||
@@ -326,20 +326,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
},
|
||||
];
|
||||
} else if (
|
||||
newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.HISTOGRAM ||
|
||||
newQuery.aggregateAttribute?.type ===
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM
|
||||
) {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
timeAggregation: '',
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
spaceAggregation: '',
|
||||
},
|
||||
];
|
||||
} else {
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { useGetMetrics } from 'container/MetricsExplorer/Explorer/utils';
|
||||
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
|
||||
import useGetYAxisUnit from './useGetYAxisUnit';
|
||||
|
||||
jest.mock('./queryBuilder/useQueryBuilder');
|
||||
jest.mock('container/MetricsExplorer/Explorer/utils', () => ({
|
||||
...jest.requireActual('container/MetricsExplorer/Explorer/utils'),
|
||||
useGetMetrics: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as jest.MockedFunction<
|
||||
typeof useQueryBuilder
|
||||
>;
|
||||
const mockUseGetMetrics = useGetMetrics as jest.MockedFunction<
|
||||
typeof useGetMetrics
|
||||
>;
|
||||
|
||||
const MOCK_METRIC_1 = {
|
||||
unit: UniversalYAxisUnit.BYTES,
|
||||
} as MetricMetadata;
|
||||
const MOCK_METRIC_2 = {
|
||||
unit: UniversalYAxisUnit.SECONDS,
|
||||
} as MetricMetadata;
|
||||
const MOCK_METRIC_3 = {
|
||||
unit: '',
|
||||
} as MetricMetadata;
|
||||
|
||||
function createMockCurrentQuery(
|
||||
queryType: EQueryType,
|
||||
queryData: Query['builder']['queryData'] = [],
|
||||
): Query {
|
||||
return {
|
||||
queryType,
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
id: 'test-id',
|
||||
};
|
||||
}
|
||||
|
||||
describe('useGetYAxisUnit', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseGetMetrics.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [],
|
||||
});
|
||||
mockUseQueryBuilder.mockReturnValue(({
|
||||
currentQuery: undefined,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
});
|
||||
|
||||
it('should return undefined yAxisUnit and not call useGetMetrics when currentQuery is null', async () => {
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBeUndefined();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
|
||||
});
|
||||
|
||||
it('should return undefined yAxisUnit when queryType is PROM', async () => {
|
||||
const mockCurrentQuery = createMockCurrentQuery(EQueryType.PROM);
|
||||
mockUseQueryBuilder.mockReturnValueOnce(({
|
||||
currentQuery: mockCurrentQuery,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBeUndefined();
|
||||
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
|
||||
});
|
||||
|
||||
it('should return undefined yAxisUnit when queryType is CLICKHOUSE', async () => {
|
||||
const mockCurrentQuery = createMockCurrentQuery(EQueryType.CLICKHOUSE);
|
||||
mockUseQueryBuilder.mockReturnValueOnce(({
|
||||
currentQuery: mockCurrentQuery,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBeUndefined();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
|
||||
});
|
||||
|
||||
it('should return undefined yAxisUnit when dataSource is TRACES', async () => {
|
||||
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateAttribute: { key: 'trace_metric' },
|
||||
} as Query['builder']['queryData'][0],
|
||||
]);
|
||||
mockUseQueryBuilder.mockReturnValueOnce(({
|
||||
currentQuery: mockCurrentQuery,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBeUndefined();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
|
||||
});
|
||||
|
||||
it('should return undefined yAxisUnit when dataSource is LOGS', async () => {
|
||||
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateAttribute: { key: 'log_metric' },
|
||||
} as Query['builder']['queryData'][number],
|
||||
]);
|
||||
mockUseQueryBuilder.mockReturnValueOnce(({
|
||||
currentQuery: mockCurrentQuery,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBeUndefined();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
expect(mockUseGetMetrics).toHaveBeenCalledWith([], false);
|
||||
});
|
||||
|
||||
it('should extract all metric names from queryData when no selected query name is provided', () => {
|
||||
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
|
||||
{
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateAttribute: { key: 'metric1' },
|
||||
queryName: 'query1',
|
||||
} as Query['builder']['queryData'][number],
|
||||
{
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateAttribute: { key: 'metric2' },
|
||||
queryName: 'query2',
|
||||
} as Query['builder']['queryData'][number],
|
||||
]);
|
||||
|
||||
mockUseQueryBuilder.mockReturnValueOnce(({
|
||||
stagedQuery: mockCurrentQuery,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(mockUseGetMetrics).toHaveBeenCalledWith(['metric1', 'metric2'], true);
|
||||
});
|
||||
|
||||
it('should extract metric name for the selected query only when one is provided', () => {
|
||||
const mockCurrentQuery = createMockCurrentQuery(EQueryType.QUERY_BUILDER, [
|
||||
{
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateAttribute: { key: 'metric1' },
|
||||
queryName: 'query1',
|
||||
} as Query['builder']['queryData'][number],
|
||||
{
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateAttribute: { key: 'metric2' },
|
||||
queryName: 'query2',
|
||||
} as Query['builder']['queryData'][number],
|
||||
]);
|
||||
mockUseQueryBuilder.mockReturnValueOnce(({
|
||||
stagedQuery: mockCurrentQuery,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
renderHook(() => useGetYAxisUnit('query2'));
|
||||
|
||||
expect(mockUseGetMetrics).toHaveBeenCalledWith(['metric2'], true);
|
||||
});
|
||||
|
||||
it('should return the unit when there is a single metric with a non-empty unit', async () => {
|
||||
mockUseGetMetrics.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_1],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when there is a single metric with no unit', async () => {
|
||||
mockUseGetMetrics.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_3],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBeUndefined();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('should return the unit when all metrics have the same non-empty unit', async () => {
|
||||
mockUseGetMetrics.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_1, MOCK_METRIC_1],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when metrics have different units', async () => {
|
||||
mockUseGetMetrics.mockReturnValueOnce({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_1, MOCK_METRIC_2],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGetYAxisUnit());
|
||||
|
||||
expect(result.current.yAxisUnit).toBeUndefined();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import {
|
||||
getMetricUnits,
|
||||
useGetMetrics,
|
||||
} from 'container/MetricsExplorer/Explorer/utils';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
|
||||
|
||||
interface UseGetYAxisUnitResult {
|
||||
yAxisUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the y-axis unit for a given metrics-based query.
|
||||
* @param selectedQueryName - The name of the query to get the y-axis unit for.
|
||||
* @param params.enabled - Active state of the hook.
|
||||
* @returns `{ yAxisUnit, isLoading, isError }` The y-axis unit, loading state, and error state
|
||||
*/
|
||||
function useGetYAxisUnit(
|
||||
selectedQueryName?: string,
|
||||
params: {
|
||||
enabled?: boolean;
|
||||
} = {
|
||||
enabled: true,
|
||||
},
|
||||
): UseGetYAxisUnitResult {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
|
||||
|
||||
const metricNames: string[] | null = useMemo(() => {
|
||||
// If the query type is not QUERY_BUILDER, return null
|
||||
if (stagedQuery?.queryType !== EQueryType.QUERY_BUILDER) {
|
||||
return null;
|
||||
}
|
||||
// If the data source is not METRICS, return null
|
||||
const dataSource = stagedQuery?.builder?.queryData?.[0]?.dataSource;
|
||||
if (dataSource !== DataSource.METRICS) {
|
||||
return null;
|
||||
}
|
||||
const currentMetricNames: string[] = [];
|
||||
// If a selected query name is provided, return the metric name for that query only
|
||||
if (selectedQueryName) {
|
||||
stagedQuery?.builder?.queryData?.forEach((query) => {
|
||||
if (
|
||||
query.queryName === selectedQueryName &&
|
||||
query.aggregateAttribute?.key
|
||||
) {
|
||||
currentMetricNames.push(query.aggregateAttribute?.key);
|
||||
}
|
||||
});
|
||||
return currentMetricNames.length ? currentMetricNames : null;
|
||||
}
|
||||
// Else, return all metric names
|
||||
stagedQuery?.builder?.queryData?.forEach((query) => {
|
||||
if (query.aggregateAttribute?.key) {
|
||||
currentMetricNames.push(query.aggregateAttribute?.key);
|
||||
}
|
||||
});
|
||||
return currentMetricNames.length ? currentMetricNames : null;
|
||||
}, [
|
||||
selectedQueryName,
|
||||
stagedQuery?.builder?.queryData,
|
||||
stagedQuery?.queryType,
|
||||
]);
|
||||
|
||||
const { metrics, isLoading, isError } = useGetMetrics(
|
||||
metricNames ?? [],
|
||||
!!metricNames && params?.enabled,
|
||||
);
|
||||
|
||||
const units = useMemo(() => getMetricUnits(metrics), [metrics]);
|
||||
|
||||
const areAllMetricUnitsSame = useMemo(
|
||||
() => units.every((unit) => unit === units[0]),
|
||||
[units],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If there are no metrics, set the y-axis unit to undefined
|
||||
if (units.length === 0) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
|
||||
} else if (units.length === 1 && units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
// If all metrics have the same non-empty unit, set the y-axis unit to it
|
||||
} else if (areAllMetricUnitsSame) {
|
||||
if (units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
} else {
|
||||
setYAxisUnit(undefined);
|
||||
}
|
||||
// If there is more than one metric and they have different units, set the y-axis unit to undefined
|
||||
} else if (units.length > 1 && !areAllMetricUnitsSame) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
|
||||
} else if (units.length === 1 && units[0] === '') {
|
||||
setYAxisUnit(undefined);
|
||||
}
|
||||
}, [units, areAllMetricUnitsSame]);
|
||||
|
||||
return { yAxisUnit, isLoading, isError };
|
||||
}
|
||||
|
||||
export default useGetYAxisUnit;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { cloneDeep, isUndefined } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { normalizePlotValue } from './dataUtils';
|
||||
import { generateColor } from './generateColor';
|
||||
|
||||
function getXAxisTimestamps(seriesList: QueryData[]): number[] {
|
||||
const timestamps = new Set<number>();
|
||||
const timestamps = new Set();
|
||||
|
||||
seriesList.forEach((series: { values?: [number, string][] }) => {
|
||||
if (series?.values) {
|
||||
@@ -18,71 +18,54 @@ function getXAxisTimestamps(seriesList: QueryData[]): number[] {
|
||||
}
|
||||
});
|
||||
|
||||
const timestampsArr = Array.from(timestamps);
|
||||
timestampsArr.sort((a, b) => a - b);
|
||||
|
||||
return timestampsArr;
|
||||
const timestampsArr: number[] | unknown[] = Array.from(timestamps) || [];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return timestampsArr.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function fillMissingXAxisTimestamps(
|
||||
timestampArr: number[],
|
||||
data: Array<{ values?: [number, string][] }>,
|
||||
): (number | null)[][] {
|
||||
function fillMissingXAxisTimestamps(timestampArr: number[], data: any[]): any {
|
||||
// Generate a set of all timestamps in the range
|
||||
const allTimestampsSet = new Set(timestampArr);
|
||||
const result: (number | null)[][] = [];
|
||||
const processedData = cloneDeep(data);
|
||||
|
||||
// Process each series entry
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const entry = data[i];
|
||||
if (!entry?.values) {
|
||||
result.push([]);
|
||||
} else {
|
||||
// Build Set of existing timestamps directly (avoid intermediate array)
|
||||
const existingTimestamps = new Set<number>();
|
||||
const valuesMap = new Map<number, number | null>();
|
||||
// Fill missing timestamps with null values
|
||||
processedData.forEach((entry: { values: (number | null)[][] }) => {
|
||||
const existingTimestamps = new Set(
|
||||
(entry?.values ?? []).map((value) => value[0]),
|
||||
);
|
||||
|
||||
for (let j = 0; j < entry.values.length; j++) {
|
||||
const [timestamp, value] = entry.values[j];
|
||||
existingTimestamps.add(timestamp);
|
||||
valuesMap.set(timestamp, normalizePlotValue(value));
|
||||
}
|
||||
const missingTimestamps = Array.from(allTimestampsSet).filter(
|
||||
(timestamp) => !existingTimestamps.has(timestamp),
|
||||
);
|
||||
|
||||
// Find missing timestamps by iterating Set directly (avoid Array.from + filter)
|
||||
const missingTimestamps: number[] = [];
|
||||
const allTimestampsArray = Array.from(allTimestampsSet);
|
||||
for (let k = 0; k < allTimestampsArray.length; k++) {
|
||||
const timestamp = allTimestampsArray[k];
|
||||
if (!existingTimestamps.has(timestamp)) {
|
||||
missingTimestamps.push(timestamp);
|
||||
}
|
||||
}
|
||||
missingTimestamps.forEach((timestamp) => {
|
||||
const value = null;
|
||||
|
||||
// Add missing timestamps to map
|
||||
for (let j = 0; j < missingTimestamps.length; j++) {
|
||||
valuesMap.set(missingTimestamps[j], null);
|
||||
}
|
||||
entry?.values?.push([timestamp, value]);
|
||||
});
|
||||
|
||||
// Build sorted array of values
|
||||
const sortedTimestamps = Array.from(valuesMap.keys()).sort((a, b) => a - b);
|
||||
const yValues = sortedTimestamps.map((timestamp) => {
|
||||
const value = valuesMap.get(timestamp);
|
||||
return value !== undefined ? value : null;
|
||||
});
|
||||
result.push(yValues);
|
||||
}
|
||||
}
|
||||
entry?.values?.forEach((v) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
v[1] = normalizePlotValue(v[1]);
|
||||
});
|
||||
|
||||
return result;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
entry?.values?.sort((a, b) => a[0] - b[0]);
|
||||
});
|
||||
|
||||
return processedData.map((entry: { values: [number, string][] }) =>
|
||||
entry?.values?.map((value) => value[1]),
|
||||
);
|
||||
}
|
||||
|
||||
function getStackedSeries(val: (number | null)[][]): (number | null)[][] {
|
||||
const series = val ? val.map((row: (number | null)[]) => [...row]) : [];
|
||||
function getStackedSeries(val: any): any {
|
||||
const series = cloneDeep(val) || [];
|
||||
|
||||
for (let i = series.length - 2; i >= 0; i--) {
|
||||
for (let j = 0; j < series[i].length; j++) {
|
||||
series[i][j] = (series[i][j] || 0) + (series[i + 1][j] || 0);
|
||||
series[i][j] += series[i + 1][j];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +110,6 @@ const processAnomalyDetectionData = (
|
||||
queryIndex < anomalyDetectionData.length;
|
||||
queryIndex++
|
||||
) {
|
||||
const queryData = anomalyDetectionData[queryIndex];
|
||||
const {
|
||||
series,
|
||||
predictedSeries,
|
||||
@@ -135,7 +117,7 @@ const processAnomalyDetectionData = (
|
||||
lowerBoundSeries,
|
||||
queryName,
|
||||
legend,
|
||||
} = queryData;
|
||||
} = anomalyDetectionData[queryIndex];
|
||||
|
||||
for (let index = 0; index < series?.length; index++) {
|
||||
const label = getLabelName(
|
||||
@@ -147,30 +129,14 @@ const processAnomalyDetectionData = (
|
||||
const objKey =
|
||||
anomalyDetectionData.length > 1 ? `${queryName}-${label}` : label;
|
||||
|
||||
// Single iteration instead of 5 separate map operations
|
||||
const { values: seriesValues } = series[index];
|
||||
const { values: predictedValues } = predictedSeries[index];
|
||||
const { values: upperBoundValues } = upperBoundSeries[index];
|
||||
const { values: lowerBoundValues } = lowerBoundSeries[index];
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
const length = seriesValues.length;
|
||||
|
||||
const timestamps: number[] = new Array(length);
|
||||
const values: number[] = new Array(length);
|
||||
const predicted: number[] = new Array(length);
|
||||
const upperBound: number[] = new Array(length);
|
||||
const lowerBound: number[] = new Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
timestamps[i] = seriesValues[i].timestamp / 1000;
|
||||
values[i] = seriesValues[i].value;
|
||||
predicted[i] = predictedValues[i].value;
|
||||
upperBound[i] = upperBoundValues[i].value;
|
||||
lowerBound[i] = lowerBoundValues[i].value;
|
||||
}
|
||||
|
||||
processedData[objKey] = {
|
||||
data: [timestamps, values, predicted, upperBound, lowerBound],
|
||||
data: [
|
||||
series[index].values.map((v: { timestamp: number }) => v.timestamp / 1000),
|
||||
series[index].values.map((v: { value: number }) => v.value),
|
||||
predictedSeries[index].values.map((v: { value: number }) => v.value),
|
||||
upperBoundSeries[index].values.map((v: { value: number }) => v.value),
|
||||
lowerBoundSeries[index].values.map((v: { value: number }) => v.value),
|
||||
],
|
||||
color: generateColor(
|
||||
objKey,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
@@ -186,7 +152,14 @@ const processAnomalyDetectionData = (
|
||||
export const getUplotChartDataForAnomalyDetection = (
|
||||
apiResponse: MetricRangePayloadProps,
|
||||
isDarkMode: boolean,
|
||||
): Record<string, { [x: string]: any; data: number[][]; color: string }> => {
|
||||
): Record<
|
||||
string,
|
||||
{
|
||||
[x: string]: any;
|
||||
data: number[][];
|
||||
color: string;
|
||||
}
|
||||
> => {
|
||||
const anomalyDetectionData = apiResponse?.data?.newResult?.data?.result;
|
||||
return processAnomalyDetectionData(anomalyDetectionData, isDarkMode);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ function NoData(): JSX.Element {
|
||||
<Typography.Text className="not-found-text-1">
|
||||
Uh-oh! We cannot show the selected trace.
|
||||
<span className="not-found-text-2">
|
||||
This can happen in either of the two scenarios -
|
||||
This can happen in either of the two scenraios -
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
export interface MetricMetadata {
|
||||
description: string;
|
||||
type: MetricType;
|
||||
unit: string;
|
||||
temporality: Temporality;
|
||||
isMonotonic: boolean;
|
||||
}
|
||||
|
||||
export interface MetricMetadataResponse {
|
||||
status: string;
|
||||
data: MetricMetadata;
|
||||
}
|
||||
16
go.mod
16
go.mod
@@ -74,12 +74,12 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.38.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/text v0.28.0
|
||||
google.golang.org/protobuf v1.36.9
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -103,7 +103,6 @@ require (
|
||||
go.opentelemetry.io/collector/config/configretry v1.34.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
@@ -224,7 +223,6 @@ require (
|
||||
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/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
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.128.0 // indirect
|
||||
@@ -338,10 +336,10 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
gonum.org/v1/gonum v0.16.0 // indirect
|
||||
google.golang.org/api v0.236.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
|
||||
36
go.sum
36
go.sum
@@ -762,8 +762,6 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/open-feature/go-sdk v1.17.0 h1:/OUBBw5d9D61JaNZZxb2Nnr5/EJrEpjtKCTY3rspJQk=
|
||||
github.com/open-feature/go-sdk v1.17.0/go.mod h1:lPxPSu1UnZ4E3dCxZi5gV3et2ACi8O8P+zsTGVsDZUw=
|
||||
github.com/open-telemetry/opamp-go v0.19.0 h1:8LvQKDwqi+BU3Yy159SU31e2XB0vgnk+PN45pnKilPs=
|
||||
github.com/open-telemetry/opamp-go v0.19.0/go.mod h1:9/1G6T5dnJz4cJtoYSr6AX18kHdOxnxxETJPZSHyEUg=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage v0.128.0 h1:T5IE0l1qcIg6dkHui4hHe+qj3VzuMwpnhrUyubyCwO0=
|
||||
@@ -1284,8 +1282,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
|
||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1323,8 +1321,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1373,8 +1371,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1409,8 +1407,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1497,12 +1495,12 @@ golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1513,8 +1511,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1577,10 +1575,8 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -80,17 +80,6 @@ func parseFieldKeyRequest(r *http.Request) (*telemetrytypes.FieldKeySelector, er
|
||||
|
||||
name := r.URL.Query().Get("searchText")
|
||||
|
||||
if name != "" && fieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
parsedFieldKey := telemetrytypes.GetFieldKeyFromKeyText(name)
|
||||
if parsedFieldKey.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
// Only apply inferred context if it is valid for the current signal
|
||||
if isContextValidForSignal(parsedFieldKey.FieldContext, signal) {
|
||||
name = parsedFieldKey.Name
|
||||
fieldContext = parsedFieldKey.FieldContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req = telemetrytypes.FieldKeySelector{
|
||||
StartUnixMilli: startUnixMilli,
|
||||
EndUnixMilli: endUnixMilli,
|
||||
@@ -113,16 +102,6 @@ func parseFieldValueRequest(r *http.Request) (*telemetrytypes.FieldValueSelector
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("name")
|
||||
if name != "" && keySelector.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
parsedFieldKey := telemetrytypes.GetFieldKeyFromKeyText(name)
|
||||
if parsedFieldKey.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
// Only apply inferred context if it is valid for the current signal
|
||||
if isContextValidForSignal(parsedFieldKey.FieldContext, keySelector.Signal) {
|
||||
name = parsedFieldKey.Name
|
||||
keySelector.FieldContext = parsedFieldKey.FieldContext
|
||||
}
|
||||
}
|
||||
}
|
||||
keySelector.Name = name
|
||||
existingQuery := r.URL.Query().Get("existingQuery")
|
||||
value := r.URL.Query().Get("searchText")
|
||||
@@ -142,21 +121,3 @@ func parseFieldValueRequest(r *http.Request) (*telemetrytypes.FieldValueSelector
|
||||
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func isContextValidForSignal(ctx telemetrytypes.FieldContext, signal telemetrytypes.Signal) bool {
|
||||
if ctx == telemetrytypes.FieldContextResource ||
|
||||
ctx == telemetrytypes.FieldContextAttribute ||
|
||||
ctx == telemetrytypes.FieldContextScope {
|
||||
return true
|
||||
}
|
||||
|
||||
switch signal.StringValue() {
|
||||
case telemetrytypes.SignalLogs.StringValue():
|
||||
return ctx == telemetrytypes.FieldContextLog || ctx == telemetrytypes.FieldContextBody
|
||||
case telemetrytypes.SignalTraces.StringValue():
|
||||
return ctx == telemetrytypes.FieldContextSpan || ctx == telemetrytypes.FieldContextEvent || ctx == telemetrytypes.FieldContextTrace
|
||||
case telemetrytypes.SignalMetrics.StringValue():
|
||||
return ctx == telemetrytypes.FieldContextMetric
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/features", handler.New(provider.authZ.ViewAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
|
||||
ID: "GetFeatures",
|
||||
Tags: []string{"features"},
|
||||
Summary: "Get features",
|
||||
Description: "This endpoint returns the supported features and their details",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*featuretypes.GettableFeature, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/promotetypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addPromoteRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/logs/promote_paths", handler.New(provider.authZ.EditAccess(provider.promoteHandler.HandlePromoteAndIndexPaths), handler.OpenAPIDef{
|
||||
ID: "HandlePromoteAndIndexPaths",
|
||||
Tags: []string{"logs"},
|
||||
Summary: "Promote and index paths",
|
||||
Description: "This endpoints promotes and indexes paths",
|
||||
Request: new([]*promotetypes.PromotePath),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/logs/promote_paths", handler.New(provider.authZ.ViewAccess(provider.promoteHandler.ListPromotedAndIndexedPaths), handler.OpenAPIDef{
|
||||
ID: "ListPromotedAndIndexedPaths",
|
||||
Tags: []string{"logs"},
|
||||
Summary: "Promote and index paths",
|
||||
Description: "This endpoints promotes and indexes paths",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new([]*promotetypes.PromotePath),
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -6,14 +6,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/apiserver"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@@ -32,8 +30,6 @@ type provider struct {
|
||||
authDomainHandler authdomain.Handler
|
||||
preferenceHandler preference.Handler
|
||||
globalHandler global.Handler
|
||||
promoteHandler promote.Handler
|
||||
flaggerHandler flagger.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -45,11 +41,9 @@ func NewFactory(
|
||||
authDomainHandler authdomain.Handler,
|
||||
preferenceHandler preference.Handler,
|
||||
globalHandler global.Handler,
|
||||
promoteHandler promote.Handler,
|
||||
flaggerHandler flagger.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(ctx, providerSettings, config, orgGetter, authz, orgHandler, userHandler, sessionHandler, authDomainHandler, preferenceHandler, globalHandler, promoteHandler, flaggerHandler)
|
||||
return newProvider(ctx, providerSettings, config, orgGetter, authz, orgHandler, userHandler, sessionHandler, authDomainHandler, preferenceHandler, globalHandler)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,8 +59,6 @@ func newProvider(
|
||||
authDomainHandler authdomain.Handler,
|
||||
preferenceHandler preference.Handler,
|
||||
globalHandler global.Handler,
|
||||
promoteHandler promote.Handler,
|
||||
flaggerHandler flagger.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -81,8 +73,6 @@ func newProvider(
|
||||
authDomainHandler: authDomainHandler,
|
||||
preferenceHandler: preferenceHandler,
|
||||
globalHandler: globalHandler,
|
||||
promoteHandler: promoteHandler,
|
||||
flaggerHandler: flaggerHandler,
|
||||
}
|
||||
|
||||
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
|
||||
@@ -123,14 +113,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addPromoteRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addFlaggerRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -209,11 +209,6 @@ func NewUnexpectedf(code Code, format string, args ...any) *base {
|
||||
return Newf(TypeInvalidInput, code, format, args...)
|
||||
}
|
||||
|
||||
// NewMethodNotAllowedf is a wrapper around Newf with TypeMethodNotAllowed.
|
||||
func NewMethodNotAllowedf(code Code, format string, args ...any) *base {
|
||||
return Newf(TypeMethodNotAllowed, code, format, args...)
|
||||
}
|
||||
|
||||
// WrapTimeoutf is a wrapper around Wrapf with TypeTimeout.
|
||||
func WrapTimeoutf(cause error, code Code, format string, args ...any) *base {
|
||||
return Wrapf(cause, TypeTimeout, code, format, args...)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package flagger
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/factory"
|
||||
|
||||
type Config struct {
|
||||
Config ConfigProvider `mapstructure:"config"`
|
||||
}
|
||||
|
||||
type ConfigProvider struct {
|
||||
Boolean map[string]bool `mapstructure:"boolean"`
|
||||
String map[string]string `mapstructure:"string"`
|
||||
Float map[string]float64 `mapstructure:"float"`
|
||||
Integer map[string]int64 `mapstructure:"integer"`
|
||||
Object map[string]any `mapstructure:"object"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(
|
||||
factory.MustNewName("flagger"), newConfig,
|
||||
)
|
||||
}
|
||||
|
||||
// newConfig creates a new config with the default values.
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Config: ConfigProvider{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
package configflagger
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
config flagger.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
// This is the default registry that will be containing all the supported features along with there all possible variants
|
||||
registry featuretypes.Registry
|
||||
// These are the feature variants that are configured in the config file and will be used as overrides
|
||||
featureVariants map[featuretypes.Name]*featuretypes.FeatureVariant
|
||||
}
|
||||
|
||||
func NewFactory(registry featuretypes.Registry) factory.ProviderFactory[flagger.FlaggerProvider, flagger.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("config"), func(ctx context.Context, ps factory.ProviderSettings, c flagger.Config) (flagger.FlaggerProvider, error) {
|
||||
return New(ctx, ps, c, registry)
|
||||
})
|
||||
}
|
||||
|
||||
func New(ctx context.Context, ps factory.ProviderSettings, c flagger.Config, registry featuretypes.Registry) (flagger.FlaggerProvider, error) {
|
||||
settings := factory.NewScopedProviderSettings(ps, "github.com/SigNoz/signoz/pkg/flagger/configflagger")
|
||||
|
||||
featureVariants := make(map[featuretypes.Name]*featuretypes.FeatureVariant)
|
||||
|
||||
for name, value := range c.Config.Boolean {
|
||||
feature, _, err := registry.GetByString(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
variant, err := featuretypes.VariantByValue(feature, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
featureVariants[feature.Name] = variant
|
||||
}
|
||||
|
||||
for name, value := range c.Config.String {
|
||||
feature, _, err := registry.GetByString(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
variant, err := featuretypes.VariantByValue(feature, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
featureVariants[feature.Name] = variant
|
||||
}
|
||||
|
||||
for name, value := range c.Config.Float {
|
||||
feature, _, err := registry.GetByString(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
variant, err := featuretypes.VariantByValue(feature, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
featureVariants[feature.Name] = variant
|
||||
}
|
||||
|
||||
for name, value := range c.Config.Integer {
|
||||
feature, _, err := registry.GetByString(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
variant, err := featuretypes.VariantByValue(feature, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
featureVariants[feature.Name] = variant
|
||||
}
|
||||
|
||||
for name, value := range c.Config.Object {
|
||||
feature, _, err := registry.GetByString(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
variant, err := featuretypes.VariantByValue(feature, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
featureVariants[feature.Name] = variant
|
||||
}
|
||||
|
||||
return &provider{
|
||||
config: c,
|
||||
settings: settings,
|
||||
registry: registry,
|
||||
featureVariants: featureVariants,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Metadata() openfeature.Metadata {
|
||||
return openfeature.Metadata{
|
||||
Name: "config",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail {
|
||||
// check if the feature is present in the default registry
|
||||
feature, detail, err := p.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
return openfeature.BoolResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
value, detail, err := featuretypes.VariantValue[bool](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
return openfeature.BoolResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// check if the feature is present in the featureVariants map
|
||||
variant, ok := p.featureVariants[feature.Name]
|
||||
if ok {
|
||||
// return early as we have found the value in the featureVariants map
|
||||
return openfeature.BoolResolutionDetail{
|
||||
Value: variant.Value.(bool),
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// return the value from the default registry we found earlier
|
||||
return openfeature.BoolResolutionDetail{
|
||||
Value: value,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail {
|
||||
// check if the feature is present in the default registry
|
||||
feature, detail, err := p.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
return openfeature.FloatResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
value, detail, err := featuretypes.VariantValue[float64](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
return openfeature.FloatResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// check if the feature is present in the featureVariants map
|
||||
variant, ok := p.featureVariants[feature.Name]
|
||||
if ok {
|
||||
// return early as we have found the value in the featureVariants map
|
||||
return openfeature.FloatResolutionDetail{
|
||||
Value: variant.Value.(float64),
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// return the value from the default registry we found earlier
|
||||
return openfeature.FloatResolutionDetail{
|
||||
Value: value,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail {
|
||||
// check if the feature is present in the default registry
|
||||
feature, detail, err := p.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
return openfeature.StringResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
value, detail, err := featuretypes.VariantValue[string](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
return openfeature.StringResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// check if the feature is present in the featureVariants map
|
||||
variant, ok := p.featureVariants[feature.Name]
|
||||
if ok {
|
||||
// return early as we have found the value in the featureVariants map
|
||||
return openfeature.StringResolutionDetail{
|
||||
Value: variant.Value.(string),
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// return the value from the default registry we found earlier
|
||||
return openfeature.StringResolutionDetail{
|
||||
Value: value,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail {
|
||||
// check if the feature is present in the default registry
|
||||
feature, detail, err := p.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
return openfeature.IntResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
value, detail, err := featuretypes.VariantValue[int64](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
return openfeature.IntResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// check if the feature is present in the featureVariants map
|
||||
variant, ok := p.featureVariants[feature.Name]
|
||||
if ok {
|
||||
// return early as we have found the value in the featureVariants map
|
||||
return openfeature.IntResolutionDetail{
|
||||
Value: variant.Value.(int64),
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// return the value from the default registry we found earlier
|
||||
return openfeature.IntResolutionDetail{
|
||||
Value: value,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *provider) ObjectEvaluation(ctx context.Context, flag string, defaultValue any, evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail {
|
||||
// check if the feature is present in the default registry
|
||||
feature, detail, err := p.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
return openfeature.InterfaceResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
value, detail, err := featuretypes.VariantValue[any](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
return openfeature.InterfaceResolutionDetail{
|
||||
Value: defaultValue,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// check if the feature is present in the featureVariants map
|
||||
variant, ok := p.featureVariants[feature.Name]
|
||||
if ok {
|
||||
// return early as we have found the value in the featureVariants map
|
||||
return openfeature.InterfaceResolutionDetail{
|
||||
Value: variant.Value,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// return the value from the default registry we found earlier
|
||||
return openfeature.InterfaceResolutionDetail{
|
||||
Value: value,
|
||||
ProviderResolutionDetail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) Hooks() []openfeature.Hook {
|
||||
return []openfeature.Hook{}
|
||||
}
|
||||
|
||||
func (p *provider) List(ctx context.Context) ([]*featuretypes.GettableFeature, error) {
|
||||
result := make([]*featuretypes.GettableFeature, 0, len(p.featureVariants))
|
||||
|
||||
for featureName, variant := range p.featureVariants {
|
||||
feature, _, err := p.registry.Get(featureName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, &featuretypes.GettableFeature{
|
||||
Name: feature.Name.String(),
|
||||
Kind: feature.Kind.StringValue(),
|
||||
Stage: feature.Stage.StringValue(),
|
||||
Description: feature.Description,
|
||||
DefaultVariant: feature.DefaultVariant.String(),
|
||||
Variants: nil,
|
||||
ResolvedValue: variant.Value,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package flagger
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
)
|
||||
|
||||
// Any feature flag provider has to implement this interface.
|
||||
type FlaggerProvider interface {
|
||||
openfeature.FeatureProvider
|
||||
|
||||
// List returns all the feature flags
|
||||
List(ctx context.Context) ([]*featuretypes.GettableFeature, error)
|
||||
}
|
||||
|
||||
// This is the consumer facing interface for the Flagger service.
|
||||
type Flagger interface {
|
||||
Boolean(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (bool, error)
|
||||
String(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (string, error)
|
||||
Float(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (float64, error)
|
||||
Int(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (int64, error)
|
||||
Object(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (any, error)
|
||||
List(ctx context.Context, evalCtx featuretypes.FlaggerEvaluationContext) ([]*featuretypes.GettableFeature, error)
|
||||
}
|
||||
|
||||
// This is the concrete implementation of the Flagger interface.
|
||||
type flagger struct {
|
||||
registry featuretypes.Registry
|
||||
settings factory.ScopedProviderSettings
|
||||
providers map[string]FlaggerProvider
|
||||
clients map[string]*openfeature.Client
|
||||
}
|
||||
|
||||
func New(ctx context.Context, ps factory.ProviderSettings, config Config, registry featuretypes.Registry, factories ...factory.ProviderFactory[FlaggerProvider, Config]) (Flagger, error) {
|
||||
settings := factory.NewScopedProviderSettings(ps, "github.com/SigNoz/signoz/pkg/flagger")
|
||||
|
||||
providers := make(map[string]FlaggerProvider)
|
||||
clients := make(map[string]*openfeature.Client)
|
||||
|
||||
for _, factory := range factories {
|
||||
provider, err := factory.New(ctx, ps, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
providers[provider.Metadata().Name] = provider
|
||||
|
||||
openfeatureClient := openfeature.NewClient(provider.Metadata().Name)
|
||||
|
||||
if err := openfeature.SetNamedProviderAndWait(provider.Metadata().Name, provider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clients[provider.Metadata().Name] = openfeatureClient
|
||||
}
|
||||
|
||||
return &flagger{
|
||||
registry: registry,
|
||||
settings: settings,
|
||||
providers: providers,
|
||||
clients: clients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *flagger) Boolean(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (bool, error) {
|
||||
// check if the feature is present in the default registry
|
||||
feature, _, err := f.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get feature from default registry", "error", err, "flag", flag)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
defaultValue, _, err := featuretypes.VariantValue[bool](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
// something which should never happen
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get default value from feature", "error", err, "flag", flag)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// * this logic can be optimised based on priority of the clients and short circuiting
|
||||
// now ask all the available clients for the value
|
||||
for _, client := range f.clients {
|
||||
value, err := client.BooleanValue(ctx, flag, defaultValue, evalCtx.Ctx())
|
||||
if err != nil {
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get value from client", "error", err, "flag", flag, "client", client.Metadata().Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if value != defaultValue {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
func (f *flagger) String(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (string, error) {
|
||||
// check if the feature is present in the default registry
|
||||
feature, _, err := f.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get feature from default registry", "error", err, "flag", flag)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
defaultValue, _, err := featuretypes.VariantValue[string](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
// something which should never happen
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get default value from feature", "error", err, "flag", flag)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// * this logic can be optimised based on priority of the clients and short circuiting
|
||||
// now ask all the available clients for the value
|
||||
for _, client := range f.clients {
|
||||
value, err := client.StringValue(ctx, flag, defaultValue, evalCtx.Ctx())
|
||||
if err != nil {
|
||||
f.settings.Logger().WarnContext(ctx, "failed to get value from client", "error", err, "flag", flag, "client", client.Metadata().Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if value != defaultValue {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
func (f *flagger) Float(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (float64, error) {
|
||||
// check if the feature is present in the default registry
|
||||
feature, _, err := f.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get feature from default registry", "error", err, "flag", flag)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
defaultValue, _, err := featuretypes.VariantValue[float64](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
// something which should never happen
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get default value from feature", "error", err, "flag", flag)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// * this logic can be optimised based on priority of the clients and short circuiting
|
||||
// now ask all the available clients for the value
|
||||
for _, client := range f.clients {
|
||||
value, err := client.FloatValue(ctx, flag, defaultValue, evalCtx.Ctx())
|
||||
if err != nil {
|
||||
f.settings.Logger().WarnContext(ctx, "failed to get value from client", "error", err, "flag", flag, "client", client.Metadata().Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if value != defaultValue {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
func (f *flagger) Int(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (int64, error) {
|
||||
// check if the feature is present in the default registry
|
||||
feature, _, err := f.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get feature from default registry", "error", err, "flag", flag)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
defaultValue, _, err := featuretypes.VariantValue[int64](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
// something which should never happen
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get default value from feature", "error", err, "flag", flag)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// * this logic can be optimised based on priority of the clients and short circuiting
|
||||
// now ask all the available clients for the value
|
||||
for _, client := range f.clients {
|
||||
value, err := client.IntValue(ctx, flag, defaultValue, evalCtx.Ctx())
|
||||
if err != nil {
|
||||
f.settings.Logger().WarnContext(ctx, "failed to get value from client", "error", err, "flag", flag, "client", client.Metadata().Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if value != defaultValue {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
func (f *flagger) Object(ctx context.Context, flag string, evalCtx featuretypes.FlaggerEvaluationContext) (any, error) {
|
||||
// check if the feature is present in the default registry
|
||||
feature, _, err := f.registry.GetByString(flag)
|
||||
if err != nil {
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get feature from default registry", "error", err, "flag", flag)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get the default value from the feature from default registry
|
||||
defaultValue, _, err := featuretypes.VariantValue[any](feature, feature.DefaultVariant)
|
||||
if err != nil {
|
||||
// something which should never happen
|
||||
f.settings.Logger().ErrorContext(ctx, "failed to get default value from feature", "error", err, "flag", flag)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// * this logic can be optimised based on priority of the clients and short circuiting
|
||||
// now ask all the available clients for the value
|
||||
for _, client := range f.clients {
|
||||
value, err := client.ObjectValue(ctx, flag, defaultValue, evalCtx.Ctx())
|
||||
if err != nil {
|
||||
f.settings.Logger().WarnContext(ctx, "failed to get value from client", "error", err, "flag", flag, "client", client.Metadata().Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// ! for object we do not compare with the default value for now, we will figure this out better in future coming releases
|
||||
// if value != defaultValue {
|
||||
// return value, nil
|
||||
// }
|
||||
return value, nil
|
||||
}
|
||||
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
func (f *flagger) List(ctx context.Context, evalCtx featuretypes.FlaggerEvaluationContext) ([]*featuretypes.GettableFeature, error) {
|
||||
// get all the feature from the default registry
|
||||
allFeatures := f.registry.List()
|
||||
|
||||
// make a map of name of feature -> the dict we want to create from all features
|
||||
featureMap := make(map[string]*featuretypes.GettableFeature, len(allFeatures))
|
||||
|
||||
for _, feature := range allFeatures {
|
||||
variants := make(map[string]any, len(feature.Variants))
|
||||
for name, value := range feature.Variants {
|
||||
variants[name.String()] = value.Value
|
||||
}
|
||||
|
||||
featureMap[feature.Name.String()] = &featuretypes.GettableFeature{
|
||||
Name: feature.Name.String(),
|
||||
Kind: feature.Kind.StringValue(),
|
||||
Stage: feature.Stage.StringValue(),
|
||||
Description: feature.Description,
|
||||
DefaultVariant: feature.DefaultVariant.String(),
|
||||
Variants: variants,
|
||||
ResolvedValue: feature.Variants[feature.DefaultVariant].Value,
|
||||
}
|
||||
}
|
||||
|
||||
// now call each provider and fix the value in feature map
|
||||
for _, provider := range f.providers {
|
||||
pFeatures, err := provider.List(ctx)
|
||||
if err != nil {
|
||||
f.settings.Logger().WarnContext(ctx, "failed to get features from provider", "error", err, "provider", provider.Metadata().Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// merge
|
||||
for _, pFeature := range pFeatures {
|
||||
if existing, ok := featureMap[pFeature.Name]; ok {
|
||||
existing.ResolvedValue = pFeature.ResolvedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]*featuretypes.GettableFeature, 0, len(allFeatures))
|
||||
|
||||
for _, f := range featureMap {
|
||||
result = append(result, f)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package flagger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
GetFeatures(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
flagger Flagger
|
||||
}
|
||||
|
||||
func NewHandler(flagger Flagger) Handler {
|
||||
return &handler{
|
||||
flagger: flagger,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) GetFeatures(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
|
||||
features, err := handler.flagger.List(ctx, evalCtx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, features)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package flagger
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
registry, err := featuretypes.NewRegistry()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return registry
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package implpromote
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/promotetypes"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module promote.Module
|
||||
}
|
||||
|
||||
func NewHandler(module promote.Module) promote.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (h *handler) HandlePromoteAndIndexPaths(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO(Nitya): Use in multi tenant setup
|
||||
_, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, errors.NewInternalf(errors.CodeInternal, "failed to get org id from context"))
|
||||
return
|
||||
}
|
||||
|
||||
var req []*promotetypes.PromotePath
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.module.PromoteAndIndexPaths(r.Context(), req...)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusCreated, nil)
|
||||
}
|
||||
|
||||
func (h *handler) ListPromotedAndIndexedPaths(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO(Nitya): Use in multi tenant setup
|
||||
_, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, errors.NewInternalf(errors.CodeInternal, "failed to get org id from context"))
|
||||
return
|
||||
}
|
||||
|
||||
paths, err := h.module.ListPromotedAndIndexedPaths(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, paths)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package implpromote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/promotetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var (
|
||||
CodeFailedToCreateIndex = errors.MustNewCode("failed_to_create_index_promoted_paths")
|
||||
CodeFailedToQueryPromotedPaths = errors.MustNewCode("failed_to_query_promoted_paths")
|
||||
)
|
||||
|
||||
type module struct {
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
}
|
||||
|
||||
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore) promote.Module {
|
||||
return &module{metadataStore: metadataStore, telemetryStore: telemetrystore}
|
||||
}
|
||||
|
||||
func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetypes.PromotePath, error) {
|
||||
logsIndexes, err := m.metadataStore.ListLogsJSONIndexes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Flatten the map values (which are slices) into a single slice
|
||||
indexes := slices.Concat(slices.Collect(maps.Values(logsIndexes))...)
|
||||
|
||||
aggr := map[string][]promotetypes.WrappedIndex{}
|
||||
for _, index := range indexes {
|
||||
path, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(index.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// clean backticks from the path
|
||||
path = strings.ReplaceAll(path, "`", "")
|
||||
|
||||
aggr[path] = append(aggr[path], promotetypes.WrappedIndex{
|
||||
ColumnType: columnType,
|
||||
Type: index.Type,
|
||||
Granularity: index.Granularity,
|
||||
})
|
||||
}
|
||||
promotedPaths, err := m.listPromotedPaths(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := []promotetypes.PromotePath{}
|
||||
for _, path := range promotedPaths {
|
||||
fullPath := telemetrylogs.BodyPromotedColumnPrefix + path
|
||||
path = telemetrylogs.BodyJSONStringSearchPrefix + path
|
||||
item := promotetypes.PromotePath{
|
||||
Path: path,
|
||||
Promote: true,
|
||||
}
|
||||
indexes, ok := aggr[fullPath]
|
||||
if ok {
|
||||
item.Indexes = indexes
|
||||
delete(aggr, fullPath)
|
||||
}
|
||||
response = append(response, item)
|
||||
}
|
||||
|
||||
// add the paths that are not promoted but have indexes
|
||||
for path, indexes := range aggr {
|
||||
path := strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix)
|
||||
path = telemetrylogs.BodyJSONStringSearchPrefix + path
|
||||
response = append(response, promotetypes.PromotePath{
|
||||
Path: path,
|
||||
Indexes: indexes,
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (m *module) listPromotedPaths(ctx context.Context) ([]string, error) {
|
||||
paths, err := m.metadataStore.ListPromotedPaths(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.Collect(maps.Keys(paths)), nil
|
||||
}
|
||||
|
||||
// PromotePaths inserts provided JSON paths into the promoted paths table for logs queries.
|
||||
func (m *module) PromotePaths(ctx context.Context, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "paths cannot be empty")
|
||||
}
|
||||
|
||||
return m.metadataStore.PromotePaths(ctx, paths...)
|
||||
}
|
||||
|
||||
// createIndexes creates string ngram + token filter indexes on JSON path subcolumns for LIKE queries.
|
||||
func (m *module) createIndexes(ctx context.Context, indexes []schemamigrator.Index) error {
|
||||
if len(indexes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, index := range indexes {
|
||||
alterStmt := schemamigrator.AlterTableAddIndex{
|
||||
Database: telemetrylogs.DBName,
|
||||
Table: telemetrylogs.LogsV2LocalTableName,
|
||||
Index: index,
|
||||
}
|
||||
op := alterStmt.OnCluster(m.telemetryStore.Cluster())
|
||||
if err := m.telemetryStore.ClickhouseDB().Exec(ctx, op.ToSQL()); err != nil {
|
||||
return errors.WrapInternalf(err, CodeFailedToCreateIndex, "failed to create index")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PromoteAndIndexPaths handles promoting paths and creating indexes in one call.
|
||||
func (m *module) PromoteAndIndexPaths(
|
||||
ctx context.Context,
|
||||
paths ...*promotetypes.PromotePath,
|
||||
) error {
|
||||
if len(paths) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "paths cannot be empty")
|
||||
}
|
||||
|
||||
pathsStr := []string{}
|
||||
// validate the paths
|
||||
for _, path := range paths {
|
||||
if err := path.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
pathsStr = append(pathsStr, path.Path)
|
||||
}
|
||||
|
||||
existingPromotedPaths, err := m.metadataStore.ListPromotedPaths(ctx, pathsStr...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var toInsert []string
|
||||
indexes := []schemamigrator.Index{}
|
||||
for _, it := range paths {
|
||||
if it.Promote {
|
||||
if _, promoted := existingPromotedPaths[it.Path]; !promoted {
|
||||
toInsert = append(toInsert, it.Path)
|
||||
}
|
||||
}
|
||||
if len(it.Indexes) > 0 {
|
||||
parentColumn := telemetrylogs.LogsV2BodyJSONColumn
|
||||
// if the path is already promoted or is being promoted, add it to the promoted column
|
||||
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
|
||||
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn
|
||||
}
|
||||
|
||||
for _, index := range it.Indexes {
|
||||
var typeIndex schemamigrator.IndexType
|
||||
switch {
|
||||
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeNGramBF)):
|
||||
typeIndex = schemamigrator.IndexTypeNGramBF
|
||||
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeTokenBF)):
|
||||
typeIndex = schemamigrator.IndexTypeTokenBF
|
||||
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeMinMax)):
|
||||
typeIndex = schemamigrator.IndexTypeMinMax
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid index type: %s", index.Type)
|
||||
}
|
||||
indexes = append(indexes, schemamigrator.Index{
|
||||
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, index.JSONDataType.StringValue(), typeIndex),
|
||||
Expression: schemamigrator.JSONSubColumnIndexExpr(parentColumn, it.Path, index.JSONDataType.StringValue()),
|
||||
Type: index.Type,
|
||||
Granularity: index.Granularity,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(toInsert) > 0 {
|
||||
err := m.PromotePaths(ctx, toInsert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(indexes) > 0 {
|
||||
if err := m.createIndexes(ctx, indexes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package promote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/promotetypes"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetypes.PromotePath, error)
|
||||
PromoteAndIndexPaths(ctx context.Context, paths ...*promotetypes.PromotePath) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
HandlePromoteAndIndexPaths(w http.ResponseWriter, r *http.Request)
|
||||
ListPromotedAndIndexedPaths(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/traces/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/common"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
|
||||
chErrors "github.com/SigNoz/signoz/pkg/query-service/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/metrics"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
@@ -96,6 +95,7 @@ const (
|
||||
signozLocalTableAttributesMetadata = "attributes_metadata"
|
||||
|
||||
signozUpdatedMetricsMetadataLocalTable = "updated_metadata"
|
||||
signozMetricsMetadataLocalTable = "metadata"
|
||||
signozUpdatedMetricsMetadataTable = "distributed_updated_metadata"
|
||||
minTimespanForProgressiveSearch = time.Hour
|
||||
minTimespanForProgressiveSearchMargin = time.Minute
|
||||
@@ -6440,6 +6440,73 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
|
||||
return cachedMetadata, nil
|
||||
}
|
||||
|
||||
// GetFirstSeenFromMetricMetadata queries the metadata table to get the first_seen timestamp
|
||||
// for each metric-attribute-value combination.
|
||||
// Returns a map where key is `model.MetricMetadataLookupKey` and value is first_seen in milliseconds.
|
||||
func (r *ClickHouseReader) GetFirstSeenFromMetricMetadata(ctx context.Context, lookupKeys []model.MetricMetadataLookupKey) (map[model.MetricMetadataLookupKey]int64, error) {
|
||||
// Chunk the lookup keys to avoid overly large queries (max 300 tuples per query)
|
||||
const chunkSize = 300
|
||||
result := make(map[model.MetricMetadataLookupKey]int64)
|
||||
|
||||
for i := 0; i < len(lookupKeys); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lookupKeys) {
|
||||
end = len(lookupKeys)
|
||||
}
|
||||
chunk := lookupKeys[i:end]
|
||||
|
||||
// Build the IN clause values - ClickHouse uses tuple syntax with placeholders
|
||||
var valueStrings []string
|
||||
var args []interface{}
|
||||
|
||||
for _, key := range chunk {
|
||||
valueStrings = append(valueStrings, "(?, ?, ?)")
|
||||
args = append(args, key.MetricName, key.AttributeName, key.AttributeValue)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
m.metric_name,
|
||||
m.attr_name,
|
||||
m.attr_string_value,
|
||||
min(m.last_reported_unix_milli) AS first_seen
|
||||
FROM %s.%s AS m
|
||||
WHERE (m.metric_name, m.attr_name, m.attr_string_value) IN (%s)
|
||||
GROUP BY m.metric_name, m.attr_name, m.attr_string_value
|
||||
ORDER BY first_seen`,
|
||||
signozMetricDBName, signozMetricsMetadataLocalTable, strings.Join(valueStrings, ", "))
|
||||
|
||||
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
|
||||
rows, err := r.db.Query(valueCtx, query, args...)
|
||||
if err != nil {
|
||||
zap.L().Error("Error querying metadata for first_seen", zap.Error(err))
|
||||
return nil, &model.ApiError{Typ: "ClickhouseErr", Err: fmt.Errorf("error querying metadata for first_seen: %v", err)}
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var metricName, attrName, attrValue string
|
||||
var firstSeen uint64
|
||||
if err := rows.Scan(&metricName, &attrName, &attrValue, &firstSeen); err != nil {
|
||||
rows.Close()
|
||||
return nil, &model.ApiError{Typ: "ClickhouseErr", Err: fmt.Errorf("error scanning metadata first_seen result: %v", err)}
|
||||
}
|
||||
result[model.MetricMetadataLookupKey{
|
||||
MetricName: metricName,
|
||||
AttributeName: attrName,
|
||||
AttributeValue: attrValue,
|
||||
}] = int64(firstSeen)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, &model.ApiError{Typ: "ClickhouseErr", Err: fmt.Errorf("error iterating metadata first_seen results: %v", err)}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) SearchTraces(ctx context.Context, params *model.SearchTracesParams) (*[]model.SearchSpansResult, error) {
|
||||
searchSpansResult := []model.SearchSpansResult{
|
||||
{
|
||||
|
||||
@@ -555,7 +555,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v1/settings/ttl", am.ViewAccess(aH.getTTL)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/settings/ttl", am.AdminAccess(aH.setCustomRetentionTTL)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v2/settings/ttl", am.ViewAccess(aH.getCustomRetentionTTL)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/settings/apdex", am.AdminAccess(aH.Signoz.Handlers.Apdex.Set)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/settings/apdex", am.ViewAccess(aH.Signoz.Handlers.Apdex.Get)).Methods(http.MethodGet)
|
||||
|
||||
@@ -1414,6 +1413,10 @@ func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
if apiErr, ok := err.(*model.ApiError); ok {
|
||||
RespondError(w, apiErr, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
@@ -1444,6 +1447,10 @@ func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
if apiErr, ok := err.(*model.ApiError); ok {
|
||||
RespondError(w, apiErr, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@ func (h *HostsRepo) GetHostList(ctx context.Context, orgID valuer.UUID, req mode
|
||||
if _, ok := hostAttrs[record.HostName]; ok {
|
||||
record.Meta = hostAttrs[record.HostName]
|
||||
}
|
||||
if osType, ok := record.Meta[GetDotMetrics("os_type")]; ok {
|
||||
if osType, ok := record.Meta["os_type"]; ok {
|
||||
record.OS = osType
|
||||
}
|
||||
record.Active = activeHosts[record.HostName]
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_getClickhouseKey(t *testing.T) {
|
||||
@@ -1211,8 +1210,9 @@ func TestPrepareLogsQuery(t *testing.T) {
|
||||
t.Errorf("PrepareLogsQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want, got)
|
||||
if got != tt.want {
|
||||
t.Errorf("PrepareLogsQuery() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ func makeRulesManager(
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SqlStore: sqlstore,
|
||||
QueryParser: queryParser,
|
||||
}
|
||||
|
||||
// create Manager
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package converter
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
// Unit represents a unit of measurement
|
||||
type Unit string
|
||||
|
||||
func (u Unit) Validate() error {
|
||||
if !IsValidUnit(u) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid unit: %s", u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value represents a value with a unit of measurement
|
||||
type Value struct {
|
||||
F float64
|
||||
@@ -60,6 +69,27 @@ func FromUnit(u Unit) Converter {
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidUnit returns true if the given unit is valid
|
||||
func IsValidUnit(u Unit) bool {
|
||||
switch u {
|
||||
// Duration unit
|
||||
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min",
|
||||
// Data unit
|
||||
"bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy",
|
||||
// Data rate unit
|
||||
"binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s",
|
||||
// Percent unit
|
||||
"percent", "percentunit", "%",
|
||||
// Bool unit
|
||||
"bool", "bool_yes_no", "bool_true_false", "bool_1_0",
|
||||
// Throughput unit
|
||||
"cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm", "{count}/s", "{ops}/s", "{req}/s", "{read}/s", "{write}/s", "{iops}/s", "{count}/min", "{ops}/min", "{read}/min", "{write}/min":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func UnitToName(u string) string {
|
||||
switch u {
|
||||
case "ns":
|
||||
|
||||
@@ -81,6 +81,7 @@ type Reader interface {
|
||||
CheckClickHouse(ctx context.Context) error
|
||||
|
||||
GetMetricMetadata(context.Context, valuer.UUID, string, string) (*v3.MetricMetadataResponse, error)
|
||||
GetFirstSeenFromMetricMetadata(ctx context.Context, lookupKeys []model.MetricMetadataLookupKey) (map[model.MetricMetadataLookupKey]int64, error)
|
||||
|
||||
AddRuleStateHistory(ctx context.Context, ruleStateHistory []model.RuleStateHistory) error
|
||||
GetOverallStateTransitions(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.ReleStateItem, error)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user