Compare commits

...

33 Commits

Author SHA1 Message Date
Abhi kumar
abeadc7672 fix: backward compatibility for explorer in case of aggregateAttribute is not present (#9000) 2025-09-04 13:21:16 +00:00
SagarRajput-7
faadc60c74 fix: fixed table panels not sorting, due to mismatch in lookup (id vs name) for aggregations (#9002)
* fix: fixed table panels not sorting, due to mismatch in id for aggregations

* fix: added test cases for the sort and util for qbv5 aggregation
2025-09-04 18:44:38 +05:30
Vibhu Pandey
360e8309c8 feat(password): implement strong controls for password (#8983)
## 📄 Summary

implement strong controls for password. Now the password requirement is : 

password must be at least 12 characters long, should contain at least one uppercase letter [A-Z], one lowercase letter [a-z], one number [0-9], and one symbol
2025-09-04 17:22:28 +05:30
SagarRajput-7
27580b62ba fix: fixed full view height for table panel (#9004) 2025-09-04 10:40:30 +00:00
SagarRajput-7
bcd21cee74 fix: y axis unit not interactive in the panel edit mode (#9003) 2025-09-04 16:00:03 +05:30
Vikrant Gupta
2dbe0777f4 feat(authz): add openfga authz middleware (#8990)
* feat(authz): add openfga authz middleware

* feat(authz): update the auth context

* feat(authz): update the auth context

* feat(authz): update check request

* feat(authz): update check request

* feat(authz): add lifecycle tests

* feat(authz): add lifecycle tests

* feat(authz): add start-stop tests
2025-09-04 08:37:11 +00:00
SagarRajput-7
7602d863dd fix: fixed table panel no scroll issue due to style override from calender component (#8996) 2025-09-04 09:55:46 +05:30
Abhi kumar
68d9c6c3cc fix: added fix for incorrect query in dashboard on panel change to list panel (#8994)
* fix: added fix for incorrect query in dashboard on panel change to list issue

* test: added test for handleQueryChange

* chore: pr reviews
2025-09-03 14:00:22 +00:00
manika-signoz
10c6e1fac7 feat: add delete button to invite user flow (#8993) 2025-09-03 19:07:35 +05:30
Shaheer Kochai
3999a64c64 feat: add pinning functionality for span attributes (#8769)
* refactor: adjust the attribute pinning changes based on trace actionables latest changes

* feat: persist pinned attributes on the BE, fallback to local storage

* chore: overall improvement

* chore: fix the failing tests

* fix: make the changes w.r.t. pinned attributes preferences in in preference.go
2025-09-03 16:36:10 +04:30
Vikrant Gupta
729bfb31f1 feat(authz): implement the current usecases in openfga (#8982)
* feat(authz): implement the current usecases in openfga

* feat(authz): implement the current usecases in openfga

* feat(authz): extract out the schema and DI the same

* feat(authz): extract out the schema and DI the same
2025-09-02 11:00:47 +00:00
Nityananda Gohain
052fb8b703 fix: support canDefaultZero for logs and traces (#8973)
* fix: support canDefaultZero for logs and traces

* fix: remove increase

* fix: move changes to req.go

* fix: add tood

* fix: address comments
2025-09-02 09:54:55 +00:00
nikhilmantri0902
5d9247f591 Allow deletion of multiple panels for dashboard updates made with API key (#8903) 2025-09-02 09:30:22 +00:00
Yunus M
c0a9948146 feat: handle active log flow (#8946)
* feat: handle active log flow

* feat: show live logs in logs explorer view

* feat: enable live logs in logs explorer

* feat: show live time option only in logs list view

* chore: pass showLiveLogs as false in test cases

* fix: handle live logs data format to open in log details

* fix: use current query state for frequency chart in live logs view

* fix: encode filter expression, show live option only in list view
2025-09-02 11:05:52 +05:30
Vikrant Gupta
f3569a9a02 chore(meter): remove the meter data validity message for non cloud users (#8972) 2025-09-01 16:18:47 +00:00
Vikrant Gupta
0df1ed3b57 fix(dashboard): remove context from dashboard types (#8971) 2025-09-01 16:08:25 +00:00
Nityananda Gohain
d0132f11ae fix: update clickhouse-sql-parser (#8970) 2025-09-01 13:25:27 +00:00
Vikrant Gupta
f61e859901 feat(authz): embed openfga server (#8966)
* feat(access-control): embed openfga in signoz

* feat(authz): rename access control to authz

* feat(authz): fix codeowners and go mod tidy

* feat(authz): fix lint

* feat(authz): update go version and move convertor to instrumentation

* feat(authz): some more lint issues

* feat(authz): some more lint issues

* feat(authz): some more lint issues

* feat(authz): fix more lint issues

* feat(authz): make logger converter interface
2025-09-01 17:10:13 +05:30
Ekansh Gupta
4daec45d98 feat: added custom retention for logs api (#8513)
* feat: added custom retention for logs api

* feat: added custom retention for logs api

* feat: added implementation of trace operators

* feat: added validation checks for resource keys

* feat: added validation checks for resource keys

* feat: added validation checks for resource keys

* feat: added validation checks for resource keys

* feat: added validation checks for resource keys

* feat: added validation checks for resource keys

* feat: added validation checks for resource keys

* feat: added validation checks for resource keys

* feat: added default checks for custom retention

* feat: added default checks for custom retention

* feat: added default checks for custom retention

* feat: added change for ttl

* feat: v2 api supports both v1 and v2

* feat: v2 api supports both v1 and v2

* feat: v2 api supports both v1 and v2

* feat: v2 api supports both v1 and v2

* feat: added default_ttl in v1

* feat: added set logs ttl v1 from v2

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts
2025-09-01 07:30:57 +00:00
Ekansh Gupta
382d9d4a87 Revert "3rd party API sem conv fix (Supports >1.26) (#8822)" (#8954)
This reverts commit 396e0cdc2d.
2025-09-01 07:20:56 +00:00
Nityananda Gohain
87ce197631 fix: don't skip resource filter in main table for OR queries (#8958)
* fix: don't skip resource filter in main table for OR queries

* fix: dont skip resource table

* fix: make check case insensitive

* fix: iterate over token stream
2025-08-30 23:16:56 +05:30
aniketio-ctrl
3cc5a24a4b fix: return 404 status code if rule not found (#8940)
* fix(rule-alert-state): added 404 status code for invalid rules
2025-08-30 12:32:03 +05:30
Yunus M
9b8a892079 chore: use infinity table for logs column view (#8953) 2025-08-29 16:12:27 +05:30
Ekansh Gupta
396e0cdc2d 3rd party API sem conv fix (Supports >1.26) (#8822)
* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: added native support for 1.26

* feat: added native support for 1.26

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts
2025-08-29 09:50:16 +00:00
Abhi kumar
c838d7e2d4 chore: added api chagnes for logs retenetion v2 api (#8649)
* chore: added api chagnes for logs retenetion v2 api

* chore: added pr review fixes

* chore: minor fix

* feat: added api changes for setting retention period

* chore: pr review fixes

* chore: removed return statement

* fix: pr reviews

* fix: pr review
2025-08-29 15:10:15 +05:30
Nityananda Gohain
1a193fb1a9 fix: update email template for update role (#8610)
* fix: update email template for update role

* fix: remove space
2025-08-29 06:51:03 +00:00
Yunus M
88dff3f552 feat: minor ui updates (#8947) 2025-08-29 11:36:32 +05:30
Nityananda Gohain
5bb6d78c42 fix: remove isRoot and Entrypoint from selectfields (#8893)
* fix: remove isRoot and Entrypoint from selectfields

* fix: add comment

* fix: add comment

* fix: move logic to validation

* fix: remove requestType trace

* fix: update comment

* fix: update error message
2025-08-29 05:46:16 +00:00
Yunus M
369f77977d feat: use update props from data table component for better UX (#8950) 2025-08-29 10:06:43 +05:30
Vikrant Gupta
836605def5 feat(ingestion): add ingestion id to details (#8949) 2025-08-29 00:47:42 +05:30
Yunus M
cc80923265 feat: show timestamp in selected timezone format (#8948) 2025-08-29 00:18:05 +05:30
Yunus M
92e5986af2 feat: replace infinity list view component with data table component (#8904)
* feat: replace infinity list view component with data table component

* feat: remove duplicate hook calls

* chore: add @signozhq/table to transformIgnorePatterns

* feat: update test cases for frequency chart in logs explorer

* feat: address review comments , add sonner for notifications

* feat: address review comments
2025-08-28 19:03:29 +05:30
Abhi kumar
912a34da8d fix: added fix for context query not getting updated (#8941) 2025-08-28 15:14:18 +05:30
179 changed files with 6210 additions and 1403 deletions

4
.github/CODEOWNERS vendored
View File

@@ -42,3 +42,7 @@
/pkg/telemetrymetadata/ @srikanthccv
/pkg/telemetrymetrics/ @srikanthccv
/pkg/telemetrytraces/ @srikanthccv
# AuthN / AuthZ Owners
/pkg/authz/ @vikrantgupta25 @grandwizard28

View File

@@ -62,7 +62,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.23
GO_VERSION: 1.24
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build

View File

@@ -93,7 +93,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.23
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -92,7 +92,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.23
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -18,7 +18,7 @@ jobs:
with:
PRIMUS_REF: main
GO_TEST_CONTEXT: ./...
GO_VERSION: 1.23
GO_VERSION: 1.24
fmt:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
@@ -27,7 +27,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.23
GO_VERSION: 1.24
lint:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
@@ -36,7 +36,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.23
GO_VERSION: 1.24
deps:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
@@ -45,7 +45,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.23
GO_VERSION: 1.24
build:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
@@ -57,7 +57,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version: "1.24"
- name: qemu-install
uses: docker/setup-qemu-action@v3
- name: aarch64-install

View File

@@ -58,7 +58,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -122,7 +122,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -72,7 +72,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -135,7 +135,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version: "1.24"
# copy the caches from build
- name: get-sha

2
.gitignore vendored
View File

@@ -86,6 +86,8 @@ queries.active
.devenv/**/tmp/**
.qodo
.dev
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -2,10 +2,11 @@ FROM node:18-bullseye AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 yarn install
RUN CI=1 yarn build
FROM golang:1.23-bullseye
FROM golang:1.24-bullseye
ARG OS="linux"
ARG TARGETARCH

View File

@@ -13,11 +13,11 @@ import (
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/gorilla/mux"
"go.uber.org/zap"
)
@@ -192,14 +192,14 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
))
}
password, err := types.NewFactorPassword(uuid.NewString())
password := types.MustGenerateFactorPassword(newUser.ID.StringValue())
integrationUser, err := ah.Signoz.Modules.User.CreateUserWithPassword(ctx, newUser, password)
err = ah.Signoz.Modules.User.CreateUser(ctx, newUser, user.WithFactorPassword(password))
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}
return integrationUser, nil
return newUser, nil
}
func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (

View File

@@ -1,7 +1,7 @@
package model
import (
"fmt"
"errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
)
@@ -57,7 +57,7 @@ func Unauthorized(err error) *ApiError {
func BadRequestStr(s string) *ApiError {
return &ApiError{
Typ: basemodel.ErrorBadData,
Err: fmt.Errorf(s),
Err: errors.New(s),
}
}
@@ -73,7 +73,7 @@ func InternalError(err error) *ApiError {
func InternalErrorStr(s string) *ApiError {
return &ApiError{
Typ: basemodel.ErrorInternal,
Err: fmt.Errorf(s),
Err: errors.New(s),
}
}

View File

@@ -26,7 +26,7 @@ const config: Config.InitialOptions = {
'^.+\\.(js|jsx)$': 'babel-jest',
},
transformIgnorePatterns: [
'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|@signozhq/calendar|@signozhq/input|@signozhq/popover|@signozhq/button|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn)/)',
'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|@signozhq/table|@signozhq/calendar|@signozhq/input|@signozhq/popover|@signozhq/button|@signozhq/sonner|@signozhq/*|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn)/)',
],
setupFilesAfterEnv: ['<rootDir>jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -48,6 +48,9 @@
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -0,0 +1,25 @@
import { ApiV2Instance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps } from 'types/api/settings/getRetention';
// Only works for logs
const getRetentionV2 = async (): Promise<
SuccessResponseV2<PayloadProps<'logs'>>
> => {
try {
const response = await ApiV2Instance.get<PayloadProps<'logs'>>(
`/settings/ttl`,
);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getRetentionV2;

View File

@@ -1,14 +1,14 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/settings/setRetention';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadPropsV2, Props } from 'types/api/settings/setRetention';
const setRetention = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
): Promise<SuccessResponseV2<PayloadPropsV2>> => {
try {
const response = await axios.post<PayloadProps>(
const response = await axios.post<PayloadPropsV2>(
`/settings/ttl?duration=${props.totalDuration}&type=${props.type}${
props.coldStorage
? `&coldStorage=${props.coldStorage}&toColdDuration=${props.toColdDuration}`
@@ -17,13 +17,11 @@ const setRetention = async (
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@@ -0,0 +1,32 @@
import { ApiV2Instance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadPropsV2, PropsV2 } from 'types/api/settings/setRetention';
const setRetentionV2 = async ({
type,
defaultTTLDays,
coldStorageVolume,
coldStorageDuration,
ttlConditions,
}: PropsV2): Promise<SuccessResponseV2<PayloadPropsV2>> => {
try {
const response = await ApiV2Instance.post<PayloadPropsV2>(`/settings/ttl`, {
type,
defaultTTLDays,
coldStorageVolume,
coldStorageDuration,
ttlConditions,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default setRetentionV2;

View File

@@ -20,13 +20,15 @@
.ant-card-body {
height: calc(100% - 18px);
.widget-graph-container {
&.bar {
height: calc(100% - 110px);
}
.widget-graph-component-container {
.widget-graph-container {
&.bar-panel-container {
height: calc(100% - 110px);
}
&.graph {
height: calc(100% - 80px);
&.graph-panel-container {
height: calc(100% - 80px);
}
}
}
}
@@ -82,9 +84,11 @@
.ant-card-body {
height: calc(100% - 18px);
.widget-graph-container {
&.bar {
height: calc(100% - 110px);
.widget-graph-component-container {
.widget-graph-container {
&.bar-panel-container {
height: calc(100% - 110px);
}
}
}
}

View File

@@ -174,6 +174,31 @@
cursor: pointer;
}
.time-input-prefix {
.live-dot-icon {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--bg-forest-500);
animation: ripple 1s infinite;
margin-right: 4px;
margin-left: 4px;
}
}
@keyframes ripple {
0% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
}
}
.time-input-suffix-icon-badge {
display: flex;
align-items: center;

View File

@@ -59,7 +59,9 @@ interface CustomTimePickerProps {
customDateTimeVisible?: boolean;
setCustomDTPickerVisible?: Dispatch<SetStateAction<boolean>>;
onCustomDateHandler?: (dateTimeRange: DateTimeRangeType) => void;
handleGoLive?: () => void;
showLiveLogs?: boolean;
onGoLive?: () => void;
onExitLiveLogs?: () => void;
}
function CustomTimePicker({
@@ -76,7 +78,9 @@ function CustomTimePicker({
customDateTimeVisible,
setCustomDTPickerVisible,
onCustomDateHandler,
handleGoLive,
onGoLive,
onExitLiveLogs,
showLiveLogs,
}: CustomTimePickerProps): JSX.Element {
const [
selectedTimePlaceholderValue,
@@ -165,9 +169,13 @@ function CustomTimePicker({
};
useEffect(() => {
const value = getSelectedTimeRangeLabel(selectedTime, selectedValue);
setSelectedTimePlaceholderValue(value);
}, [selectedTime, selectedValue]);
if (showLiveLogs) {
setSelectedTimePlaceholderValue('Live');
} else {
const value = getSelectedTimeRangeLabel(selectedTime, selectedValue);
setSelectedTimePlaceholderValue(value);
}
}, [selectedTime, selectedValue, showLiveLogs]);
const hide = (): void => {
setOpen(false);
@@ -338,6 +346,28 @@ function CustomTimePicker({
return '';
};
const getInputPrefix = (): JSX.Element => {
if (showLiveLogs) {
return (
<div className="time-input-prefix">
<div className="live-dot-icon" />
</div>
);
}
return (
<div className="time-input-prefix">
{inputValue && inputStatus === 'success' ? (
<CheckCircle size={14} color="#51E7A8" />
) : (
<Tooltip title="Enter time in format (e.g., 1m, 2h, 3d, 4w)">
<Clock size={14} className="cursor-pointer" />
</Tooltip>
)}
</div>
);
};
return (
<div className="custom-time-picker">
<Tooltip title={getTooltipTitle()} placement="top">
@@ -357,7 +387,8 @@ function CustomTimePicker({
setCustomDTPickerVisible={defaultTo(setCustomDTPickerVisible, noop)}
onCustomDateHandler={defaultTo(onCustomDateHandler, noop)}
onSelectHandler={handleSelect}
handleGoLive={defaultTo(handleGoLive, noop)}
onGoLive={defaultTo(onGoLive, noop)}
onExitLiveLogs={defaultTo(onExitLiveLogs, noop)}
options={items}
selectedTime={selectedTime}
activeView={activeView}
@@ -392,17 +423,7 @@ function CustomTimePicker({
onBlur={handleBlur}
onChange={handleInputChange}
data-1p-ignore
prefix={
<div className="time-input-prefix">
{inputValue && inputStatus === 'success' ? (
<CheckCircle size={14} color="#51E7A8" />
) : (
<Tooltip title="Enter time in format (e.g., 1m, 2h, 3d, 4w)">
<Clock size={14} className="cursor-pointer" />
</Tooltip>
)}
</div>
}
prefix={getInputPrefix()}
suffix={
<div className="time-input-suffix">
{!!isTimezoneOverridden && activeTimezoneOffset && (
@@ -439,6 +460,8 @@ CustomTimePicker.defaultProps = {
customDateTimeVisible: false,
setCustomDTPickerVisible: noop,
onCustomDateHandler: noop,
handleGoLive: noop,
onGoLive: noop,
onCustomTimeStatusUpdate: noop,
onExitLiveLogs: noop,
showLiveLogs: false,
};

View File

@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import DatePickerV2 from 'components/DatePickerV2/DatePickerV2';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import {
@@ -16,7 +17,14 @@ import {
import dayjs from 'dayjs';
import { Clock, PenLine } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { getCustomTimeRanges } from 'utils/customTimeRangeUtils';
@@ -32,12 +40,13 @@ interface CustomTimePickerPopoverContentProps {
lexicalContext?: LexicalContext,
) => void;
onSelectHandler: (label: string, value: string) => void;
handleGoLive: () => void;
onGoLive: () => void;
selectedTime: string;
activeView: 'datetime' | 'timezone';
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
isOpenedFromFooter: boolean;
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
onExitLiveLogs: () => void;
}
interface RecentlyUsedDateTimeRange {
@@ -56,12 +65,13 @@ function CustomTimePickerPopoverContent({
setCustomDTPickerVisible,
onCustomDateHandler,
onSelectHandler,
handleGoLive,
onGoLive,
selectedTime,
activeView,
setActiveView,
isOpenedFromFooter,
setIsOpenedFromFooter,
onExitLiveLogs,
}: CustomTimePickerPopoverContentProps): JSX.Element {
const { pathname } = useLocation();
@@ -69,6 +79,19 @@ function CustomTimePickerPopoverContent({
pathname,
]);
const url = new URLSearchParams(window.location.search);
let panelTypeFromURL = url.get(QueryParams.panelTypes);
try {
panelTypeFromURL = JSON.parse(panelTypeFromURL as string);
} catch {
// fallback → leave as-is
}
const isLogsListView =
panelTypeFromURL !== 'table' && panelTypeFromURL !== 'graph'; // we do not select list view in the url
const { timezone } = useTimezone();
const activeTimezoneOffset = timezone.offset;
@@ -76,6 +99,12 @@ function CustomTimePickerPopoverContent({
RecentlyUsedDateTimeRange[]
>([]);
const handleExitLiveLogs = useCallback((): void => {
if (isLogsExplorerPage) {
onExitLiveLogs();
}
}, [isLogsExplorerPage, onExitLiveLogs]);
useEffect(() => {
if (!customDateTimeVisible) {
const customTimeRanges = getCustomTimeRanges();
@@ -107,6 +136,7 @@ function CustomTimePickerPopoverContent({
className="time-btns"
key={option.label + option.value}
onClick={(): void => {
handleExitLiveLogs();
onSelectHandler(option.label, option.value);
}}
>
@@ -140,12 +170,17 @@ function CustomTimePickerPopoverContent({
);
}
const handleGoLive = (): void => {
onGoLive();
setIsOpen(false);
};
return (
<>
<div className="date-time-popover">
{!customDateTimeVisible && (
<div className="date-time-options">
{isLogsExplorerPage && (
{isLogsExplorerPage && isLogsListView && (
<Button className="data-time-live" type="text" onClick={handleGoLive}>
Live
</Button>
@@ -155,6 +190,7 @@ function CustomTimePickerPopoverContent({
type="text"
key={option.label + option.value}
onClick={(): void => {
handleExitLiveLogs();
onSelectHandler(option.label, option.value);
}}
className={cx(
@@ -169,7 +205,6 @@ function CustomTimePickerPopoverContent({
))}
</div>
)}
<div
className={cx(
'relative-date-time',
@@ -199,12 +234,14 @@ function CustomTimePickerPopoverContent({
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}
}}
key={range.value}
onClick={(): void => {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}}

View File

@@ -23,6 +23,7 @@ import {
} from 'container/LogDetailedView/utils';
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
@@ -39,7 +40,7 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useCopyToClipboard, useLocation } from 'react-use';
import { AppState } from 'store/reducers';
@@ -94,6 +95,8 @@ function LogDetailInner({
const { notifications } = useNotifications();
const { onLogCopy } = useCopyLogLink(log?.id);
const LogJsonData = log ? aggregateAttributesResourcesToString(log) : '';
const handleModeChange = (e: RadioChangeEvent): void => {
@@ -146,6 +149,34 @@ function LogDetailInner({
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`);
};
const handleQueryExpressionChange = useCallback(
(value: string, queryIndex: number) => {
// update the query at the given index
setContextQuery((prev) => {
if (!prev) return prev;
return {
...prev,
builder: {
...prev.builder,
queryData: prev.builder.queryData.map((query, idx) =>
idx === queryIndex
? {
...query,
filter: {
...query.filter,
expression: value,
},
}
: query,
),
},
};
});
},
[],
);
const handleRunQuery = (expression: string): void => {
let updatedContextQuery = cloneDeep(contextQuery);
@@ -305,11 +336,19 @@ function LogDetailInner({
onClick={handleFilterVisible}
/>
)}
<Tooltip title="Copy Log Link" placement="left" aria-label="Copy Log Link">
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={onLogCopy}
/>
</Tooltip>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(): void => {}}
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}

View File

@@ -56,6 +56,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
.map(({ name }) => ({
title: name,
dataIndex: name,
accessorKey: name,
id: name.toLowerCase().replace(/\./g, '_'),
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
@@ -83,7 +85,10 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
// We do not need any title and data index for the log state indicator
title: '',
dataIndex: '',
// eslint-disable-next-line sonarjs/no-duplicate-string
key: 'state-indicator',
accessorKey: 'state-indicator',
id: 'state-indicator',
render: (_, item): ColumnTypeRender<Record<string, unknown>> => ({
children: (
<div className={cx('state-indicator', fontSize)}>
@@ -101,6 +106,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
title: 'timestamp',
dataIndex: 'timestamp',
key: 'timestamp',
accessorKey: 'timestamp',
id: 'timestamp',
// https://github.com/ant-design/ant-design/discussions/36886
render: (
field: string | number,
@@ -135,6 +142,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
title: 'body',
dataIndex: 'body',
key: 'body',
accessorKey: 'body',
id: 'body',
render: (
field: string | number,
): ColumnTypeRender<Record<string, unknown>> => ({

View File

@@ -1,10 +1,16 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable import/no-unresolved */
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
convertAggregationToExpression,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
} from '../utils';
@@ -769,3 +775,200 @@ describe('convertFiltersToExpression', () => {
expect(result.filter.expression).toBe("service.name = 'old-service'");
});
});
describe('convertAggregationToExpression', () => {
const mockAttribute: BaseAutocompleteData = {
id: 'test-id',
key: 'test_metric',
type: 'string',
dataType: DataTypes.String,
};
it('should return undefined when no aggregateOperator is provided', () => {
const result = convertAggregationToExpression({
aggregateOperator: '',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
});
expect(result).toBeUndefined();
});
it('should convert metrics aggregation with required temporality field', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
timeAggregation: 'avg',
spaceAggregation: 'max',
alias: 'test_alias',
reduceTo: 'sum',
temporality: 'delta',
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'avg',
spaceAggregation: 'max',
reduceTo: 'sum',
temporality: 'delta',
},
]);
});
it('should handle noop operators by converting to count', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'noop',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
timeAggregation: 'noop',
spaceAggregation: 'noop',
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'count',
spaceAggregation: 'count',
},
]);
});
it('should handle missing attribute key gracefully', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: { ...mockAttribute, key: '' },
dataSource: DataSource.METRICS,
});
expect(result).toEqual([
{
metricName: '',
timeAggregation: 'sum',
spaceAggregation: 'sum',
},
]);
});
it('should convert traces aggregation to expression format', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'count',
aggregateAttribute: mockAttribute,
dataSource: DataSource.TRACES,
alias: 'trace_alias',
});
expect(result).toEqual([
{
expression: 'count(test_metric)',
alias: 'trace_alias',
},
]);
});
it('should convert logs aggregation to expression format', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'avg',
aggregateAttribute: mockAttribute,
dataSource: DataSource.LOGS,
alias: 'log_alias',
});
expect(result).toEqual([
{
expression: 'avg(test_metric)',
alias: 'log_alias',
},
]);
});
it('should handle aggregation without attribute key for traces/logs', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'count',
aggregateAttribute: { ...mockAttribute, key: '' },
dataSource: DataSource.TRACES,
});
expect(result).toEqual([
{
expression: 'count()',
},
]);
});
it('should handle missing alias for traces/logs', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: mockAttribute,
dataSource: DataSource.LOGS,
});
expect(result).toEqual([
{
expression: 'sum(test_metric)',
},
]);
});
it('should use aggregateOperator as fallback for time and space aggregation', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'max',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'max',
spaceAggregation: 'max',
},
]);
});
it('should handle undefined aggregateAttribute parameter with metrics', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'sum',
spaceAggregation: 'sum',
reduceTo: undefined,
temporality: undefined,
},
]);
});
it('should handle undefined aggregateAttribute parameter with traces', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'noop',
aggregateAttribute: (undefined as unknown) as BaseAutocompleteData,
dataSource: DataSource.TRACES,
});
expect(result).toEqual([
{
expression: 'count()',
},
]);
});
it('should handle undefined aggregateAttribute parameter with logs', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'noop',
aggregateAttribute: (undefined as unknown) as BaseAutocompleteData,
dataSource: DataSource.LOGS,
});
expect(result).toEqual([
{
expression: 'count()',
},
]);
});
});

View File

@@ -22,7 +22,7 @@ import {
TraceAggregation,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { unquote } from 'utils/stringUtils';
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
@@ -580,14 +580,25 @@ export const convertHavingToExpression = (
* @returns New aggregation format based on data source
*
*/
export const convertAggregationToExpression = (
aggregateOperator: string,
aggregateAttribute: BaseAutocompleteData,
dataSource: DataSource,
timeAggregation?: string,
spaceAggregation?: string,
alias?: string,
): (TraceAggregation | LogAggregation | MetricAggregation)[] | undefined => {
export const convertAggregationToExpression = ({
aggregateOperator,
aggregateAttribute,
dataSource,
timeAggregation,
spaceAggregation,
alias,
reduceTo,
temporality,
}: {
aggregateOperator: string;
aggregateAttribute: BaseAutocompleteData;
dataSource: DataSource;
timeAggregation?: string;
spaceAggregation?: string;
alias?: string;
reduceTo?: ReduceOperators;
temporality?: string;
}): (TraceAggregation | LogAggregation | MetricAggregation)[] | undefined => {
// Skip if no operator or attribute key
if (!aggregateOperator) {
return undefined;
@@ -605,7 +616,9 @@ export const convertAggregationToExpression = (
if (dataSource === DataSource.METRICS) {
return [
{
metricName: aggregateAttribute.key,
metricName: aggregateAttribute?.key || '',
reduceTo,
temporality,
timeAggregation: (normalizedTimeAggregation || normalizedOperator) as any,
spaceAggregation: (normalizedSpaceAggregation || normalizedOperator) as any,
} as MetricAggregation,
@@ -613,7 +626,9 @@ export const convertAggregationToExpression = (
}
// For traces and logs, use expression format
const expression = `${normalizedOperator}(${aggregateAttribute.key})`;
const expression = aggregateAttribute?.key
? `${normalizedOperator}(${aggregateAttribute?.key})`
: `${normalizedOperator}()`;
if (dataSource === DataSource.TRACES) {
return [

View File

@@ -32,5 +32,7 @@ export enum LOCALSTORAGE {
BANNER_DISMISSED = 'BANNER_DISMISSED',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
}

View File

@@ -2,4 +2,5 @@ export const USER_PREFERENCES = {
SIDENAV_PINNED: 'sidenav_pinned',
NAV_SHORTCUTS: 'nav_shortcuts',
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
};

View File

@@ -48,10 +48,10 @@
line-height: 36px;
}
}
&__graph {
&__alert-history-graph {
margin-top: 80px;
.graph {
.alert-history-graph {
width: 100%;
height: 72px;
}

View File

@@ -135,8 +135,8 @@ function StatsCard({
/>
</div>
<div className="stats-card__graph">
<div className="graph">
<div className="stats-card__alert-history-graph">
<div className="alert-history-graph">
{!isEmpty && timeSeries.length > 1 && (
<StatsGraph timeSeries={timeSeries} changeDirection={changeDirection} />
)}

View File

@@ -4,6 +4,7 @@
import './AppLayout.styles.scss';
import * as Sentry from '@sentry/react';
import { Toaster } from '@signozhq/sonner';
import { Flex } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -852,6 +853,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}
<Toaster />
</Layout>
);
}

View File

@@ -1,6 +1,6 @@
.explorer-options-container {
position: fixed;
bottom: 8px;
bottom: 0px;
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;
@@ -74,6 +74,7 @@
display: flex;
gap: 16px;
z-index: 1;
.ant-select-selector {
padding: 0 !important;
}

View File

@@ -27,7 +27,7 @@
}
.explorer-show-btn {
border-radius: 10px 10px 0px 0px;
border-radius: 6px 6px 0px 0px;
border: 1px solid var(--bg-slate-400);
background: rgba(22, 24, 29, 0.4);
box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.25);

View File

@@ -2,22 +2,30 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Col, Divider, Modal, Row, Spin, Typography } from 'antd';
import setRetentionApi from 'api/settings/setRetention';
import setRetentionApiV2 from 'api/settings/setRetentionV2';
import TextToolTip from 'components/TextToolTip';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { StatusCodes } from 'http-status-codes';
import find from 'lodash-es/find';
import { useAppContext } from 'providers/App/App';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { useInterval } from 'react-use';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
ErrorResponse,
ErrorResponseV2,
SuccessResponse,
SuccessResponseV2,
} from 'types/api';
import {
IDiskType,
PayloadProps as GetDisksPayload,
} from 'types/api/disks/getDisks';
import APIError from 'types/api/error';
import { TTTLType } from 'types/api/settings/common';
import {
PayloadPropsLogs as GetRetentionPeriodLogsPayload,
@@ -127,7 +135,7 @@ function GeneralSettings({
useEffect(() => {
if (logsCurrentTTLValues) {
setLogsTotalRetentionPeriod(logsCurrentTTLValues.logs_ttl_duration_hrs);
setLogsTotalRetentionPeriod(logsCurrentTTLValues.default_ttl_days * 24);
setLogsS3RetentionPeriod(
logsCurrentTTLValues.logs_move_ttl_duration_hrs
? logsCurrentTTLValues.logs_move_ttl_duration_hrs
@@ -336,20 +344,40 @@ function GeneralSettings({
}
try {
onPostApiLoadingHandler(type);
const setTTLResponse = await setRetentionApi({
type,
totalDuration: `${apiCallTotalRetention || -1}h`,
coldStorage: s3Enabled ? 's3' : null,
toColdDuration: `${apiCallS3Retention || -1}h`,
});
let hasSetTTLFailed = false;
if (setTTLResponse.statusCode === 409) {
try {
if (type === 'logs') {
await setRetentionApiV2({
type,
defaultTTLDays: apiCallTotalRetention ? apiCallTotalRetention / 24 : -1, // convert Hours to days
coldStorageVolume: '',
coldStorageDuration: 0,
ttlConditions: [],
});
} else {
await setRetentionApi({
type,
totalDuration: `${apiCallTotalRetention || -1}h`,
coldStorage: s3Enabled ? 's3' : null,
toColdDuration: `${apiCallS3Retention || -1}h`,
});
}
} catch (error) {
hasSetTTLFailed = true;
notifications.error({
message: 'Error',
description: t('retention_request_race_condition'),
placement: 'topRight',
});
if ((error as APIError).getHttpStatusCode() === StatusCodes.CONFLICT) {
notifications.error({
message: 'Error',
description: t('retention_request_race_condition'),
placement: 'topRight',
});
} else {
notifications.error({
message: 'Error',
description: (error as APIError).getErrorMessage(),
placement: 'topRight',
});
}
}
if (type === 'metrics') {
@@ -376,11 +404,14 @@ function GeneralSettings({
logsTtlValuesRefetch();
if (!hasSetTTLFailed)
// Updates the currentTTL Values in order to avoid pushing the same values.
setLogsCurrentTTLValues({
setLogsCurrentTTLValues((prev) => ({
...prev,
logs_ttl_duration_hrs: logsTotalRetentionPeriod || -1,
logs_move_ttl_duration_hrs: logsS3RetentionPeriod || -1,
status: '',
});
default_ttl_days: logsTotalRetentionPeriod
? logsTotalRetentionPeriod / 24 // convert Hours to days
: -1,
}));
}
} catch (error) {
notifications.error({
@@ -399,6 +430,7 @@ function GeneralSettings({
const renderConfig = [
{
name: 'Metrics',
type: 'metrics',
retentionFields: [
{
name: t('total_retention_period'),
@@ -440,6 +472,7 @@ function GeneralSettings({
},
{
name: 'Traces',
type: 'traces',
retentionFields: [
{
name: t('total_retention_period'),
@@ -479,6 +512,7 @@ function GeneralSettings({
},
{
name: 'Logs',
type: 'logs',
retentionFields: [
{
name: t('total_retention_period'),
@@ -537,6 +571,7 @@ function GeneralSettings({
/>
{category.retentionFields.map((retentionField) => (
<Retention
type={category.type as TTTLType}
key={retentionField.name}
text={retentionField.name}
retentionValue={retentionField.value}
@@ -625,7 +660,7 @@ interface GeneralSettingsProps {
ErrorResponse | SuccessResponse<GetRetentionPeriodTracesPayload>
>['refetch'];
logsTtlValuesRefetch: UseQueryResult<
ErrorResponse | SuccessResponse<GetRetentionPeriodLogsPayload>
ErrorResponseV2 | SuccessResponseV2<GetRetentionPeriodLogsPayload>
>['refetch'];
}

View File

@@ -9,6 +9,7 @@ import {
useRef,
useState,
} from 'react';
import { TTTLType } from 'types/api/settings/common';
import {
Input,
@@ -20,11 +21,13 @@ import {
convertHoursValueToRelevantUnit,
SettingPeriod,
TimeUnits,
TimeUnitsValues,
} from './utils';
const { Option } = Select;
function Retention({
type,
retentionValue,
setRetentionValue,
text,
@@ -50,7 +53,9 @@ function Retention({
if (!interacted.current) setSelectTimeUnit(initialTimeUnitValue);
}, [initialTimeUnitValue]);
const menuItems = TimeUnits.map((option) => (
const menuItems = TimeUnits.filter((option) =>
type === 'logs' ? option.value !== TimeUnitsValues.hr : true,
).map((option) => (
<Option key={option.value} value={option.value}>
{option.key}
</Option>
@@ -124,6 +129,7 @@ function Retention({
}
interface RetentionProps {
type: TTTLType;
retentionValue: number | null;
text: string;
setRetentionValue: Dispatch<SetStateAction<number | null>>;

View File

@@ -1,11 +1,13 @@
import { Typography } from 'antd';
import getDisks from 'api/disks/getDisks';
import getRetentionPeriodApi from 'api/settings/getRetention';
import getRetentionPeriodApiV2 from 'api/settings/getRetentionV2';
import Spinner from 'components/Spinner';
import { useAppContext } from 'providers/App/App';
import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { TTTLType } from 'types/api/settings/common';
import { PayloadProps as GetRetentionPeriodAPIPayloadProps } from 'types/api/settings/getRetention';
@@ -15,6 +17,10 @@ type TRetentionAPIReturn<T extends TTTLType> = Promise<
SuccessResponse<GetRetentionPeriodAPIPayloadProps<T>> | ErrorResponse
>;
type TRetentionAPIReturnV2<T extends TTTLType> = Promise<
SuccessResponseV2<GetRetentionPeriodAPIPayloadProps<T>>
>;
function GeneralSettings(): JSX.Element {
const { t } = useTranslation('common');
const { user } = useAppContext();
@@ -36,7 +42,7 @@ function GeneralSettings(): JSX.Element {
queryKey: ['getRetentionPeriodApiTraces', user?.accessJwt],
},
{
queryFn: (): TRetentionAPIReturn<'logs'> => getRetentionPeriodApi('logs'),
queryFn: (): TRetentionAPIReturnV2<'logs'> => getRetentionPeriodApiV2(), // Only works for logs
queryKey: ['getRetentionPeriodApiLogs', user?.accessJwt],
},
{
@@ -70,7 +76,7 @@ function GeneralSettings(): JSX.Element {
if (getRetentionPeriodLogsApiResponse.isError || getDisksResponse.isError) {
return (
<Typography>
{getRetentionPeriodLogsApiResponse.data?.error ||
{(getRetentionPeriodLogsApiResponse.error as APIError).getErrorMessage() ||
getDisksResponse.data?.error ||
t('something_went_wrong')}
</Typography>
@@ -86,7 +92,7 @@ function GeneralSettings(): JSX.Element {
getRetentionPeriodTracesApiResponse.isLoading ||
!getRetentionPeriodTracesApiResponse.data?.payload ||
getRetentionPeriodLogsApiResponse.isLoading ||
!getRetentionPeriodLogsApiResponse.data?.payload
!getRetentionPeriodLogsApiResponse.data?.data
) {
return <Spinner tip="Loading.." height="70vh" />;
}
@@ -99,7 +105,7 @@ function GeneralSettings(): JSX.Element {
metricsTtlValuesRefetch: getRetentionPeriodMetricsApiResponse.refetch,
tracesTtlValuesPayload: getRetentionPeriodTracesApiResponse.data?.payload,
tracesTtlValuesRefetch: getRetentionPeriodTracesApiResponse.refetch,
logsTtlValuesPayload: getRetentionPeriodLogsApiResponse.data?.payload,
logsTtlValuesPayload: getRetentionPeriodLogsApiResponse.data?.data,
logsTtlValuesRefetch: getRetentionPeriodLogsApiResponse.refetch,
}}
/>

View File

@@ -5,19 +5,26 @@ export interface ITimeUnit {
key: string;
multiplier: number;
}
export enum TimeUnitsValues {
hr = 'hr',
day = 'day',
month = 'month',
}
export const TimeUnits: ITimeUnit[] = [
{
value: 'hr',
value: TimeUnitsValues.hr,
key: 'Hours',
multiplier: 1,
},
{
value: 'day',
value: TimeUnitsValues.day,
key: 'Days',
multiplier: 1 / 24,
},
{
value: 'month',
value: TimeUnitsValues.month,
key: 'Months',
multiplier: 1 / (24 * 30),
},

View File

@@ -28,7 +28,7 @@
height: calc(100% - 40px);
}
.list-graph-container {
.full-view-graph-container {
height: calc(100% - 40px);
overflow-y: auto;
}

View File

@@ -232,7 +232,7 @@ function FullView({
className={cx('graph-container', {
disabled: isDashboardLocked,
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
'list-graph-container': isListView,
'full-view-graph-container': isListView || isTablePanel,
})}
ref={fullViewRef}
>

View File

@@ -325,6 +325,7 @@ function WidgetGraphComponent({
setHovered(false);
}}
id={widget.id}
className="widget-graph-component-container"
>
<Modal
destroyOnClose
@@ -396,7 +397,10 @@ function WidgetGraphComponent({
)}
{(queryResponse.isSuccess || widget.panelTypes === PANEL_TYPES.LIST) && (
<div
className={cx('widget-graph-container', widget.panelTypes)}
className={cx(
'widget-graph-container',
`${widget.panelTypes}-panel-container`,
)}
ref={graphRef}
>
<PanelWrapper

View File

@@ -41,9 +41,11 @@
}
}
.widget-graph-container {
&.graph {
height: 100%;
.widget-graph-component-container {
.widget-graph-container {
&.graph-panel-container {
height: 100%;
}
}
}
}
@@ -89,11 +91,13 @@
}
}
.widget-graph-container {
height: 100%;
.widget-graph-component-container {
.widget-graph-container {
height: 100%;
&.graph {
height: calc(100% - 30px);
&.graph-panel-container {
height: calc(100% - 30px);
}
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
export const tableDataMultipleQueriesSuccessResponse = {
columns: [
{
@@ -210,3 +211,278 @@ export const expectedOutputWithLegends = {
},
],
};
// QB v5 Aggregations Mock Data
export const tableDataQBv5MultiAggregations = {
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{
name: 'host.name',
queryName: 'A',
isValueColumn: false,
id: 'host.name',
},
{
name: 'count()',
queryName: 'A',
isValueColumn: true,
id: 'A.count()',
},
{
name: 'count_distinct(app.ads.count)',
queryName: 'A',
isValueColumn: true,
id: 'A.count_distinct(app.ads.count)',
},
{
name: 'count()',
queryName: 'B',
isValueColumn: true,
id: 'B.count()',
},
{
name: 'count_distinct(app.ads.count)',
queryName: 'B',
isValueColumn: true,
id: 'B.count_distinct(app.ads.count)',
},
{
name: 'count()',
queryName: 'C',
isValueColumn: true,
id: 'C.count()',
},
{
name: 'count_distinct(app.ads.count)',
queryName: 'C',
isValueColumn: true,
id: 'C.count_distinct(app.ads.count)',
},
],
rows: [
{
data: {
'service.name': 'frontend-proxy',
'host.name': 'test-host.name',
'A.count()': 144679,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 144679,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 144679,
'C.count_distinct(app.ads.count)': 0,
},
},
{
data: {
'service.name': 'frontend',
'host.name': 'test-host.name',
'A.count()': 142311,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 142311,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 142311,
'C.count_distinct(app.ads.count)': 0,
},
},
],
};
export const widgetQueryQBv5MultiAggregations = {
clickhouse_sql: [
{
name: 'A',
legend: 'p99',
disabled: false,
query: '',
},
{
name: 'B',
legend: '',
disabled: false,
query: '',
},
{
name: 'C',
legend: 'max',
disabled: false,
query: '',
},
],
promql: [
{
name: 'A',
query: '',
legend: 'p99',
disabled: false,
},
{
name: 'B',
query: '',
legend: '',
disabled: false,
},
{
name: 'C',
query: '',
legend: 'max',
disabled: false,
},
],
builder: {
queryData: [
{
dataSource: 'metrics',
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: 'float64',
id: 'signoz_latency--float64--ExponentialHistogram--true',
key: 'signoz_latency',
type: 'ExponentialHistogram',
},
timeAggregation: '',
spaceAggregation: 'p90',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [],
groupBy: [
{
dataType: 'string',
key: 'service.name',
type: 'tag',
id: 'service.name--string--tag--false',
},
{
dataType: 'string',
key: 'host.name',
type: 'tag',
id: 'host.name--string--tag--false',
},
],
legend: 'p99',
reduceTo: 'avg',
},
{
dataSource: 'metrics',
queryName: 'B',
aggregateOperator: 'rate',
aggregateAttribute: {
dataType: 'float64',
id: 'system_disk_operations--float64--Sum--true',
key: 'system_disk_operations',
type: 'Sum',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'B',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [],
groupBy: [
{
dataType: 'string',
key: 'service.name',
type: 'tag',
id: 'service.name--string--tag--false',
},
{
dataType: 'string',
key: 'host.name',
type: 'tag',
id: 'host.name--string--tag--false',
},
],
legend: '',
reduceTo: 'avg',
},
{
dataSource: 'metrics',
queryName: 'C',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: 'float64',
id: 'signoz_latency--float64--ExponentialHistogram--true',
key: 'signoz_latency',
type: 'ExponentialHistogram',
},
timeAggregation: '',
spaceAggregation: 'p90',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'C',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [],
groupBy: [
{
dataType: 'string',
key: 'service.name',
type: 'tag',
id: 'service.name--string--tag--false',
},
{
dataType: 'string',
key: 'host.name',
type: 'tag',
id: 'host.name--string--tag--false',
},
],
legend: 'max',
reduceTo: 'avg',
},
],
queryFormulas: [],
},
id: 'qb-v5-multi-aggregations-test',
queryType: 'builder',
};
export const expectedOutputQBv5MultiAggregations = {
dataSource: [
{
'service.name': 'frontend-proxy',
'host.name': 'test-host.name',
'A.count()': 144679,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 144679,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 144679,
'C.count_distinct(app.ads.count)': 0,
},
{
'service.name': 'frontend',
'host.name': 'test-host.name',
'A.count()': 142311,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 142311,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 142311,
'C.count_distinct(app.ads.count)': 0,
},
],
};

View File

@@ -6,8 +6,11 @@ import {
sortFunction,
} from '../utils';
import {
expectedOutputQBv5MultiAggregations,
expectedOutputWithLegends,
tableDataMultipleQueriesSuccessResponse,
tableDataQBv5MultiAggregations,
widgetQueryQBv5MultiAggregations,
widgetQueryWithLegend,
} from './response';
@@ -67,6 +70,7 @@ describe('Table Panel utils', () => {
isValueColumn: true,
name: 'A',
queryName: 'A',
id: 'A',
};
// A has value and value is considered bigger than n/a hence 1
expect(sortFunction(rowA, rowB, item)).toBe(1);
@@ -128,3 +132,96 @@ describe('Table Panel utils', () => {
expect(sortFunction(rowA, rowB, item)).toBe(0);
});
});
describe('Table Panel utils with QB v5 aggregations', () => {
it('createColumnsAndDataSource function - QB v5 multi-aggregations', () => {
const data = tableDataQBv5MultiAggregations;
const query = widgetQueryQBv5MultiAggregations as Query;
const { columns, dataSource } = createColumnsAndDataSource(data, query);
// Verify column structure for multi-aggregations
expect(columns).toHaveLength(8);
expect(columns[0].title).toBe('service.name');
expect(columns[1].title).toBe('host.name');
// All columns with queryName 'A' get the legend 'p99'
expect(columns[2].title).toBe('p99'); // A.count() uses legend from query A
expect(columns[3].title).toBe('p99'); // A.count_distinct() uses legend from query A
expect(columns[4].title).toBe('count()'); // B.count() uses column name (no legend)
expect(columns[5].title).toBe('count_distinct(app.ads.count)'); // B.count_distinct() uses column name
expect(columns[6].title).toBe('max'); // C.count() uses legend from query C
expect(columns[7].title).toBe('max'); // C.count_distinct() uses legend from query C
// Verify dataIndex mapping
expect((columns[0] as any).dataIndex).toBe('service.name');
expect((columns[2] as any).dataIndex).toBe('A.count()');
expect((columns[3] as any).dataIndex).toBe('A.count_distinct(app.ads.count)');
// Verify dataSource structure
expect(dataSource).toStrictEqual(
expectedOutputQBv5MultiAggregations.dataSource,
);
});
it('getQueryLegend function - QB v5 multi-query support', () => {
const query = widgetQueryQBv5MultiAggregations as Query;
expect(getQueryLegend(query, 'A')).toBe('p99');
expect(getQueryLegend(query, 'B')).toBeUndefined();
expect(getQueryLegend(query, 'C')).toBe('max');
expect(getQueryLegend(query, 'D')).toBeUndefined();
});
it('sorter function - QB v5 multi-aggregation columns', () => {
const item = {
isValueColumn: true,
name: 'count()',
queryName: 'A',
id: 'A.count()',
};
// Test numeric sorting
expect(
sortFunction(
{ 'A.count()': 100, key: '1', timestamp: 1000 },
{ 'A.count()': 200, key: '2', timestamp: 1000 },
item,
),
).toBe(-100);
// Test n/a handling
expect(
sortFunction(
{ 'A.count()': 'n/a', key: '1', timestamp: 1000 },
{ 'A.count()': 100, key: '2', timestamp: 1000 },
item,
),
).toBe(-1);
expect(
sortFunction(
{ 'A.count()': 100, key: '1', timestamp: 1000 },
{ 'A.count()': 'n/a', key: '2', timestamp: 1000 },
item,
),
).toBe(1);
// Test string sorting
expect(
sortFunction(
{ 'A.count()': 'read', key: '1', timestamp: 1000 },
{ 'A.count()': 'write', key: '2', timestamp: 1000 },
item,
),
).toBe(-1);
// Test equal values
expect(
sortFunction(
{ 'A.count()': 'n/a', key: '1', timestamp: 1000 },
{ 'A.count()': 'n/a', key: '2', timestamp: 1000 },
item,
),
).toBe(0);
});
});

View File

@@ -150,11 +150,14 @@ export function sortFunction(
name: string;
queryName: string;
isValueColumn: boolean;
id: string;
},
): number {
const colId = item.id;
const colName = item.name;
// assumption :- number values is bigger than 'n/a'
const valueA = Number(a[`${item.name}_without_unit`] ?? a[item.name]);
const valueB = Number(b[`${item.name}_without_unit`] ?? b[item.name]);
const valueA = Number(a[`${colId}_without_unit`] ?? a[colId] ?? a[colName]);
const valueB = Number(b[`${colId}_without_unit`] ?? b[colId] ?? b[colName]);
// if both the values are numbers then return the difference here
if (!isNaN(valueA) && !isNaN(valueB)) {
@@ -172,10 +175,11 @@ export function sortFunction(
}
// if both of them are strings do the localecompare
return ((a[item.name] as string) || '').localeCompare(
(b[item.name] as string) || '',
return ((a[colId] as string) || (a[colName] as string) || '').localeCompare(
(b[colId] as string) || (b[colName] as string) || '',
);
}
export function createColumnsAndDataSource(
data: TableData,
currentQuery: Query,
@@ -198,7 +202,7 @@ export function createColumnsAndDataSource(
// if no legend present then rely on the column name value
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
width: QUERY_TABLE_CONFIG.width,
render: renderColumnCell && renderColumnCell[item.name],
render: renderColumnCell && renderColumnCell[item.id],
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
};

View File

@@ -774,6 +774,13 @@ function MultiIngestionSettings(): JSX.Element {
),
children: (
<div className="ingestion-key-info-container">
<Row>
<Col span={6}> ID </Col>
<Col span={12}>
<Typography.Text>{APIKey.id}</Typography.Text>
</Col>
</Row>
<Row>
<Col span={6}> Created on </Col>
<Col span={12}>

View File

@@ -13,7 +13,7 @@ import { useCallback } from 'react';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { SpinnerWrapper, Wrapper } from './styles';
import { SpinnerWrapper } from './styles';
function ListViewPanel(): JSX.Element {
const { config } = useOptionsMenu({
@@ -42,7 +42,7 @@ function ListViewPanel(): JSX.Element {
}, [config]);
return (
<Wrapper>
<div className="live-logs-settings-panel">
<Select
getPopupContainer={popupContainer}
style={defaultSelectStyle}
@@ -68,7 +68,7 @@ function ListViewPanel(): JSX.Element {
<Spinner style={{ height: 'auto' }} />
</SpinnerWrapper>
)}
</Wrapper>
</div>
);
}

View File

@@ -0,0 +1,26 @@
.live-logs-chart-container {
height: 200px;
min-height: 200px;
border-left: none;
border-right: none;
}
.live-logs-settings-panel {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--bg-ink-300);
.live-logs-frequency-chart-view-controller {
display: flex;
align-items: center;
gap: 8px;
}
}
.lightMode {
.live-logs-settings-panel {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -1,46 +1,89 @@
import { Col } from 'antd';
import Spinner from 'components/Spinner';
import './LiveLogsContainer.styles.scss';
import { Button, Switch, Typography } from 'antd';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { LOCALSTORAGE } from 'constants/localStorage';
import GoToTop from 'container/GoToTop';
import FiltersInput from 'container/LiveLogs/FiltersInput';
import LiveLogsTopNav from 'container/LiveLogsTopNav';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useEventSourceEvent } from 'hooks/useEventSourceEvent';
import { prepareQueryRangePayload } from 'lib/dashboard/prepareQueryRangePayload';
import { Sliders } from 'lucide-react';
import { useEventSource } from 'providers/EventSource';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { ILog } from 'types/api/logs/log';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import { idObject } from '../constants';
import ListViewPanel from '../ListViewPanel';
import LiveLogsList from '../LiveLogsList';
import { ILiveLogsLog } from '../LiveLogsList/types';
import LiveLogsListChart from '../LiveLogsListChart';
import { QueryHistoryState } from '../types';
import { prepareQueryByFilter } from '../utils';
import { ContentWrapper, LiveLogsChart, Wrapper } from './styles';
function LiveLogsContainer(): JSX.Element {
const location = useLocation();
const [logs, setLogs] = useState<ILog[]>([]);
const [logs, setLogs] = useState<ILiveLogsLog[]>([]);
const { currentQuery, stagedQuery } = useQueryBuilder();
const [showLiveLogsFrequencyChart, setShowLiveLogsFrequencyChart] = useState(
true,
);
const { stagedQuery } = useQueryBuilder();
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null;
return stagedQuery.builder.queryData.find((item) => !item.disabled) || null;
}, [stagedQuery]);
const queryLocationState = location.state as QueryHistoryState;
const batchedEventsRef = useRef<ILog[]>([]);
const batchedEventsRef = useRef<ILiveLogsLog[]>([]);
const { selectedTime: globalSelectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const prevFilterExpressionRef = useRef<string | null>(null);
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});
const formatItems = [
{
key: 'raw',
label: 'Raw',
data: {
title: 'max lines per row',
},
},
{
key: 'list',
label: 'Default',
},
{
key: 'table',
label: 'Column',
data: {
title: 'columns',
},
},
];
const handleToggleShowFormatOptions = (): void =>
setShowFormatMenuItems(!showFormatMenuItems);
useClickOutside({
ref: menuRef,
onClickOutside: () => {
if (showFormatMenuItems) {
setShowFormatMenuItems(false);
}
},
});
const {
handleStartOpenConnection,
@@ -53,7 +96,7 @@ function LiveLogsContainer(): JSX.Element {
const compositeQuery = useGetCompositeQueryParam();
const updateLogs = useCallback((newLogs: ILog[]) => {
const updateLogs = useCallback((newLogs: ILiveLogsLog[]) => {
setLogs((prevState) =>
[...newLogs, ...prevState].slice(0, MAX_LOGS_LIST_SIZE),
);
@@ -67,7 +110,7 @@ function LiveLogsContainer(): JSX.Element {
}, 500);
const batchLiveLog = useCallback(
(log: ILog): void => {
(log: ILiveLogsLog): void => {
batchedEventsRef.current.push(log);
debouncedUpdateLogs();
@@ -77,7 +120,7 @@ function LiveLogsContainer(): JSX.Element {
const handleGetLiveLogs = useCallback(
(event: MessageEvent<string>) => {
const data: ILog = JSON.parse(event.data);
const data: ILiveLogsLog = JSON.parse(event?.data);
batchLiveLog(data);
},
@@ -91,72 +134,65 @@ function LiveLogsContainer(): JSX.Element {
useEventSourceEvent('message', handleGetLiveLogs);
useEventSourceEvent('error', handleError);
const getPreparedQuery = useCallback(
(query: Query): Query => {
const firstLogId: string | null = logs.length ? logs[0].id : null;
const preparedQuery: Query = prepareQueryByFilter(
query,
idObject,
firstLogId,
);
return preparedQuery;
},
[logs],
);
const openConnection = useCallback(
(query: Query) => {
const { queryPayload } = prepareQueryRangePayload({
query,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
});
const encodedQueryPayload = encodeURIComponent(JSON.stringify(queryPayload));
const queryString = `q=${encodedQueryPayload}`;
handleStartOpenConnection({ queryString });
(filterExpression?: string | null) => {
handleStartOpenConnection(filterExpression || '');
},
[globalSelectedTime, handleStartOpenConnection],
[handleStartOpenConnection],
);
const handleStartNewConnection = useCallback(
(query: Query) => {
(filterExpression?: string | null) => {
handleCloseConnection();
const preparedQuery = getPreparedQuery(query);
openConnection(preparedQuery);
openConnection(filterExpression);
},
[getPreparedQuery, handleCloseConnection, openConnection],
[handleCloseConnection, openConnection],
);
useEffect(() => {
if (!compositeQuery) return;
const currentFilterExpression =
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
// Check if filterExpression has actually changed
if (
(initialLoading && !isConnectionLoading) ||
compositeQuery.id !== stagedQuery?.id
!prevFilterExpressionRef.current ||
prevFilterExpressionRef.current !== currentFilterExpression
) {
handleStartNewConnection(compositeQuery);
const validationResult = validateQuery(currentFilterExpression || '');
if (validationResult.isValid) {
setLogs([]);
batchedEventsRef.current = [];
handleStartNewConnection(currentFilterExpression);
}
prevFilterExpressionRef.current = currentFilterExpression || null;
}
}, [
compositeQuery,
initialLoading,
stagedQuery,
isConnectionLoading,
openConnection,
handleStartNewConnection,
]);
}, [currentQuery, handleStartNewConnection]);
useEffect(() => {
if (initialLoading && !isConnectionLoading) {
const currentFilterExpression =
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
const validationResult = validateQuery(currentFilterExpression || '');
if (validationResult.isValid) {
handleStartNewConnection(currentFilterExpression);
prevFilterExpressionRef.current = currentFilterExpression || null;
} else {
handleStartNewConnection(null);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialLoading, isConnectionLoading, handleStartNewConnection]);
useEffect((): (() => void) | undefined => {
if (isConnectionError && reconnectDueToError && compositeQuery) {
if (isConnectionError && reconnectDueToError) {
// Small delay to prevent immediate reconnection attempts
const reconnectTimer = setTimeout(() => {
handleStartNewConnection(compositeQuery);
handleStartNewConnection();
}, 1000);
return (): void => clearTimeout(reconnectTimer);
@@ -169,50 +205,70 @@ function LiveLogsContainer(): JSX.Element {
handleStartNewConnection,
]);
useEffect(() => {
const prefetchedList = queryLocationState?.listQueryPayload[0]?.list;
// clean up the connection when the component unmounts
useEffect(
() => (): void => {
handleCloseConnection();
},
[handleCloseConnection],
);
if (prefetchedList) {
const prefetchedLogs: ILog[] = prefetchedList
.map((item) => ({
...item.data,
timestamp: item.timestamp,
}))
.reverse();
updateLogs(prefetchedLogs);
}
}, [queryLocationState, updateLogs]);
const handleToggleFrequencyChart = useCallback(() => {
setShowLiveLogsFrequencyChart(!showLiveLogsFrequencyChart);
}, [showLiveLogsFrequencyChart]);
return (
<Wrapper>
<LiveLogsTopNav />
<ContentWrapper gutter={[0, 20]} style={{ color: themeColors.lightWhite }}>
<Col span={24}>
<FiltersInput />
</Col>
{initialLoading && logs.length === 0 ? (
<Col span={24}>
<Spinner style={{ height: 'auto' }} tip="Fetching Logs" />
</Col>
) : (
<>
<Col span={24}>
<LiveLogsChart
initialData={queryLocationState?.graphQueryPayload || null}
<div className="live-logs-container">
<div className="live-logs-content">
<div className="live-logs-settings-panel">
<div className="live-logs-frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
size="small"
checked={showLiveLogsFrequencyChart}
defaultChecked
onChange={handleToggleFrequencyChart}
/>
</div>
<div className="format-options-container" ref={menuRef}>
<Button
className="periscope-btn ghost"
onClick={handleToggleShowFormatOptions}
icon={<Sliders size={14} />}
/>
{showFormatMenuItems && (
<LogsFormatOptionsMenu
title="FORMAT"
items={formatItems}
selectedOptionFormat={options.format}
config={config}
/>
</Col>
<Col span={24}>
<ListViewPanel />
</Col>
<Col span={24}>
<LiveLogsList logs={logs} />
</Col>
</>
)}
</div>
</div>
{showLiveLogsFrequencyChart && (
<div className="live-logs-chart-container">
<LiveLogsListChart
initialData={queryLocationState?.graphQueryPayload || null}
className="live-logs-chart"
isShowingLiveLogs
/>
</div>
)}
<GoToTop />
</ContentWrapper>
</Wrapper>
<div className="live-logs-list-container">
<LiveLogsList
logs={logs}
isLoading={initialLoading && logs.length === 0}
/>
</div>
</div>
<GoToTop />
</div>
);
}

View File

@@ -14,4 +14,7 @@ export const ContentWrapper = styled(Row)`
export const Wrapper = styled.div`
padding-bottom: 4rem;
padding-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
`;

View File

@@ -0,0 +1,55 @@
.live-logs-container {
.live-logs-content {
.live-logs-chart-container {
padding: 0px 8px;
.logs-frequency-chart {
.ant-card-body {
height: 140px;
min-height: 140px;
padding: 0 16px 22px 16px;
font-family: 'Geist Mono';
}
margin-bottom: 0px;
}
}
}
}
.live-logs-list {
.live-logs-list-loading {
padding: 16px;
text-align: center;
color: var(--text-vanilla-100);
}
.live-logs-list-loading {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
.loading-live-logs-content {
display: flex;
align-items: flex-start;
flex-direction: column;
.loading-gif {
height: 72px;
margin-left: -24px;
}
}
}
}
.lightMode {
.live-logs-list-loading {
.loading-live-logs-content {
.ant-typography {
color: var(--text-ink-500);
}
}
}
}

View File

@@ -1,24 +1,23 @@
import './LiveLogsList.styles.scss';
import { Card, Typography } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import Spinner from 'components/Spinner';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { Heading } from 'container/LogsTable/styles';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useEventSource } from 'providers/EventSource';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -26,11 +25,9 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { LiveLogsListProps } from './types';
function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const { t } = useTranslation(['logs']);
const { isConnectionLoading } = useEventSource();
const { activeLogId } = useCopyLogLink();
@@ -43,6 +40,12 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
onSetActiveLog,
} = useActiveLog();
// get only data from the logs object
const formattedLogs: ILog[] = useMemo(
() => logs.map((log) => log?.data).flat(),
[logs],
);
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
@@ -50,8 +53,8 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
});
const activeLogIndex = useMemo(
() => logs.findIndex(({ id }) => id === activeLogId),
[logs, activeLogId],
() => formattedLogs.findIndex(({ id }) => id === activeLogId),
[formattedLogs, activeLogId],
);
const selectedFields = convertKeysToColumnFields([
@@ -105,30 +108,39 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
});
}, [activeLogId, activeLogIndex]);
const isLoadingList = isConnectionLoading && logs.length === 0;
const isLoadingList = isConnectionLoading && formattedLogs.length === 0;
if (isLoadingList) {
return <Spinner style={{ height: 'auto' }} tip="Fetching Logs" />;
}
const renderLoading = useCallback(
() => (
<div className="live-logs-list-loading">
<div className="loading-live-logs-content">
<img
className="loading-gif"
src="/Icons/loading-plane.gif"
alt="wait-icon"
/>
<Typography>Fetching live logs...</Typography>
</div>
</div>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return (
<>
{options.format !== OptionFormatTypes.TABLE && (
<Heading>
<Typography.Text>Event</Typography.Text>
</Heading>
)}
<div className="live-logs-list">
{(formattedLogs.length === 0 || isLoading || isLoadingList) &&
renderLoading()}
{logs.length === 0 && <Typography>{t('fetching_log_lines')}</Typography>}
{logs.length !== 0 && (
{formattedLogs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === OptionFormatTypes.TABLE ? (
<InfinityTableView
ref={ref}
isLoading={false}
tableViewProps={{
logs,
logs: formattedLogs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
@@ -142,8 +154,8 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
<Virtuoso
ref={ref}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={logs}
totalCount={logs.length}
data={formattedLogs}
totalCount={formattedLogs.length}
itemContent={getItemContent}
/>
</OverlayScrollbar>
@@ -151,15 +163,18 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
)}
</InfinityWrapperStyled>
)}
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
onClickActionItem={onAddToQuery}
/>
</>
{activeLog && (
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
onClickActionItem={onAddToQuery}
/>
)}
</div>
);
}

View File

@@ -1,5 +1,11 @@
import { ILog } from 'types/api/logs/log';
export interface ILiveLogsLog {
data: ILog[];
timestamp: number;
}
export type LiveLogsListProps = {
logs: ILog[];
logs: ILiveLogsLog[];
isLoading: boolean;
};

View File

@@ -9,24 +9,33 @@ import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import { LiveLogsListChartProps } from './types';
function LiveLogsListChart({
className,
initialData,
isShowingLiveLogs = false,
}: LiveLogsListChartProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
const { currentQuery } = useQueryBuilder();
const { isConnectionOpen } = useEventSource();
const listChartQuery: Query | null = useMemo(() => {
if (!stagedQuery) return null;
if (!currentQuery) return null;
const currentFilterExpression =
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
const validationResult = validateQuery(currentFilterExpression || '');
if (!validationResult.isValid) return null;
return {
...stagedQuery,
...currentQuery,
builder: {
...stagedQuery.builder,
queryData: stagedQuery.builder.queryData.map((item) => ({
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
disabled: false,
aggregateOperator: LogsAggregatorOperator.COUNT,
@@ -39,7 +48,7 @@ function LiveLogsListChart({
})),
},
};
}, [stagedQuery]);
}, [currentQuery]);
const { data, isFetching } = useGetExplorerQueryRange(
listChartQuery,
@@ -62,12 +71,15 @@ function LiveLogsListChart({
}, [data, initialData]);
return (
<LogsExplorerChart
isLoading={initialData ? false : isFetching}
data={chartData}
isLabelEnabled={false}
className={className}
/>
<div className="live-logs-chart-container">
<LogsExplorerChart
isLoading={initialData ? false : isFetching}
data={chartData}
isLabelEnabled={false}
className={className}
isLogsExplorerViews={isShowingLiveLogs}
/>
</div>
);
}

View File

@@ -3,4 +3,5 @@ import { QueryData } from 'types/api/widgets/getQuery';
export type LiveLogsListChartProps = {
className?: string;
initialData: QueryData[] | null;
isShowingLiveLogs: boolean;
};

View File

@@ -0,0 +1,90 @@
import { PauseCircleFilled, PlayCircleFilled } from '@ant-design/icons';
import { Button } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useEventSource } from 'providers/EventSource';
import { useCallback, useEffect } from 'react';
import { validateQuery } from 'utils/queryValidationUtils';
function LiveLogsPauseResume(): JSX.Element {
const {
isConnectionOpen,
isConnectionLoading,
initialLoading,
handleCloseConnection,
handleStartOpenConnection,
handleSetInitialLoading,
} = useEventSource();
const { currentQuery } = useQueryBuilder();
const isPlaying = isConnectionOpen || isConnectionLoading || initialLoading;
const openConnection = useCallback(
(filterExpression?: string | null) => {
handleStartOpenConnection(filterExpression || '');
},
[handleStartOpenConnection],
);
const handleStartNewConnection = useCallback(
(filterExpression?: string | null) => {
handleCloseConnection();
openConnection(filterExpression);
},
[handleCloseConnection, openConnection],
);
const onLiveButtonClick = useCallback(() => {
if (initialLoading) {
handleSetInitialLoading(false);
}
if ((!isConnectionOpen && isConnectionLoading) || isConnectionOpen) {
handleCloseConnection();
} else {
const currentFilterExpression =
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
const validationResult = validateQuery(currentFilterExpression || '');
if (validationResult.isValid) {
handleStartNewConnection(currentFilterExpression);
} else {
handleStartNewConnection(null);
}
}
}, [
initialLoading,
isConnectionOpen,
isConnectionLoading,
currentQuery,
handleSetInitialLoading,
handleCloseConnection,
handleStartNewConnection,
]);
// clean up the connection when the component unmounts
useEffect(
() => (): void => {
handleCloseConnection();
},
[handleCloseConnection],
);
return (
<div className="live-logs-pause-resume">
<Button
icon={isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
danger={isPlaying}
onClick={onLiveButtonClick}
type="primary"
className={`periscope-btn ${isPlaying ? 'warning' : 'success'}`}
>
{isPlaying ? 'Pause' : 'Resume'}
</Button>
</div>
);
}
export default LiveLogsPauseResume;

View File

@@ -6,4 +6,5 @@ export type LogsExplorerChartProps = {
isLogsExplorerViews?: boolean;
isLabelEnabled?: boolean;
className?: string;
isShowingLiveLogs?: boolean;
};

View File

@@ -1,14 +0,0 @@
import { Card } from 'antd';
import styled from 'styled-components';
export const CardStyled = styled(Card)`
border: none !important;
position: relative;
margin-bottom: 16px;
.ant-card-body {
height: 200px;
min-height: 200px;
padding: 0 16px 16px 16px;
font-family: 'Geist Mono';
}
`;

View File

@@ -0,0 +1,25 @@
.logs-frequency-chart-container {
height: 200px;
min-height: 200px;
border-bottom: 1px solid #262626;
.ant-card-body {
height: 200px;
min-height: 200px;
padding: 0 16px 16px 16px;
font-family: 'Geist Mono';
}
.logs-frequency-chart-loading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
.lightMode {
.logs-frequency-chart-container {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -1,3 +1,5 @@
import './LogsExplorerChart.styles.scss';
import Graph from 'components/Graph';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
@@ -15,7 +17,6 @@ import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
import { CardStyled } from './LogsExplorerChart.styled';
import { getColorsForSeverityLabels } from './utils';
function LogsExplorerChart({
@@ -24,6 +25,7 @@ function LogsExplorerChart({
isLabelEnabled = true,
className,
isLogsExplorerViews = false,
isShowingLiveLogs = false,
}: LogsExplorerChartProps): JSX.Element {
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
@@ -54,6 +56,11 @@ function LogsExplorerChart({
const onDragSelect = useCallback(
(start: number, end: number): void => {
// Do not allow dragging on live logs chart
if (isShowingLiveLogs) {
return;
}
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
@@ -74,7 +81,7 @@ function LogsExplorerChart({
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
safeNavigate(generatedUrl);
},
[dispatch, location.pathname, safeNavigate, urlQuery],
[dispatch, location.pathname, safeNavigate, urlQuery, isShowingLiveLogs],
);
const graphData = useMemo(
@@ -100,9 +107,11 @@ function LogsExplorerChart({
);
return (
<CardStyled className={className}>
<div className={`${className} logs-frequency-chart-container`}>
{isLoading ? (
<Spinner size="default" height="100%" />
<div className="logs-frequency-chart-loading">
<Spinner size="default" height="100%" />
</div>
) : (
<Graph
name="logsExplorerChart"
@@ -115,7 +124,7 @@ function LogsExplorerChart({
maxTime={chartMaxTime}
/>
)}
</CardStyled>
</div>
);
}

View File

@@ -0,0 +1,278 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable react-hooks/exhaustive-deps */
import { ColumnDef, DataTable, Row } from '@signozhq/table';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { getLogIndicatorTypeForTable } from 'components/Logs/LogStateIndicator/utils';
import { useTableView } from 'components/Logs/TableView/useTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { FontSize } from 'container/OptionsMenu/types';
import dayjs from 'dayjs';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import useDragColumns from 'hooks/useDragColumns';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { isEmpty, isEqual } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ILog } from 'types/api/logs/log';
interface ColumnViewProps {
logs: ILog[];
onLoadMore: () => void;
selectedFields: any[];
isLoading: boolean;
isFetching: boolean;
isFrequencyChartVisible: boolean;
options: {
maxLinesPerRow: number;
fontSize: FontSize;
};
}
function ColumnView({
logs,
onLoadMore,
selectedFields,
isLoading,
isFetching,
isFrequencyChartVisible,
options,
}: ColumnViewProps): JSX.Element {
const {
activeLog,
onSetActiveLog: handleSetActiveLog,
onClearActiveLog: handleClearActiveLog,
onAddToQuery: handleAddToQuery,
onGroupByAttribute: handleGroupByAttribute,
} = useActiveLog();
const [showActiveLog, setShowActiveLog] = useState<boolean>(false);
const { queryData: activeLogId } = useUrlQueryData<string | null>(
QueryParams.activeLogId,
null,
);
const scrollToIndexRef = useRef<
| ((
rowIndex: number,
options?: { align?: 'start' | 'center' | 'end' },
) => void)
| undefined
>();
const { timezone } = useTimezone();
useEffect(() => {
if (activeLogId) {
const log = logs.find(({ id }) => id === activeLogId);
if (log) {
handleSetActiveLog(log);
setShowActiveLog(true);
}
}
}, []);
const tableViewProps = {
logs,
fields: selectedFields,
linesPerRow: options.maxLinesPerRow as number,
fontSize: options.fontSize as FontSize,
appendTo: 'end' as const,
activeLogIndex: 0,
};
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: handleSetActiveLog,
});
const { draggedColumns, onColumnOrderChange } = useDragColumns<
Record<string, unknown>
>(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const tableColumns = useMemo(
() => getDraggedColumns<Record<string, unknown>>(columns, draggedColumns),
[columns, draggedColumns],
);
const scrollToLog = useCallback(
(logId: string): void => {
const logIndex = logs.findIndex((log) => log.id === logId);
if (logIndex !== -1 && scrollToIndexRef.current) {
scrollToIndexRef.current(logIndex, { align: 'center' });
}
},
[logs],
);
useEffect(() => {
if (activeLogId) {
scrollToLog(activeLogId);
}
}, [activeLogId]);
const args = {
columns,
tableId: 'virtualized-infinite-reorder-resize',
enableSorting: false,
enableFiltering: false,
enableGlobalFilter: false,
enableColumnReordering: true,
enableColumnResizing: true,
enableColumnPinning: false,
enableRowSelection: false,
enablePagination: false,
showHeaders: true,
defaultColumnWidth: 180,
minColumnWidth: 80,
maxColumnWidth: 480,
// Virtualization + Infinite Scroll
enableVirtualization: true,
estimateRowSize: 56,
overscan: 50,
rowHeight: 56,
enableInfiniteScroll: true,
enableScrollRestoration: false,
fixedHeight: isFrequencyChartVisible ? 560 : 760,
enableDynamicRowHeight: true,
};
const selectedColumns = useMemo(
() =>
tableColumns.map((field) => ({
id: field.key?.toString().toLowerCase().replace(/\./g, '_'), // IMP - Replace dots with underscores as reordering does not work well for accessorKey with dots
// accessorKey: field.name,
accessorFn: (row: Record<string, string>): string =>
row[field.key as string] as string,
header: field.title as string,
// eslint-disable-next-line sonarjs/no-duplicate-string
size: field.key === 'state-indicator' ? 4 : 180,
minSize: field.key === 'state-indicator' ? 4 : 120,
maxSize: field.key === 'state-indicator' ? 4 : Number.MAX_SAFE_INTEGER,
disableReorder: field.key === 'state-indicator',
disableDropBefore: field.key === 'state-indicator',
disableResizing: field.key === 'state-indicator',
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({
row,
getValue,
}: {
row: Row<Record<string, string>>;
getValue: () => string | JSX.Element;
}): string | JSX.Element => {
if (field.key === 'state-indicator') {
const type = getLogIndicatorTypeForTable(row.original);
const fontSize = options.fontSize as FontSize;
return <LogStateIndicator type={type} fontSize={fontSize} />;
}
const isTimestamp = field.key === 'timestamp';
const cellContent = getValue();
if (isTimestamp) {
const formattedTimestamp = dayjs(cellContent as string).tz(
timezone.value,
);
return (
<div className="table-cell-content">
{formattedTimestamp.format(DATE_TIME_FORMATS.ISO_DATETIME_MS)}
</div>
);
}
return (
<div
className={`table-cell-content ${
row.original.id === activeLog?.id ? 'active-log' : ''
}`}
>
{cellContent}
</div>
);
},
})),
[tableColumns, options.fontSize, activeLog?.id],
);
const handleColumnOrderChange = (newColumns: ColumnDef<any>[]): void => {
if (isEmpty(newColumns) || isEqual(newColumns, selectedColumns)) return;
const formattedColumns = newColumns.map((column) => ({
id: column.id,
header: column.header,
size: column.size,
minSize: column.minSize,
maxSize: column.maxSize,
key: column.id,
title: column.header as string,
dataIndex: column.id,
}));
onColumnOrderChange(formattedColumns);
};
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
const currentLog = logs.find(({ id }) => id === row.original.id);
setShowActiveLog(true);
handleSetActiveLog(currentLog as ILog);
};
const removeQueryParam = (key: string): void => {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState({}, '', url);
};
const handleLogDetailClose = (): void => {
removeQueryParam(QueryParams.activeLogId);
handleClearActiveLog();
setShowActiveLog(false);
};
return (
<div
className={`logs-list-table-view-container ${
options.fontSize as FontSize
} max-lines-${options.maxLinesPerRow as number}`}
data-max-lines-per-row={options.maxLinesPerRow}
data-font-size={options.fontSize}
>
<DataTable
// eslint-disable-next-line react/jsx-props-no-spreading
{...args}
columns={selectedColumns as ColumnDef<Record<string, string>, unknown>[]}
data={dataSource}
hasMore
onLoadMore={onLoadMore}
loadingMore={isLoading || isFetching}
onColumnOrderChange={handleColumnOrderChange}
onRowClick={handleRowClick}
scrollToIndexRef={scrollToIndexRef}
/>
{showActiveLog && activeLog && (
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={handleLogDetailClose}
onAddToQuery={handleAddToQuery}
onClickActionItem={handleAddToQuery}
onGroupByAttribute={handleGroupByAttribute}
/>
)}
</div>
);
}
export default ColumnView;

View File

@@ -11,4 +11,5 @@ export type LogsExplorerListProps = {
isError: boolean;
error?: Error | APIError;
isFilterApplied: boolean;
isFrequencyChartVisible: boolean;
};

View File

@@ -9,4 +9,364 @@
letter-spacing: -0.005em;
text-align: left;
min-height: 500px;
.logs-list-table-view-container {
.data-table-container {
border: none !important;
}
table {
thead {
tr {
th {
font-family: 'Space Mono', monospace;
font-size: 12px;
color: white !important;
.cursor-col-resize {
width: 3px !important;
cursor: col-resize !important;
opacity: 0.5 !important;
background-color: var(--bg-ink-500) !important;
border: 1px solid var(--bg-ink-500) !important;
&:hover {
opacity: 1 !important;
}
}
}
td {
font-family: 'Space Mono', monospace;
font-size: 12px;
}
border-bottom: 1px solid var(--bg-slate-400) !important;
}
border-bottom: 1px solid var(--bg-slate-400) !important;
background-color: var(--bg-ink-400) !important;
tr {
&:hover {
background-color: var(--bg-ink-400) !important;
}
}
}
tbody {
tr {
td {
font-family: 'Space Mono', monospace;
font-size: 12px;
padding: 4px !important;
white-space: pre-wrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
&:hover {
background-color: var(--bg-slate-500) !important;
}
border-bottom: 1px solid var(--bg-slate-400) !important;
}
tr:has(.active-log) {
background-color: rgba(
78,
116,
248,
0.5
) !important; // same as bg-robin-500
color: var(--text-vanilla-100) !important;
}
}
thead {
z-index: 0 !important;
}
.log-state-indicator {
padding-left: 0 !important;
.line {
margin: 0 0 !important;
}
}
}
.sticky-header-table-container {
&::-webkit-scrollbar {
width: 0.4rem;
height: 0.4rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
&.small {
tbody {
tr {
td {
.table-cell-content {
font-size: 12px !important;
}
}
}
}
}
&.medium {
tbody {
tr {
td {
.table-cell-content {
font-size: 14px !important;
}
}
}
}
}
&.large {
tbody {
tr {
td {
.table-cell-content {
font-size: 16px !important;
}
}
}
}
}
&.max-lines-1 {
tbody {
tr {
td {
.table-cell-content {
font-size: 12px !important;
}
}
}
}
}
&[data-max-lines-per-row='1'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='2'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='3'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='4'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='5'] {
tbody {
tr {
td {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
&[data-max-lines-per-row='6'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='7'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 7;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='8'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 8;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='9'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 9;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
&[data-max-lines-per-row='10'] {
tbody {
tr {
td {
.table-cell-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 10;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
}
}
.lightMode {
.logs-list-view-container {
.logs-list-table-view-container {
table {
thead {
tr {
th {
color: var(--text-ink-500) !important;
}
border-bottom: 1px solid var(--bg-vanilla-300) !important;
}
border-bottom: 1px solid var(--bg-vanilla-300) !important;
background-color: var(--bg-vanilla-100) !important;
tr {
&:hover {
background-color: var(--bg-vanilla-300) !important;
}
}
}
tbody {
tr {
&:hover {
background-color: var(--bg-vanilla-300) !important;
}
border-bottom: 1px solid var(--bg-vanilla-300) !important;
}
}
}
.sticky-header-table-container {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
}
}
}
}
}

View File

@@ -141,6 +141,7 @@ describe('LogsExplorerList - empty states', () => {
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
setWarning={(): void => {}}
showLiveLogs={false}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,
@@ -205,6 +206,7 @@ describe('LogsExplorerList - empty states', () => {
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
setWarning={(): void => {}}
showLiveLogs={false}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,

View File

@@ -90,6 +90,7 @@ function LogsExplorerList({
});
}
}, [isLoading, isFetching, isError, logs.length]);
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {

View File

@@ -0,0 +1,174 @@
import { Button, Switch, Typography } from 'antd';
import { WsDataEvent } from 'api/common/getQueryStats';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Download from 'container/DownloadV2/DownloadV2';
import { useOptionsMenu } from 'container/OptionsMenu';
import useClickOutside from 'hooks/useClickOutside';
import { ArrowUp10, Minus, Sliders } from 'lucide-react';
import { useRef, useState } from 'react';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import QueryStatus from './QueryStatus';
function LogsActionsContainer({
listQuery,
queryStats,
selectedPanelType,
showFrequencyChart,
handleToggleFrequencyChart,
orderBy,
setOrderBy,
flattenLogData,
isFetching,
isLoading,
isError,
isSuccess,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
showFrequencyChart: boolean;
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
flattenLogData: any;
isFetching: boolean;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
queryStats: WsDataEvent | undefined;
}): JSX.Element {
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});
const formatItems = [
{
key: 'raw',
label: 'Raw',
data: {
title: 'max lines per row',
},
},
{
key: 'list',
label: 'Default',
},
{
key: 'table',
label: 'Column',
data: {
title: 'columns',
},
},
];
const handleToggleShowFormatOptions = (): void =>
setShowFormatMenuItems(!showFormatMenuItems);
useClickOutside({
ref: menuRef,
onClickOutside: () => {
if (showFormatMenuItems) {
setShowFormatMenuItems(false);
}
},
});
return (
<div className="logs-actions-container">
<div className="tab-options">
<div className="tab-options-left">
{selectedPanelType === PANEL_TYPES.LIST && (
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
size="small"
checked={showFrequencyChart}
defaultChecked
onChange={handleToggleFrequencyChart}
/>
</div>
)}
</div>
<div className="tab-options-right">
{selectedPanelType === PANEL_TYPES.LIST && (
<>
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={(value): void => setOrderBy(value)}
dataSource={DataSource.LOGS}
/>
</div>
<Download
data={flattenLogData}
isLoading={isFetching}
fileName="log_data"
/>
<div className="format-options-container" ref={menuRef}>
<Button
className="periscope-btn ghost"
onClick={handleToggleShowFormatOptions}
icon={<Sliders size={14} />}
data-testid="periscope-btn"
/>
{showFormatMenuItems && (
<LogsFormatOptionsMenu
title="FORMAT"
items={formatItems}
selectedOptionFormat={options.format}
config={config}
/>
)}
</div>
</>
)}
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
selectedPanelType === PANEL_TYPES.TABLE) && (
<div className="query-stats">
<QueryStatus
loading={isLoading || isFetching}
error={isError}
success={isSuccess}
/>
{queryStats?.read_rows && (
<Typography.Text className="rows">
{getYAxisFormattedValue(queryStats.read_rows?.toString(), 'short')}{' '}
rows
</Typography.Text>
)}
{queryStats?.elapsed_ms && (
<>
<div className="divider" />
<Typography.Text className="time">
{getYAxisFormattedValue(queryStats?.elapsed_ms?.toString(), 'ms')}
</Typography.Text>
</>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default LogsActionsContainer;

View File

@@ -9,7 +9,7 @@
display: flex;
flex-direction: column;
flex: 1;
padding-bottom: 60px;
padding-bottom: 10px;
.views-tabs-container {
padding: 8px 16px;
@@ -216,15 +216,19 @@
background-color: var(--bg-ink-500);
}
.logs-histogram {
.ant-card-body {
height: 140px;
min-height: 140px;
padding: 0 16px 22px 16px;
font-family: 'Geist Mono';
}
.logs-frequency-chart-container {
padding: 0px 8px;
margin-bottom: 0px;
.logs-frequency-chart {
.ant-card-body {
height: 140px;
min-height: 140px;
padding: 0 16px 22px 16px;
font-family: 'Geist Mono';
}
margin-bottom: 0px;
}
}
}

View File

@@ -1,12 +1,10 @@
/* eslint-disable sonarjs/cognitive-complexity */
import './LogsExplorerViews.styles.scss';
import { Button, Switch, Typography } from 'antd';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
import logEvent from 'api/common/logEvent';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -20,20 +18,18 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import Download from 'container/DownloadV2/DownloadV2';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import GoToTop from 'container/GoToTop';
import {} from 'container/LiveLogs/constants';
import LogsExplorerChart from 'container/LogsExplorerChart';
import LogsExplorerList from 'container/LogsExplorerList';
import LogsExplorerTable from 'container/LogsExplorerTable';
import { useOptionsMenu } from 'container/OptionsMenu';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import dayjs from 'dayjs';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQueryData from 'hooks/useUrlQueryData';
@@ -47,7 +43,7 @@ import {
omit,
set,
} from 'lodash-es';
import { ArrowUp10, Minus, Sliders } from 'lucide-react';
import LiveLogs from 'pages/LiveLogs';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { useTimezone } from 'providers/Timezone';
import {
@@ -75,16 +71,12 @@ import {
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import {
DataSource,
LogsAggregatorOperator,
StringOperators,
} from 'types/common/queryBuilder';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { v4 } from 'uuid';
import QueryStatus from './QueryStatus';
import LogsActionsContainer from './LogsActionsContainer';
function LogsExplorerViewsContainer({
selectedView,
@@ -92,6 +84,7 @@ function LogsExplorerViewsContainer({
listQueryKeyRef,
chartQueryKeyRef,
setWarning,
showLiveLogs,
}: {
selectedView: ExplorerViews;
setIsLoadingQueries: React.Dispatch<React.SetStateAction<boolean>>;
@@ -100,10 +93,17 @@ function LogsExplorerViewsContainer({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chartQueryKeyRef: MutableRefObject<any>;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
showLiveLogs: boolean;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dispatch = useDispatch();
const [showFrequencyChart, setShowFrequencyChart] = useState(true);
const [showFrequencyChart, setShowFrequencyChart] = useState(false);
useEffect(() => {
const frequencyChart = getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART);
setShowFrequencyChart(frequencyChart === 'true');
}, []);
// this is to respect the panel type present in the URL rather than defaulting it to list always.
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
@@ -141,7 +141,6 @@ function LogsExplorerViewsContainer({
const [page, setPage] = useState<number>(1);
const [logs, setLogs] = useState<ILog[]>([]);
const [requestData, setRequestData] = useState<Query | null>(null);
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
const [queryId, setQueryId] = useState<string>(v4());
const [queryStats, setQueryStats] = useState<WsDataEvent>();
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
@@ -154,12 +153,6 @@ function LogsExplorerViewsContainer({
return stagedQuery.builder.queryData.find((item) => !item.disabled) || null;
}, [stagedQuery]);
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});
const isMultipleQueries = useMemo(
() =>
currentQuery?.builder?.queryData?.length > 1 ||
@@ -595,41 +588,6 @@ function LogsExplorerViewsContainer({
return isGroupByExist ? data.payload.data.result : firstPayloadQueryArray;
}, [stagedQuery, panelType, data, listChartData, listQuery]);
const formatItems = [
{
key: 'raw',
label: 'Raw',
data: {
title: 'max lines per row',
},
},
{
key: 'list',
label: 'Default',
},
{
key: 'table',
label: 'Column',
data: {
title: 'columns',
},
},
];
const handleToggleShowFormatOptions = (): void =>
setShowFormatMenuItems(!showFormatMenuItems);
const menuRef = useRef<HTMLDivElement>(null);
useClickOutside({
ref: menuRef,
onClickOutside: () => {
if (showFormatMenuItems) {
setShowFormatMenuItems(false);
}
},
});
useEffect(() => {
if (
isLoading ||
@@ -672,118 +630,69 @@ function LogsExplorerViewsContainer({
[logs, timezone.value],
);
const handleToggleFrequencyChart = useCallback(() => {
const newShowFrequencyChart = !showFrequencyChart;
// store the value in local storage
setToLocalstorage(
LOCALSTORAGE.SHOW_FREQUENCY_CHART,
newShowFrequencyChart?.toString() || 'false',
);
setShowFrequencyChart(newShowFrequencyChart);
}, [showFrequencyChart]);
return (
<div className="logs-explorer-views-container">
<div className="logs-explorer-views-types">
<div className="logs-actions-container">
<div className="tab-options">
<div className="tab-options-left">
{selectedPanelType === PANEL_TYPES.LIST && (
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
size="small"
checked={showFrequencyChart}
defaultChecked
onChange={(): void => setShowFrequencyChart(!showFrequencyChart)}
/>
</div>
)}
</div>
<div className="tab-options-right">
{selectedPanelType === PANEL_TYPES.LIST && (
<>
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={(value): void => setOrderBy(value)}
dataSource={DataSource.LOGS}
/>
</div>
<Download
data={flattenLogData}
isLoading={isFetching}
fileName="log_data"
/>
<div className="format-options-container" ref={menuRef}>
<Button
className="periscope-btn ghost"
onClick={handleToggleShowFormatOptions}
icon={<Sliders size={14} />}
data-testid="periscope-btn"
/>
{showFormatMenuItems && (
<LogsFormatOptionsMenu
title="FORMAT"
items={formatItems}
selectedOptionFormat={options.format}
config={config}
/>
)}
</div>
</>
)}
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
selectedPanelType === PANEL_TYPES.TABLE) && (
<div className="query-stats">
<QueryStatus
loading={isLoading || isFetching}
error={isError}
success={isSuccess}
/>
{queryStats?.read_rows && (
<Typography.Text className="rows">
{getYAxisFormattedValue(queryStats.read_rows?.toString(), 'short')}{' '}
rows
</Typography.Text>
)}
{queryStats?.elapsed_ms && (
<>
<div className="divider" />
<Typography.Text className="time">
{getYAxisFormattedValue(queryStats?.elapsed_ms?.toString(), 'ms')}
</Typography.Text>
</>
)}
</div>
)}
</div>
</div>
</div>
{selectedPanelType === PANEL_TYPES.LIST && showFrequencyChart && (
<LogsExplorerChart
className="logs-histogram"
isLoading={isFetchingListChartData || isLoadingListChartData}
data={chartData}
isLogsExplorerViews={panelType === PANEL_TYPES.LIST}
{!showLiveLogs && (
<LogsActionsContainer
listQuery={listQuery}
queryStats={queryStats}
selectedPanelType={selectedPanelType}
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
flattenLogData={flattenLogData}
isFetching={isFetching}
isLoading={isLoading}
isError={isError}
isSuccess={isSuccess}
/>
)}
{selectedPanelType === PANEL_TYPES.LIST &&
showFrequencyChart &&
!showLiveLogs && (
<div className="logs-frequency-chart-container">
<LogsExplorerChart
className="logs-frequency-chart"
isLoading={isFetchingListChartData || isLoadingListChartData}
data={chartData}
isLogsExplorerViews={panelType === PANEL_TYPES.LIST}
/>
</div>
)}
<div className="logs-explorer-views-type-content">
{selectedPanelType === PANEL_TYPES.LIST && (
{showLiveLogs && <LiveLogs />}
{selectedPanelType === PANEL_TYPES.LIST && !showLiveLogs && (
<LogsExplorerList
isLoading={isLoading}
isFetching={isFetching}
currentStagedQueryData={listQuery}
logs={logs}
onEndReached={handleEndReached}
isFrequencyChartVisible={showFrequencyChart}
isError={isError}
error={error as APIError}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
/>
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && (
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
@@ -795,7 +704,7 @@ function LogsExplorerViewsContainer({
/>
)}
{selectedPanelType === PANEL_TYPES.TABLE && (
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||

View File

@@ -174,6 +174,7 @@ const renderer = (): RenderResult =>
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
setWarning={(): void => {}}
showLiveLogs={false}
/>
</PreferenceContextProvider>
</VirtuosoMockContext.Provider>,
@@ -235,6 +236,7 @@ describe('LogsExplorerViews -', () => {
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
setWarning={(): void => {}}
showLiveLogs={false}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,

View File

@@ -1,22 +1,19 @@
import './BreakDown.styles.scss';
import { Alert, Typography } from 'antd';
// import useFilterConfig from 'components/QuickFilters/hooks/useFilterConfig';
// import { SignalType } from 'components/QuickFilters/types';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card, CardContainer } from 'container/GridCardLayout/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
// import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { UpdateTimeInterval } from 'store/actions';
import { Widgets } from 'types/api/dashboard/getAll';
// import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import {
@@ -109,65 +106,11 @@ function Section(section: MetricSection): JSX.Element {
);
}
// function FilterDropdown({ attrKey }: { attrKey: string }): JSX.Element {
// const {
// data: keyValueSuggestions,
// isLoading: isLoadingKeyValueSuggestions,
// } = useGetQueryKeyValueSuggestions({
// key: attrKey,
// signal: DataSource.METRICS,
// signalSource: 'meter',
// options: {
// keepPreviousData: true,
// },
// });
// const responseData = keyValueSuggestions?.data as any;
// const values = responseData?.data?.values || {};
// const stringValues = values.stringValues || [];
// const numberValues = values.numberValues || [];
// const stringOptions = stringValues.filter(
// (value: string | null | undefined): value is string =>
// value !== null && value !== undefined && value !== '',
// );
// const numberOptions = numberValues
// .filter(
// (value: number | null | undefined): value is number =>
// value !== null && value !== undefined,
// )
// .map((value: number) => value.toString());
// const vals = [...stringOptions, ...numberOptions];
// return (
// <div className="filter-dropdown">
// <Typography.Text>{attrKey}</Typography.Text>
// <Select
// loading={isLoadingKeyValueSuggestions}
// options={vals?.map((suggestion: any) => ({
// label: suggestion,
// value: suggestion,
// }))}
// placeholder={`Select ${attrKey}`}
// />
// </div>
// );
// }
function BreakDown(): JSX.Element {
// const { customFilters } = useFilterConfig({
// signal: SignalType.METER_EXPLORER,
// config: [],
// });
const { isCloudUser } = useGetTenantLicense();
return (
<div className="meter-explorer-breakdown">
<section className="meter-explorer-date-time">
{/* {customFilters.map((filter) => (
<FilterDropdown key={filter.key} attrKey={filter.key} />
))} */}
<DateTimeSelectionV2 showAutoRefresh={false} />
</section>
<section className="meter-explorer-graphs">
@@ -178,11 +121,13 @@ function BreakDown(): JSX.Element {
message="Billing is calculated in UTC. To match your meter data with billing, select full-day ranges in UTC time (00:00 23:59 UTC).
For example, if youre in IST, for the billing of Jan 1, select your time range as Jan 1, 5:30 AM Jan 2, 5:29 AM IST."
/>
<Alert
type="warning"
showIcon
message="Meter module data is accurate only from 22nd August 2025, 00:00 UTC onwards. Data before this time was collected during the beta phase and may be inaccurate."
/>
{isCloudUser && (
<Alert
type="warning"
showIcon
message="Meter module data is accurate only from 22nd August 2025, 00:00 UTC onwards. Data before this time was collected during the beta phase and may be inaccurate."
/>
)}
</section>
<section className="total">
<Section

View File

@@ -73,7 +73,6 @@ export function ColumnUnitSelector(
<Typography.Text className="heading">Column Units</Typography.Text>
{aggregationQueries.map(({ value, label }) => (
<YAxisUnitSelector
defaultValue={columnUnits[value]}
value={columnUnits[value] || ''}
onSelect={(unitValue: string): void =>
handleColumnUnitSelect(value, unitValue)

View File

@@ -1,6 +1,6 @@
import { AutoComplete, Input, Typography } from 'antd';
import { find } from 'lodash-es';
import { Dispatch, SetStateAction } from 'react';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { flattenedCategories } from './dataFormatCategories';
@@ -14,25 +14,54 @@ const findCategoryByName = (
find(flattenedCategories, (option) => option.name === searchValue);
type OnSelectType = Dispatch<SetStateAction<string>> | ((val: string) => void);
function YAxisUnitSelector({
defaultValue,
value,
onSelect,
fieldLabel,
handleClear,
}: {
defaultValue: string;
value: string;
onSelect: OnSelectType;
fieldLabel: string;
handleClear?: () => void;
}): JSX.Element {
const [inputValue, setInputValue] = useState('');
// Sync input value with the actual value prop
useEffect(() => {
const category = findCategoryById(value);
setInputValue(category?.name || '');
}, [value]);
const onSelectHandler = (selectedValue: string): void => {
onSelect(findCategoryByName(selectedValue)?.id || '');
const category = findCategoryByName(selectedValue);
if (category) {
onSelect(category.id);
setInputValue(selectedValue);
}
};
const onChangeHandler = (inputValue: string): void => {
setInputValue(inputValue);
// Clear the yAxisUnit if input is empty or doesn't match any option
if (!inputValue) {
onSelect('');
}
};
const onClearHandler = (): void => {
setInputValue('');
onSelect('');
if (handleClear) {
handleClear();
}
};
const options = flattenedCategories.map((options) => ({
value: options.name,
}));
return (
<div className="y-axis-unit-selector">
<Typography.Text className="heading">{fieldLabel}</Typography.Text>
@@ -41,9 +70,9 @@ function YAxisUnitSelector({
rootClassName="y-axis-root-popover"
options={options}
allowClear
defaultValue={findCategoryById(defaultValue)?.name}
value={findCategoryById(value)?.name || ''}
onClear={handleClear}
value={inputValue}
onChange={onChangeHandler}
onClear={onClearHandler}
onSelect={onSelectHandler}
filterOption={(inputValue, option): boolean => {
if (option) {

View File

@@ -0,0 +1,240 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react-dom/test-utils';
import YAxisUnitSelector from '../YAxisUnitSelector';
// Mock the dataFormatCategories to have predictable test data
jest.mock('../dataFormatCategories', () => ({
flattenedCategories: [
{ id: 'seconds', name: 'seconds (s)' },
{ id: 'milliseconds', name: 'milliseconds (ms)' },
{ id: 'hours', name: 'hours (h)' },
{ id: 'minutes', name: 'minutes (m)' },
],
}));
const MOCK_SECONDS = 'seconds';
const MOCK_MILLISECONDS = 'milliseconds';
describe('YAxisUnitSelector', () => {
const defaultProps = {
value: MOCK_SECONDS,
onSelect: jest.fn(),
fieldLabel: 'Y Axis Unit',
handleClear: jest.fn(),
};
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.clearAllMocks();
user = userEvent.setup();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Rendering (Read) & (write)', () => {
it('renders with correct field label', () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByText('Y Axis Unit')).toBeInTheDocument();
const input = screen.getByRole('combobox');
expect(input).toHaveValue('seconds (s)');
});
it('renders with custom field label', () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel="Custom Unit Label"
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByText('Custom Unit Label')).toBeInTheDocument();
});
it('displays empty input when value prop is empty', () => {
render(
<YAxisUnitSelector
value=""
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByDisplayValue('')).toBeInTheDocument();
});
it('shows placeholder text', () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByPlaceholderText('Unit')).toBeInTheDocument();
});
it('handles numeric input', async () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
await user.clear(input);
await user.type(input, '12345');
expect(input).toHaveValue('12345');
});
it('handles mixed content input', async () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
await user.clear(input);
await user.type(input, 'Test123!@#');
expect(input).toHaveValue('Test123!@#');
});
});
describe('State Management', () => {
it('syncs input value with value prop changes', async () => {
const { rerender } = render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// Initial value
expect(input).toHaveValue('seconds (s)');
// Change value prop
rerender(
<YAxisUnitSelector
value={MOCK_MILLISECONDS}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
await waitFor(() => {
expect(input).toHaveValue('milliseconds (ms)');
});
});
it('handles empty value prop correctly', async () => {
const { rerender } = render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// Change to empty value
rerender(
<YAxisUnitSelector
value=""
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
await waitFor(() => {
expect(input).toHaveValue('');
});
});
it('handles invalid value prop gracefully', async () => {
const { rerender } = render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// Change to invalid value
rerender(
<YAxisUnitSelector
value="invalid_id"
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
await waitFor(() => {
expect(input).toHaveValue('');
});
});
it('maintains local state during typing', async () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// first clear then type
await user.clear(input);
await user.type(input, 'test');
expect(input).toHaveValue('test');
// Value prop change should not override local typing
await act(async () => {
// Simulate prop change
render(
<YAxisUnitSelector
value="bytes"
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
});
// Local typing should be preserved
expect(input).toHaveValue('test');
});
});
});

View File

@@ -337,7 +337,6 @@ function RightContainer({
{allowYAxisUnit && (
<YAxisUnitSelector
defaultValue={yAxisUnit}
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={

View File

@@ -0,0 +1,80 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import type { PartialPanelTypes } from '../utils';
import { handleQueryChange } from '../utils';
const buildSupersetQuery = (extras?: Record<string, unknown>): Query => ({
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
id: '1',
unit: '1',
builder: {
queryFormulas: [],
queryData: [
{
...initialQueryBuilderFormValuesMap[DataSource.LOGS],
queryName: 'A',
orderBy: [{ columnName: 'x', order: 'asc' }],
limit: 10,
...(extras || {}),
},
{
...initialQueryBuilderFormValuesMap[DataSource.LOGS],
queryName: 'B',
orderBy: [{ columnName: 'x', order: 'desc' }],
limit: 20,
...(extras || {}),
},
],
},
});
describe('handleQueryChange', () => {
test('sets list-specific fields when switching to LIST', () => {
const superset = buildSupersetQuery();
const output = handleQueryChange(
PANEL_TYPES.LIST as keyof PartialPanelTypes,
superset as Query,
PANEL_TYPES.TABLE,
);
const firstQuery = output.builder.queryData[0];
expect(firstQuery.aggregateOperator).toBe('noop');
expect(firstQuery.offset).toBe(0);
expect(firstQuery.pageSize).toBe(10);
expect(firstQuery.orderBy).toBeUndefined();
expect(firstQuery.queryName).toBe('A');
const secondQuery = output.builder.queryData[1];
expect(secondQuery.aggregateOperator).toBe('noop');
expect(secondQuery.offset).toBe(0);
expect(secondQuery.pageSize).toBe(10);
expect(secondQuery.orderBy).toBeUndefined();
expect(secondQuery.queryName).toBe('B');
});
test('resets noop and pagination when leaving LIST', () => {
const superset = buildSupersetQuery({
aggregateOperator: 'noop',
offset: 5,
pageSize: 50,
});
const output = handleQueryChange(
PANEL_TYPES.TABLE as keyof PartialPanelTypes,
superset as Query,
PANEL_TYPES.LIST,
);
const q = output.builder.queryData[0];
expect(q.aggregateOperator).not.toBe('noop');
expect(q.offset).toBeUndefined();
expect(q.pageSize).toBeUndefined();
expect(q.orderBy).toBeUndefined();
expect(q.queryName).toBe('A');
});
});

View File

@@ -393,6 +393,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
[DataSource.LOGS]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
@@ -404,12 +405,13 @@ export const panelTypeDataSourceFormValuesMap: Record<
},
[DataSource.METRICS]: {
builder: {
queryData: ['filters', 'filter', 'aggregations'],
queryData: ['queryName', 'filters', 'filter', 'aggregations'],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',

View File

@@ -65,17 +65,6 @@ const useOptionsMenu = ({
const [isFocused, setIsFocused] = useState<boolean>(false);
const debouncedSearchText = useDebounce(searchText, 300);
// const initialQueryParams = useMemo(
// () => ({
// searchText: '',
// aggregateAttribute: '',
// tagType: undefined,
// dataSource,
// aggregateOperator,
// }),
// [dataSource, aggregateOperator],
// );
const initialQueryParamsV5: QueryKeyRequestProps = useMemo(
() => ({
signal: dataSource,
@@ -89,22 +78,6 @@ const useOptionsMenu = ({
redirectWithQuery: redirectWithOptionsData,
} = useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
// const initialQueries = useMemo(
// () =>
// initialOptions?.selectColumns?.map((column) => ({
// queryKey: column,
// queryFn: (): Promise<
// SuccessResponse<IQueryAutocompleteResponse> | ErrorResponse
// > =>
// getAggregateKeys({
// ...initialQueryParams,
// searchText: column,
// }),
// enabled: !!column && !optionsQuery,
// })) || [],
// [initialOptions?.selectColumns, initialQueryParams, optionsQuery],
// );
const initialQueriesV5 = useMemo(
() =>
initialOptions?.selectColumns?.map((column) => ({

View File

@@ -9,8 +9,6 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import APIError from 'types/api/error';
import { Container } from '../styles';
function AddDomain({ refetch }: Props): JSX.Element {
const { t } = useTranslation(['common', 'organizationsettings']);
const [isAddDomains, setIsDomain] = useState(false);
@@ -42,7 +40,7 @@ function AddDomain({ refetch }: Props): JSX.Element {
return (
<>
<Container>
<div className="auth-domains-title-container">
<Typography.Title level={3}>
{t('authenticated_domains', {
ns: 'organizationsettings',
@@ -55,10 +53,11 @@ function AddDomain({ refetch }: Props): JSX.Element {
>
{t('add_domain', { ns: 'organizationsettings' })}
</Button>
</Container>
</div>
<Modal
centered
title="Add Domain"
className="add-domain-modal"
footer={null}
open={isAddDomains}
destroyOnClose

View File

@@ -244,7 +244,7 @@ function AuthDomains(): JSX.Element {
/>
</Modal>
<Space direction="vertical" size="middle">
<div className="auth-domains-container">
<AddDomain refetch={refetch} />
<ResizeTable
@@ -255,7 +255,7 @@ function AuthDomains(): JSX.Element {
rowKey={(record: AuthDomain): string => record.name + v4()}
bordered
/>
</Space>
</div>
</>
);
}

View File

@@ -5,6 +5,7 @@ export const Container = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
`;
export const ColumnWithTooltip = styled(Row)`

View File

@@ -1,4 +1,4 @@
import { PlusOutlined } from '@ant-design/icons';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
Button,
Form,
@@ -30,7 +30,7 @@ function InviteTeamMembers({ form, onFinish }: Props): JSX.Element {
initialValues={{ members: [{ email: '', name: '', role: 'VIEWER' }] }}
>
<Form.List name="members">
{(fields, { add }): JSX.Element => (
{(fields, { add, remove }): JSX.Element => (
<SpaceContainer direction="vertical" align="center" size="middle">
{fields.map(({ key, name }) => (
<Space key={key} direction="horizontal" align="start">
@@ -56,6 +56,14 @@ function InviteTeamMembers({ form, onFinish }: Props): JSX.Element {
<Select.Option value="EDITOR">EDITOR</Select.Option>
</SelectDrawer>
</Form.Item>
<Button
type="text"
icon={<DeleteOutlined />}
onClick={(): void => remove(name)}
danger
data-testid={`delete-member-${name}`}
disabled={fields.length === 1}
/>
</Space>
))}
<Form.Item>

View File

@@ -152,6 +152,7 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
onCancel={(): void => toggleModal(false)}
centered
data-testid="invite-team-members-modal"
className="invite-user-modal"
destroyOnClose
footer={[
<Button key="back" onClick={(): void => toggleModal(false)} type="default">

View File

@@ -157,6 +157,7 @@ function UserFunction({
</Space>
<Modal
title="Edit member details"
className="edit-member-details-modal"
open={isModalVisible}
onOk={(): void => onModalToggleHandler(setIsModalVisible, false)}
onCancel={(): void => onModalToggleHandler(setIsModalVisible, false)}
@@ -285,7 +286,7 @@ function Members(): JSX.Element {
];
return (
<Space direction="vertical" size="middle">
<div className="members-container">
<Typography.Title level={3}>
Members{' '}
{!isLoading && dataSource && (
@@ -300,7 +301,7 @@ function Members(): JSX.Element {
loading={status === 'loading'}
bordered
/>
</Space>
</div>
);
}

View File

@@ -1,18 +1,40 @@
.organization-settings-container {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px auto;
gap: 24px;
margin: 16px;
padding: 16px;
width: 90%;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
}
.pending-invites-container,
.members-container,
.auth-domains-container {
display: flex;
flex-direction: column;
gap: 8px;
h3 {
margin-bottom: 0px;
}
}
.auth-domains-title-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 8px;
h3 {
margin-bottom: 0px;
}
}
.lightMode {
.organization-settings-container {
border: 1px solid var(--bg-vanilla-300);

View File

@@ -180,7 +180,7 @@ function PendingInvitesContainer(): JSX.Element {
];
return (
<div>
<div className="pending-invites-container-wrapper">
<InviteUserModal
form={form}
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
@@ -189,7 +189,7 @@ function PendingInvitesContainer(): JSX.Element {
shouldCallApi
/>
<Space direction="vertical" size="middle">
<div className="pending-invites-container">
<TitleWrapper>
<Typography.Title level={3}>
{t('pending_invites')}
@@ -218,7 +218,7 @@ function PendingInvitesContainer(): JSX.Element {
loading={getPendingInvitesResponse.status === 'loading'}
bordered
/>
</Space>
</div>
</div>
);
}

View File

@@ -1,4 +1,6 @@
import { Divider, Space } from 'antd';
import './OrganizationSettings.styles.scss';
import { Space } from 'antd';
import { useAppContext } from 'providers/App/App';
import AuthDomains from './AuthDomains';
@@ -24,7 +26,6 @@ function OrganizationSettings(): JSX.Element {
<PendingInvitesContainer />
<Members />
<Divider />
<AuthDomains />
</div>
);

View File

@@ -20,7 +20,6 @@ const activeTab = 'active-tab';
export default function LeftToolbarActions({
items,
selectedView,
onChangeSelectedView,
showFilter,
handleFilterVisibilityChange,

View File

@@ -12,6 +12,7 @@ interface RightToolbarActionsProps {
isLoadingQueries?: boolean;
listQueryKeyRef?: MutableRefObject<any>;
chartQueryKeyRef?: MutableRefObject<any>;
showLiveLogs?: boolean;
}
export default function RightToolbarActions({
@@ -19,19 +20,25 @@ export default function RightToolbarActions({
isLoadingQueries,
listQueryKeyRef,
chartQueryKeyRef,
showLiveLogs,
}: RightToolbarActionsProps): JSX.Element {
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const queryClient = useQueryClient();
useEffect(() => {
if (showLiveLogs) return;
registerShortcut(LogsExplorerShortcuts.StageAndRunQuery, onStageRunQuery);
return (): void => {
deregisterShortcut(LogsExplorerShortcuts.StageAndRunQuery);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onStageRunQuery]);
}, [onStageRunQuery, showLiveLogs]);
if (showLiveLogs) return <div />;
return (
<div>
{isLoadingQueries ? (
@@ -71,4 +78,5 @@ RightToolbarActions.defaultProps = {
isLoadingQueries: false,
listQueryKeyRef: null,
chartQueryKeyRef: null,
showLiveLogs: false,
};

View File

@@ -42,11 +42,11 @@
gap: 8px;
&.active-tab {
background-color: var(--bg-ink-500);
border-bottom: 1px solid var(--bg-ink-500);
background-color: var(--bg-robin-500);
border-bottom: 1px solid var(--bg-robin-500);
&:hover {
background-color: var(--bg-ink-500) !important;
background-color: var(--bg-robin-500) !important;
}
}

View File

@@ -2,7 +2,13 @@ import { Button, Popover, Spin, Tooltip } from 'antd';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions';
import { ArrowDownToDot, ArrowUpFromDot, Copy, Ellipsis } from 'lucide-react';
import {
ArrowDownToDot,
ArrowUpFromDot,
Copy,
Ellipsis,
Pin,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
interface AttributeRecord {
@@ -12,10 +18,14 @@ interface AttributeRecord {
interface AttributeActionsProps {
record: AttributeRecord;
isPinned?: boolean;
onTogglePin: (fieldKey: string) => void;
}
export default function AttributeActions({
record,
isPinned,
onTogglePin,
}: AttributeActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
@@ -79,6 +89,10 @@ export default function AttributeActions({
setIsOpen(false);
}, [onCopyFieldValue, textToCopy]);
const handleTogglePin = useCallback((): void => {
onTogglePin(record.field);
}, [onTogglePin, record.field]);
const moreActionsContent = (
<div className="attribute-actions-menu">
<Button
@@ -111,6 +125,14 @@ export default function AttributeActions({
return (
<div className="action-btn">
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
@@ -158,3 +180,7 @@ export default function AttributeActions({
</div>
);
}
AttributeActions.defaultProps = {
isPinned: false,
};

View File

@@ -32,6 +32,17 @@
}
}
.item-key-wrapper {
display: flex;
align-items: center;
gap: 6px;
.pin-icon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
}
.item-key {
color: var(--bg-vanilla-100);
font-family: Inter;

View File

@@ -4,12 +4,19 @@ import { Input, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { flattenObject } from 'container/LogDetailedView/utils';
import { useMemo, useState } from 'react';
import { usePinnedAttributes } from 'hooks/spanDetails/usePinnedAttributes';
import { Pin } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
import AttributeActions from './AttributeActions';
interface AttributeRecord {
field: string;
value: string;
}
interface IAttributesProps {
span: Span;
isSearchVisible: boolean;
@@ -24,11 +31,38 @@ function Attributes(props: IAttributesProps): JSX.Element {
[span],
);
const datasource = Object.keys(flattenSpanData)
.filter((attribute) =>
attribute.toLowerCase().includes(fieldSearchInput.toLowerCase()),
)
.map((key) => ({ field: key, value: flattenSpanData[key] }));
const availableAttributes = useMemo(() => Object.keys(flattenSpanData), [
flattenSpanData,
]);
const { pinnedAttributes, togglePin } = usePinnedAttributes(
availableAttributes,
);
const sortPinnedAttributes = useCallback(
(data: AttributeRecord[]): AttributeRecord[] =>
data.sort((a, b) => {
const aIsPinned = pinnedAttributes[a.field];
const bIsPinned = pinnedAttributes[b.field];
if (aIsPinned && !bIsPinned) return -1;
if (!aIsPinned && bIsPinned) return 1;
// Within same pinning status, maintain alphabetical order
return a.field.localeCompare(b.field);
}),
[pinnedAttributes],
);
const datasource = useMemo(() => {
const filtered = Object.keys(flattenSpanData)
.filter((attribute) =>
attribute.toLowerCase().includes(fieldSearchInput.toLowerCase()),
)
.map((key) => ({ field: key, value: flattenSpanData[key] }));
return sortPinnedAttributes(filtered);
}, [flattenSpanData, fieldSearchInput, sortPinnedAttributes]);
return (
<div className="attributes-corner">
@@ -49,19 +83,31 @@ function Attributes(props: IAttributesProps): JSX.Element {
className={cx('attributes-container', isSearchVisible ? 'border-top' : '')}
>
{datasource.map((item) => (
<div className="item" key={`${item.field} + ${item.value}`}>
<Typography.Text className="item-key" ellipsis>
{item.field}
</Typography.Text>
<div
className={cx('item', { pinned: pinnedAttributes[item.field] })}
key={`${item.field} + ${item.value}`}
>
<div className="item-key-wrapper">
<Typography.Text className="item-key" ellipsis>
{item.field}
</Typography.Text>
{pinnedAttributes[item.field] && (
<Pin size={14} className="pin-icon" fill="currentColor" />
)}
</div>
<div className="value-wrapper">
<Tooltip title={item.value}>
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
<Typography.Text className="item-value" ellipsis>
{item.value}
</Typography.Text>
</Typography.Text>{' '}
</CopyClipboardHOC>
</Tooltip>
<AttributeActions record={item} />
<AttributeActions
record={item}
isPinned={pinnedAttributes[item.field]}
onTogglePin={togglePin}
/>
</div>
</div>
))}

View File

@@ -2,6 +2,8 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { AppProvider } from 'providers/App/App';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { MemoryRouter, Route } from 'react-router-dom';
import { Span } from 'types/api/trace/getTraceV2';
@@ -109,23 +111,26 @@ const createMockSpan = (): Span => ({
subTreeNodeCount: 0,
level: 0,
});
const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
const user = userEvent.setup();
const component = render(
<MemoryRouter>
<Route>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={span}
traceID={span.traceId}
traceStartTime={span.timestamp}
traceEndTime={span.timestamp + span.durationNano}
/>
</Route>
</MemoryRouter>,
<MockQueryClientProvider>
<AppProvider>
<MemoryRouter>
<Route>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={span}
traceID={span.traceId}
traceStartTime={span.timestamp}
traceEndTime={span.timestamp + span.durationNano}
/>
</Route>
</MemoryRouter>{' '}
</AppProvider>
</MockQueryClientProvider>,
);
return { ...component, user };

View File

@@ -19,7 +19,7 @@
.timeRange {
display: flex;
align-items: center;
gap: 16px;
gap: 8px;
}
}

View File

@@ -1,8 +1,10 @@
import './Toolbar.styles.scss';
import ROUTES from 'constants/routes';
import LiveLogsPauseResume from 'container/LiveLogs/LiveLogsPauseResume/LiveLogsPauseResume';
import NewExplorerCTA from 'container/NewExplorerCTA';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { noop } from 'lodash-es';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
@@ -12,6 +14,9 @@ interface ToolbarProps {
rightActions?: JSX.Element;
showOldCTA?: boolean;
warningElement?: JSX.Element;
onGoLive?: () => void;
onExitLiveLogs?: () => void;
showLiveLogs?: boolean;
}
export default function Toolbar({
@@ -20,6 +25,9 @@ export default function Toolbar({
rightActions,
showOldCTA,
warningElement,
showLiveLogs,
onGoLive,
onExitLiveLogs,
}: ToolbarProps): JSX.Element {
const { pathname } = useLocation();
@@ -39,7 +47,11 @@ export default function Toolbar({
<div className="timeRange">
{warningElement}
{showOldCTA && <NewExplorerCTA />}
{showLiveLogs && <LiveLogsPauseResume />}
<DateTimeSelectionV2
showLiveLogs={showLiveLogs}
onExitLiveLogs={onExitLiveLogs}
onGoLive={onGoLive}
showAutoRefresh={showAutoRefresh}
showRefreshText={!isLogsExplorerPage && !isApiMonitoringPage}
hideShareModal
@@ -57,4 +69,7 @@ Toolbar.defaultProps = {
rightActions: <div />,
showOldCTA: false,
warningElement: <div />,
showLiveLogs: false,
onGoLive: (): void => noop(),
onExitLiveLogs: (): void => {},
};

View File

@@ -9,17 +9,7 @@ import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import {
constructCompositeQuery,
defaultLiveQueryDataConfig,
} from 'container/LiveLogs/constants';
import { QueryHistoryState } from 'container/LiveLogs/types';
import NewExplorerCTA from 'container/NewExplorerCTA';
import dayjs, { Dayjs } from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -31,7 +21,6 @@ import { cloneDeep, isObject } from 'lodash-es';
import { Check, Copy, Info, Send, Undo } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useState } from 'react';
import { useQueryClient } from 'react-query';
import { connect, useDispatch, useSelector } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat';
@@ -41,8 +30,6 @@ import { ThunkDispatch } from 'redux-thunk';
import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { v4 as uuid } from 'uuid';
@@ -78,6 +65,9 @@ function DateTimeSelection({
modalSelectedInterval,
modalInitialStartTime,
modalInitialEndTime,
onGoLive,
onExitLiveLogs,
showLiveLogs,
}: Props): JSX.Element {
const [formSelector] = Form.useForm();
const { safeNavigate } = useSafeNavigate();
@@ -91,7 +81,6 @@ function DateTimeSelection({
const searchStartTime = urlQuery.get('startTime');
const searchEndTime = urlQuery.get('endTime');
const relativeTimeFromUrl = urlQuery.get(QueryParams.relativeTime);
const queryClient = useQueryClient();
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(false);
const [isValidteRelativeTime, setIsValidteRelativeTime] = useState(false);
const [, handleCopyToClipboard] = useCopyToClipboard();
@@ -188,54 +177,7 @@ function DateTimeSelection({
false,
);
const {
stagedQuery,
currentQuery,
initQueryBuilderData,
panelType,
} = useQueryBuilder();
const handleGoLive = useCallback(() => {
if (!stagedQuery) return;
setIsOpen(false);
let queryHistoryState: QueryHistoryState | null = null;
const compositeQuery = constructCompositeQuery({
query: stagedQuery,
initialQueryData: initialQueryBuilderFormValuesMap.logs,
customQueryData: defaultLiveQueryDataConfig,
});
const isListView =
panelType === PANEL_TYPES.LIST && stagedQuery.builder.queryData[0];
if (isListView) {
const [graphQuery, listQuery] = queryClient.getQueriesData<
SuccessResponse<MetricRangePayloadProps> | ErrorResponse
>({
queryKey: REACT_QUERY_KEY.GET_QUERY_RANGE,
active: true,
});
queryHistoryState = {
graphQueryPayload:
graphQuery && graphQuery[1]
? graphQuery[1].payload?.data.result || []
: [],
listQueryPayload:
listQuery && listQuery[1]
? listQuery[1].payload?.data?.newResult?.data?.result || []
: [],
};
}
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(compositeQuery));
const path = `${ROUTES.LIVE_LOGS}?${QueryParams.compositeQuery}=${JSONCompositeQuery}`;
safeNavigate(path, { state: queryHistoryState });
}, [panelType, queryClient, safeNavigate, stagedQuery]);
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
@@ -803,8 +745,12 @@ function DateTimeSelection({
const { timezone } = useTimezone();
const getSelectedValue = (): string =>
getInputLabel(
const getSelectedValue = (): string => {
if (showLiveLogs) {
return 'live';
}
return getInputLabel(
dayjs(isModalTimeSelection ? modalStartTime : minTime / 1000000).tz(
timezone.value,
),
@@ -813,6 +759,7 @@ function DateTimeSelection({
),
isModalTimeSelection ? modalSelectedInterval : selectedTime,
);
};
return (
<div className="date-time-selector">
@@ -873,11 +820,13 @@ function DateTimeSelection({
selectedValue={getSelectedValue()}
data-testid="dropDown"
items={options}
showLiveLogs={showLiveLogs}
newPopover
handleGoLive={handleGoLive}
onGoLive={onGoLive}
onCustomDateHandler={onCustomDateHandler}
customDateTimeVisible={customDateTimeVisible}
setCustomDTPickerVisible={setCustomDTPickerVisible}
onExitLiveLogs={onExitLiveLogs}
/>
{showAutoRefresh && selectedTime !== 'custom' && (
@@ -933,6 +882,9 @@ interface DateTimeSelectionV2Props {
modalSelectedInterval?: Time;
modalInitialStartTime?: number;
modalInitialEndTime?: number;
showLiveLogs?: boolean;
onGoLive?: () => void;
onExitLiveLogs?: () => void;
}
DateTimeSelection.defaultProps = {
@@ -946,6 +898,9 @@ DateTimeSelection.defaultProps = {
modalSelectedInterval: RelativeTimeMap['5m'] as Time,
modalInitialStartTime: undefined,
modalInitialEndTime: undefined,
onGoLive: (): void => {},
onExitLiveLogs: (): void => {},
showLiveLogs: false,
};
interface DispatchProps {
updateTimeInterval: (

View File

@@ -12,6 +12,7 @@ export type UseCopyLogLink = {
isLogsExplorerPage: boolean;
activeLogId: string | null;
onLogCopy: MouseEventHandler<HTMLElement>;
onClearActiveLog: () => void;
};
export type UseActiveLog = {

View File

@@ -1,6 +1,7 @@
import { toast } from '@signozhq/sonner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import {
@@ -21,9 +22,10 @@ import { UseCopyLogLink } from './types';
export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const { pathname, search } = useLocation();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const { safeNavigate } = useSafeNavigate();
const { queryData: activeLogId } = useUrlQueryData<string | null>(
QueryParams.activeLogId,
@@ -58,13 +60,19 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
notifications.success({
message: 'Copied to clipboard',
});
toast.success('Copied to clipboard', { position: 'top-right' });
},
[logId, urlQuery, minTime, maxTime, pathname, setCopy, notifications],
[logId, urlQuery, minTime, maxTime, pathname, setCopy],
);
const onClearActiveLog = useCallback(() => {
const currentUrlQuery = new URLSearchParams(search);
currentUrlQuery.delete(QueryParams.activeLogId);
const newUrl = `${pathname}?${currentUrlQuery.toString()}`;
safeNavigate(newUrl);
}, [pathname, search, safeNavigate]);
useEffect(() => {
if (!isActiveLog) return;
@@ -81,5 +89,6 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
isLogsExplorerPage,
activeLogId,
onLogCopy,
onClearActiveLog,
};
};

View File

@@ -47,13 +47,15 @@ export const useGetCompositeQueryParam = (): Query | null => {
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression(
query.aggregateOperator,
query.aggregateAttribute as BaseAutocompleteData,
query.dataSource,
query.timeAggregation,
query.spaceAggregation,
) as any; // Type assertion to handle union type
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;

View File

@@ -0,0 +1,124 @@
import setLocalStorageApi from 'api/browser/localstorage/set';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { LOCALSTORAGE } from 'constants/localStorage';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useLocalStorage } from '../useLocalStorage';
interface UsePinnedAttributesReturn {
pinnedAttributes: Record<string, boolean>;
togglePin: (attributeKey: string) => void;
isPinned: (attributeKey: string) => boolean;
}
/**
* Hook for managing pinned span attributes with backend persistence.
* Falls back to localStorage during initial load and handles migration.
*
* @param availableAttributes - Object keys of the current span's flattened attributes
* @returns Object with pinned state, toggle function, and check function
*/
export function usePinnedAttributes(
availableAttributes: string[],
): UsePinnedAttributesReturn {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
// API mutation for updating preferences
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// Local state for optimistic updates
const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
// Get localStorage fallback for initial load
const [localStoragePinnedKeys] = useLocalStorage<string[]>(
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
[],
);
// Initialize from user preferences when loaded
useEffect(() => {
if (userPreferences !== null) {
const preference = userPreferences.find(
(pref) => pref.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
if (preference?.value) {
// use backend data
setPinnedKeys(preference.value as string[]);
} else if (localStoragePinnedKeys.length > 0) {
// use local storage data
setPinnedKeys(localStoragePinnedKeys);
}
}
}, [userPreferences, localStoragePinnedKeys]);
// Create pinned attributes state from stored keys, filtering by available attributes
const pinnedAttributes = useMemo(
(): Record<string, boolean> =>
pinnedKeys.reduce((acc, key) => {
// Only include if the attribute exists in the current span
if (availableAttributes.includes(key)) {
acc[key] = true;
}
return acc;
}, {} as Record<string, boolean>),
[pinnedKeys, availableAttributes],
);
// Toggle pin state for an attribute
const togglePin = useCallback(
(attributeKey: string): void => {
const currentlyPinned = pinnedKeys.includes(attributeKey);
const newPinnedKeys = currentlyPinned
? pinnedKeys.filter((key) => key !== attributeKey)
: [...pinnedKeys, attributeKey];
// Optimistically update local state for instant UI feedback
setPinnedKeys(newPinnedKeys);
updateUserPreferenceInContext({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: newPinnedKeys,
} as UserPreference);
// Save to localStorage immediately for offline resilience
setLocalStorageApi(
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
JSON.stringify(newPinnedKeys),
);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: newPinnedKeys,
});
},
[pinnedKeys, updateUserPreferenceInContext, updateUserPreferenceMutation],
);
// Check if an attribute is pinned
const isPinned = useCallback(
(attributeKey: string): boolean => pinnedAttributes[attributeKey] === true,
[pinnedAttributes],
);
return {
pinnedAttributes,
togglePin,
isPinned,
};
}

View File

@@ -40,6 +40,13 @@ const useDragColumns = <T>(storageKey: LOCALSTORAGE): UseDragColumns<T> => {
[handleRedirectWithDraggedColumns],
);
const onColumnOrderChange = useCallback(
(newColumns: ColumnsType<T>): void => {
handleRedirectWithDraggedColumns(newColumns);
},
[handleRedirectWithDraggedColumns],
);
const redirectWithNewDraggedColumns = useCallback(
async (localStorageColumns: string) => {
let nextDraggedColumns: ColumnsType<T> = [];
@@ -69,6 +76,7 @@ const useDragColumns = <T>(storageKey: LOCALSTORAGE): UseDragColumns<T> => {
return {
draggedColumns,
onDragColumns,
onColumnOrderChange,
};
};

View File

@@ -7,4 +7,5 @@ export type UseDragColumns<T> = {
fromIndex: number,
toIndex: number,
) => void;
onColumnOrderChange: (newColumns: ColumnsType<T>) => void;
};

View File

@@ -0,0 +1,25 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { liveLogsCompositeQuery } from 'container/LiveLogs/constants';
import LiveLogsContainer from 'container/LiveLogs/LiveLogsContainer';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { useEffect } from 'react';
import { DataSource } from 'types/common/queryBuilder';
function LiveLogs(): JSX.Element {
useShareBuilderUrl({ defaultValue: liveLogsCompositeQuery });
const { handleSetConfig } = useQueryBuilder();
useEffect(() => {
handleSetConfig(PANEL_TYPES.LIST, DataSource.LOGS);
}, [handleSetConfig]);
return (
<PreferenceContextProvider>
<LiveLogsContainer />
</PreferenceContextProvider>
);
}
export default LiveLogs;

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