Compare commits

...

29 Commits

Author SHA1 Message Date
Gaurav Tewari
d48dbec881 fix: minor issue 2026-06-03 14:06:04 +05:30
Gaurav Tewari
a81f66b995 fix: ui migration issues 2026-06-03 13:48:39 +05:30
Ashwin Bhatkal
af72a4118b fix: resolve UX regressions across dashboards, metrics and alerts pages after component migration (#11546)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: resolve UX regressions across dashboards, metrics and alerts pages after component migration

- NewSelect: make the @signozhq checkbox label fill the row (min-width: 0)
  so long option text truncates with an ellipsis; size Only/Toggle buttons to
  the resting row height so hover no longer grows the row
- PlannedDowntime: reset field-label styles bleeding into the migrated
  RadioGroup option labels and add spacing between options
- Page-level contrast, spacing, alignment and icon-size fixes across
  dashboard settings, metrics explorer, alert rules, routing policies,
  auto-refresh and the time picker

* fix(multiselect): surface clear icon and remove dropdown double-scroll

- add .ant-select-clear color override so the multiselect clear (x) icon is
  visible on the dark selector (the single-select had it, multiselect didn't)
- raise the dropdown max-height so the react-virtuoso list (<=300px) plus the
  header/footer fits, leaving the list as the single scroller instead of the
  list and the container both scrolling
- StatsCard: drop the unnecessary !important on .count-label color

* fix: success colors
2026-06-03 05:16:01 +00:00
Nityananda Gohain
dc6a1d0a4e fix: skip resource filter if it exceeds a threshold (#11524)
* fix: skip resource filter if it exceeds a threshold

* fix: test cleanup

* fix: test cleanup

* fix: more cleanup

* fix: tests
2026-06-03 05:08:40 +00:00
Aditya Singh
2e60ab0f81 fix(logs): dedupe body/timestamp columns in explorer picker (#11553)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: duplicate body issue

* fix: update test
2026-06-02 20:15:32 +00:00
SagarRajput-7
29ed44a8ce fix(sso): send empty object when clearing group/domain mappings on save (#11550) 2026-06-02 19:30:09 +00:00
Aditya Singh
76ee298605 feat(logs/traces): migrate column picker UI to FieldsSelector (#11516)
* feat: field selector migrated to telemetry field key

* feat: move floating panel to field selector

* feat: sync columns state in logs

* feat: sync columns state in traces

* feat: logs field migration integrate

* feat: traces field migration integrate

* feat: minor refactor

* feat: tests updated

* feat: move to key from name on fields for logs and traces

* feat: update tests

* feat: update tests
2026-06-02 17:37:27 +00:00
Tushar Vats
218f8269dd fix: donot close suggestion on typing period (#11490) 2026-06-02 17:35:12 +00:00
Nityananda Gohain
e8effa5b3f feat: [traces] time aware dynamic field mapper (#11234)
* feat: [traces] time aware dynamic field mapper

* fix: minor changes

* fix: get keys after modifying the selectkeys

* fix: more updated

* fix: lint

* fix: address comments

* fix: tests

* fix: tests

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-02 16:17:42 +00:00
Vikrant Gupta
ca5ff7f617 fix(web): update web settings config (#11548)
* fix(web): update web settings config

* fix(web): add test case for config settings
2026-06-02 15:12:51 +00:00
Vinicius Lourenço
a487b311bc fix(metrics-explorer): handle in case .data is undefined (#11527)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-02 13:46:53 +00:00
Vinicius Lourenço
6473066193 fix(metrics-explorer): handle in case .atributes is undefined (#11528) 2026-06-02 13:46:52 +00:00
SagarRajput-7
fb0d34ae35 feat(auth): validate reset password token on page load before showing form (#11522)
* feat(auth): validate reset password token on page load before showing form

* fix(auth): distinct error copy for expired vs invalid token; skip 401 rotation on verify endpoint

* fix(auth): use endsWith for orval-generated endpoint guards in interceptorRejected

* Revert "fix(auth): use endsWith for orval-generated endpoint guards in interceptorRejected"

This reverts commit 00aa23b8fc.
2026-06-02 13:38:18 +00:00
Vinicius Lourenço
ba684acba3 fix(create-alert-v2): tooltip not showing due to pointer-events none (#11489) 2026-06-02 12:05:02 +00:00
Gaurav Tewari
184724003a chore: remove query status (#11476)
* chore: remove confusing query status

* chore: remove extra things

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-02 11:48:40 +00:00
Vinicius Lourenço
a4d3f10da8 chore(codeowners): add pulse for alerts and infra monitoring pages (#11508) 2026-06-02 11:44:38 +00:00
Nityananda Gohain
a71ac2ada6 fix: add adjustkeys in trace operator cte builder (#11349)
* fix: add adjustkeys in trace operator cte builder

* fix: more fixes

* fix: cleanup

* fix: move tests to trace operator file

* fix: address comments

* fix: lint issues

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-02 10:59:15 +00:00
Vikrant Gupta
0963ff08cd feat(web): disable all integrations by default (#11539) 2026-06-02 09:32:20 +00:00
Nikhil Soni
e43aeb8e24 fix: add references in waterfall response (#11536)
* fix: add references in waterfall response

* chore: update openapi specs

* chore: mark reference a non nullable field
2026-06-02 08:42:26 +00:00
Aditya Singh
9074208b09 feat: added trace events (#11538) 2026-06-02 08:41:39 +00:00
Vishal Sharma
571e23910e feat(ai-assistant): show descriptive Noz hover tooltip on all entry points (#11526)
Noz launched under early access and its three entry points (header button,
floating trigger, sidebar nav item) previously showed a bare "Noz" tooltip,
which does not educate users who don't yet recognize the name. Replace it with
a shared descriptive tooltip ("Noz, your AI teammate") sourced from a single
constant so the surfaces never drift.

- Add NOZ_TOOLTIP_TITLE in components/Noz/Noz.constants.ts
- Header button and floating trigger read the constant
- Add optional tooltip field to SidebarItem; NavItem wraps the whole row in a
  Tooltip (non-pinnable items only, to avoid nesting with the pin tooltip)
- Move the trigger's Noz icon to the Button prefix slot to match the codebase
  convention for icon-only buttons
2026-06-02 06:53:57 +00:00
Nikhil Soni
5e94f7ac6e feat(trace-details): Add API endpoint & module for trace aggregations (#11452)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: add endpoint & module for trace aggregations

* chore: use v1 for aggregations api

* chore: update openapi specs

* feat: add implementation for aggregation store

* chore: use query builder for count by field

* chore: remove support for aggregations on attributes

Only supporting resource fields for now, since this
is not user input but hard coded client experience

* chore: extract out inner query as cte

* chore: again extract out inner query

* chore: move end_ns computation to first cte for simplicity

* chore: use notEmpty function instead of having

* fix: type cast issue in sum function

* chore: use query builder for duration query as well

* chore: add tests for trace store sql

* chore: remove unnecessary column checks

* chore: format the expected sql queries

* fix: formating was breaking test

* fix: change import and remove formating from sql

* chore: remove formating from store impl as well

* fix: mark required fields as required

* fix: explicitly mark nullable false for required field

* fix: mark required fields in response as well
2026-06-01 15:32:09 +00:00
Gaurav Tewari
387ad06c2d fix: tag container styles in qb (#11503)
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-01 14:09:06 +00:00
Aditya Singh
72ff433c20 feat(logs/traces): streamline column state — selectColumns becomes canonical source (#11426)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* feat: field selector migrated to telemetry field key

* feat: move floating panel to field selector

* feat: sync columns state in logs

* feat: sync columns state in traces
2026-06-01 13:01:05 +00:00
Vinicius Lourenço
587f518599 refactor(infra-monitoring): removed primary filters deprecated prop (#11505) 2026-06-01 11:32:15 +00:00
Nityananda Gohain
bfc50ee9c3 chore: use distinct fingerprint in resource filter (#11521)
* chore: use distinct fingerprint in resource filter

* fix: use group by instead of distinct
2026-06-01 09:47:56 +00:00
Tushar Vats
4b08ba1330 fix: qb warnings (#11518) 2026-06-01 09:43:22 +00:00
Ashwin Bhatkal
557a7120df feat: v2 dashboards list page (#11404)
* feat: add useIsDashboardV2 hook

* feat(dashboards-list-v2): page shell

* feat(dashboards-list-v2): loading / error / empty / no-results state components

* feat(dashboards-list-v2): SearchBar input component

* feat(dashboards-list-v2): CreateDashboardDropdown (new / import JSON entry)

* feat(dashboards-list-v2): DashboardRow + per-row ActionsPopover with v2 delete

* feat(dashboards-list-v2): ListHeader with sort/order + Configure Metadata trigger

* feat(dashboards-list-v2): JSON import modal (Monaco editor + sample)

* feat(dashboards-list-v2): Configure Metadata modal + persisted visible-columns store

* feat(dashboards-list-v2): wire list to v2 API with pagination, URL state, debounced search

* chore: park v2 dashboard pages until backend lands on main

* refactor(dashboards-list-v2): extract lastUpdatedLabel into utils

Dedupe the relative-time formatter that was copied into both DashboardRow
and ConfigureMetadataModal. Single source in DashboardsListPageV2/utils.ts.

* refactor(dashboards-list-v2): group state components under states/

Move EmptyState, ErrorState, LoadingState, and NoResultsState under
components/states/. They're a coherent family (interchangeable view
branches in the list orchestrator) and grouping them sets up shared
styling extraction next.

Pure relocation — git mv preserves history; only DashboardsList.tsx's
imports change.

* refactor(dashboards-list-v2): extract shared state wrapper styles

Pull the dashed-border card layout, body text, and learn-more CTA into
states/states.module.scss. EmptyState, ErrorState, and NoResultsState
now compose from the shared base and only declare what differs
(padding, gap, color, font-weight). LoadingState is a different shape
(skeleton stack) and stays untouched.

Cascaded properties are byte-equivalent — pure de-duplication.

* refactor(dashboards-list-v2): port to @signozhq/ui primitives

Replace antd Button/Input usages and bespoke <button> rows with the
@signozhq/ui equivalents across the four review-flagged surfaces:

  - EmptyState "Learn more" → Button variant="link" color="primary"
  - ErrorState "Retry" → Button variant="outlined" color="secondary"
    with prefix icon; drops the sub-pixel .retryButton overrides in
    favour of the variant's tokenized layout.
  - ErrorState "Contact Support" → Button variant="link" color="primary"
  - SearchBar Input → signoz Input (data-testid → testId)
  - ActionsPopover rows → Button variant="ghost" color="secondary"
    (color="destructive" for Delete) with prefix icons
  - ActionsPopover trigger → Button size="icon" variant="ghost"

The signoz button variant handles the icon-slot alignment that the
removed .actionItem styling reimplemented manually, so the bulk of
ActionsPopover SCSS is deleted. A minimal .menuItem class still
left-aligns + fills the popover row, and .deleteDivider keeps the
hairline separator above the destructive action.

Popover itself stays on antd — migrating to @signozhq/ui/dropdown-menu
is deferred (same TODO as CreateDashboardDropdown).

* style(dashboards-list-v2): use font tokens across new SCSS

Replace literal font-size/weight values across the V2 page SCSS with
the shared design-token custom properties already used elsewhere:

  - 14px → var(--font-size-sm)
  - 12px → var(--font-size-xs)
  - 400  → var(--font-weight-normal)
  - 500  → var(--font-weight-medium)
  - 600  → var(--font-weight-semibold)

ErrorState.module.scss was tokenized alongside its .retryButton
removal in the previous commit. Three literals remain because they
have no clean token equivalent: 11px (sub-token), 8px (avatar
initial), and 12.805px (sub-pixel artifact from a Figma export,
a separate concern).

* style(dashboards-list-v2): replace sub-pixel padding in CreateDashboardDropdown

Round 5.937px / 11.875px / 1.484px to clean integer pixel values
(6px / 12px / 2px). The sub-pixel values were the "trial-and-error"
artifact reviewer flagged — likely a Figma scaling round-trip.

Difference is sub-pixel and not visible on any standard DPR.

* feat(dashboards-list-v2): treat search input as DSL with explicit submission

What the user types is what gets sent to the API as the `query`
param — no more wrapping the input in a synthesized
`name CONTAINS '…' or description CONTAINS '…'` clause.

This gives users the full DSL surface (name, description, created_by,
created_at, updated_at, locked, public, source, tag-key equality)
instead of the name+description CONTAINS approximation.

Submission is explicit (Enter, blur, or a return-key icon button
in the input's suffix slot) so an in-progress DSL string never
hits the API mid-typing. On error, the toolbar (search + create)
stays visible above the ErrorState so users can edit and retry
without losing their place.

  - Drop buildSearchDSL helper and the 300ms debounce.
  - Pass searchInput.trim() to the API; empty input clears filter.
  - SearchBar gains onSubmit (Enter / blur / suffix click).
  - Move ErrorState below the toolbar in DashboardsList layout.
  - Sync local input from searchString on URL changes (back/fwd).
  - Placeholder updated to hint at DSL syntax.

Trade-off: a bare "foo" no longer matches anything; users now need
to type "name CONTAINS 'foo'". This is the new UX.

* fix(dashboards-list-v2): tighten ActionsPopover button styling

  - Drop variant="ghost" on the three non-destructive row buttons so
    they pick up the signoz Button default.
  - Use <Divider /> from @signozhq/ui above the destructive Delete
    button instead of a bespoke .deleteDivider class.
  - Drop the surrounding .content padding and the now-unused
    .deleteDivider rule. Keep .menuItem for left-alignment + width.

* fix(dashboards-list-v2): surface DSL parse errors on 400 responses

When the BE rejects the search query with a 4xx, show the server-
provided detail (e.g. `invalid filter query: unsupported expression
"asdas" — every term must be of the form \`key OP value\``) instead
of the generic "Something went wrong" panel.

  - ErrorState gains optional httpStatus + errorMessage props.
    For 4xx it renders a two-line layout (title + cleaned detail)
    and drops the Retry button (retrying the same bad query won't
    help). Contact Support stays.
  - Add formatQueryErrorMessage util: strips the "invalid filter
    query:" prefix that the FE title already implies, and rewrites
    backtick-quoted format hints to use double quotes.
  - DashboardsList extracts status + message via toAPIError and
    passes them through. Search toolbar remains visible above the
    error, so the user can fix and resubmit.

* chore: fix lint error due to rebase
2026-06-01 08:48:50 +00:00
Yunus M
11eb6e112b feat: rename AI Assistant to Noz and introduce Noz component with hov… (#11510)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* feat: rename AI Assistant to Noz and introduce Noz component with hover animation

* fix(ai-assistant): render Noz empty-state icon at true brand color

The .emptyIcon wrapper around the large empty-state Noz mascot applied
opacity: 0.85. Since opacity composites against the background, the brand
red (#E5484D) measured as #E96469 over the light panel and #C53F44 over
the dark panel instead of its true value. Removed the opacity so the
mascot keeps #E5484D in both themes.

---------

Co-authored-by: makeavish <makeavish786@gmail.com>
2026-05-30 06:21:57 +00:00
220 changed files with 9375 additions and 2668 deletions

19
.github/CODEOWNERS vendored
View File

@@ -169,3 +169,22 @@ go.mod @therealpandey
## Dashboard V2
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
## Infrastructure Monitoring
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
## Alerts
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend

View File

@@ -52,6 +52,7 @@ jobs:
- rootuser
- serviceaccount
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
sqlstore-provider:
- postgres

View File

@@ -64,16 +64,16 @@ web:
settings:
posthog:
# Whether to enable PostHog in web.
enabled: true
enabled: false
appcues:
# Whether to enable Appcues in web.
enabled: true
enabled: false
sentry:
# Whether to enable Sentry in web.
enabled: true
enabled: false
pylon:
# Whether to enable Pylon in web.
enabled: true
enabled: false
##################### Cache #####################
cache:

View File

@@ -6525,6 +6525,15 @@ components:
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
type: array
required:
- aggregations
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
@@ -6563,6 +6572,15 @@ components:
nullable: true
type: array
type: object
SpantypesOtelSpanRef:
properties:
refType:
type: string
spanId:
type: string
traceId:
type: string
type: object
SpantypesPostableSpanMapper:
properties:
config:
@@ -6590,6 +6608,15 @@ components:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregation'
type: array
required:
- aggregations
type: object
SpantypesPostableWaterfall:
properties:
aggregations:
@@ -6614,6 +6641,9 @@ components:
$ref: '#/components/schemas/SpantypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
required:
- field
- aggregation
type: object
SpantypesSpanAggregationResult:
properties:
@@ -6627,6 +6657,10 @@ components:
type: integer
nullable: true
type: object
required:
- field
- aggregation
- value
type: object
SpantypesSpanAggregationType:
enum:
@@ -6810,6 +6844,10 @@ components:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
@@ -6835,6 +6873,8 @@ components:
type: string
trace_state:
type: string
required:
- references
type: object
TagtypesPostableTag:
properties:
@@ -12265,6 +12305,75 @@ paths:
summary: Test notification channel (deprecated)
tags:
- channels
/api/v1/traces/{traceID}/aggregations:
post:
deprecated: false
description: Computes span aggregations grouped by requested field.
operationId: GetTraceAggregations
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableTraceAggregations'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceAggregations'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get aggregations for a trace
tags:
- tracedetail
/api/v1/user:
get:
deprecated: false

View File

@@ -7753,12 +7753,34 @@ export type SpantypesSpanAggregationResultDTOValue =
SpantypesSpanAggregationResultDTOValueAnyOf | null;
export interface SpantypesSpanAggregationResultDTO {
aggregation?: SpantypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
/**
* @type object,null
*/
value?: SpantypesSpanAggregationResultDTOValue;
value: SpantypesSpanAggregationResultDTOValue;
}
export interface SpantypesGettableTraceAggregationsDTO {
/**
* @type array
*/
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
*/
refType?: string;
/**
* @type string
*/
spanId?: string;
/**
* @type string
*/
traceId?: string;
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
@@ -7855,6 +7877,10 @@ export interface SpantypesWaterfallSpanDTO {
* @type string
*/
parent_span_id?: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object,null
*/
@@ -8000,8 +8026,15 @@ export interface SpantypesPostableSpanMapperGroupDTO {
}
export interface SpantypesSpanAggregationDTO {
aggregation?: SpantypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
}
export interface SpantypesPostableTraceAggregationsDTO {
/**
* @type array
*/
aggregations: SpantypesSpanAggregationDTO[];
}
export interface SpantypesPostableWaterfallDTO {
@@ -9344,6 +9377,17 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};
export type GetTraceAggregations200 = {
data: SpantypesGettableTraceAggregationsDTO;
/**
* @type string
*/
status: string;
};
export type ListUsersDeprecated200 = {
/**
* @type array

View File

@@ -12,17 +12,120 @@ import type {
} from 'react-query';
import type {
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableTraceAggregationsDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
*/
export const getTraceAggregations = (
{ traceID }: GetTraceAggregationsPathParameters,
spantypesPostableTraceAggregationsDTO?: BodyType<SpantypesPostableTraceAggregationsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceAggregations200>({
url: `/api/v1/traces/${traceID}/aggregations`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableTraceAggregationsDTO,
signal,
});
};
export const getGetTraceAggregationsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
const mutationKey = ['getTraceAggregations'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getTraceAggregations>>,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getTraceAggregations(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetTraceAggregationsMutationResult = NonNullable<
Awaited<ReturnType<typeof getTraceAggregations>>
>;
export type GetTraceAggregationsMutationBody =
| BodyType<SpantypesPostableTraceAggregationsDTO>
| undefined;
export type GetTraceAggregationsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get aggregations for a trace
*/
export const useGetTraceAggregations = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace

View File

@@ -120,7 +120,8 @@ export const interceptorRejected = async (
!(
response.config.url === '/sessions' && response.config.method === 'delete'
) &&
response.config.url !== '/authz/check'
response.config.url !== '/authz/check' &&
response.config.url !== '/api/v2/reset_password_tokens/verify'
) {
try {
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);

View File

@@ -1 +0,0 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 467 B

View File

@@ -359,8 +359,7 @@ function CustomTimePickerPopoverContent({
<Clock
color={Color.BG_ROBIN_400}
className="timezone-container__clock-icon"
height={12}
width={12}
size={14}
/>
<span className="timezone__name">{timezone.name}</span>

View File

@@ -27,13 +27,15 @@ function SortableField({
field,
onRemove,
allowDrag,
isRequired,
}: {
field: TelemetryFieldKey;
onRemove: (field: TelemetryFieldKey) => void;
allowDrag: boolean;
isRequired: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.name });
useSortable({ id: field.key as string });
const style = {
transform: CSS.Transform.toString(transform),
@@ -53,15 +55,17 @@ function SortableField({
{allowDrag && <GripVertical size={14} />}
<span className={styles.fieldKey}>{field.name}</span>
</div>
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
variant="outlined"
color="destructive"
size="sm"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
{!isRequired && (
<Button
className={cx(styles.removeBtn, 'periscope-btn')}
variant="outlined"
color="destructive"
size="sm"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
)}
</div>
);
}
@@ -71,6 +75,7 @@ interface AddedFieldsProps {
fields: TelemetryFieldKey[];
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
maxFields?: number;
requiredFields?: readonly string[];
}
function AddedFields({
@@ -78,14 +83,18 @@ function AddedFields({
fields,
onFieldsChange,
maxFields,
requiredFields = [],
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
// Contract: caller (FieldsSelector) normalizes `fields` so every entry has
// `.key` populated. AddedFields reads it directly.
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f.name === active.id);
const newIndex = fields.findIndex((f) => f.name === over.id);
const oldIndex = fields.findIndex((f) => f.key === active.id);
const newIndex = fields.findIndex((f) => f.key === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
@@ -99,7 +108,7 @@ function AddedFields({
);
const handleRemove = (field: TelemetryFieldKey): void => {
onFieldsChange(fields.filter((f) => f.name !== field.name));
onFieldsChange(fields.filter((f) => f.key !== field.key));
};
const allowDrag = inputValue.length === 0;
@@ -125,16 +134,17 @@ function AddedFields({
<div className={styles.noValues}>No values found</div>
) : (
<SortableContext
items={fields.map((f) => f.name)}
items={fields.map((f) => f.key as string)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field.name}
key={field.key}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
isRequired={requiredFields.includes(field.key as string)}
/>
))}
</SortableContext>

View File

@@ -76,11 +76,8 @@
min-height: 0;
overflow: hidden;
// Ant Skeleton.Input rendered inside the loading state — override its
// hard-coded width.
:global(.ant-skeleton-input) {
width: 300px;
margin: 8px 12px;
width: 50% !important;
}
}
@@ -95,7 +92,8 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
height: 36px;
padding: 0 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
@@ -132,7 +130,7 @@
}
.isDragDisabled {
padding: 6px 12px;
cursor: default;
}
.otherFieldItem {

View File

@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
@@ -26,33 +27,36 @@ interface FieldsSelectorProps {
onClose: () => void;
signal: DataSource;
maxFields?: number;
requiredFields?: readonly string[];
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
}
function FieldsSelector({
isOpen,
type FieldsSelectorContentProps = Omit<FieldsSelectorProps, 'isOpen'>;
// Inner component: holds all hooks + UI. Gets mounted/unmounted via the
// outer gate so opening always seeds a fresh draft from `fields`.
// Assumes `fields` arrives normalized (key populated) — see outer gate.
function FieldsSelectorContent({
title,
fields,
onFieldsChange,
onClose,
signal,
maxFields,
requiredFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
}: FieldsSelectorProps): JSX.Element | null {
if (!isOpen) {
return null;
}
}: FieldsSelectorContentProps): JSX.Element {
const resolvedHeight =
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
const resolvedPosition = defaultPosition ?? {
x: window.innerWidth - width - DEFAULT_PANEL_RIGHT_INSET,
y: DEFAULT_PANEL_TOP_INSET,
};
const [draftFields, setDraftFields] = useState<TelemetryFieldKey[]>(fields);
const [inputValue, setInputValue] = useState('');
const [debouncedInputValue, setDebouncedInputValue] = useState('');
@@ -72,15 +76,17 @@ function FieldsSelector({
const handleAdd = useCallback(
(field: TelemetryFieldKey): void => {
if (maxFields !== undefined && draftFields.length >= maxFields) {
return;
}
if (draftFields.some((f) => f.name === field.name)) {
return;
}
setDraftFields((prev) => [...prev, field]);
setDraftFields((prev) => {
if (maxFields !== undefined && prev.length >= maxFields) {
return prev;
}
if (prev.some((f) => f.key === field.key)) {
return prev;
}
return [...prev, field];
});
},
[draftFields, maxFields],
[maxFields],
);
const handleSave = useCallback((): void => {
@@ -99,7 +105,7 @@ function FieldsSelector({
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f.name === fields[i]?.name)
draftFields.every((f, i) => f.key === fields[i]?.key)
),
[draftFields, fields],
);
@@ -138,6 +144,7 @@ function FieldsSelector({
fields={draftFields}
onFieldsChange={setDraftFields}
maxFields={maxFields}
requiredFields={requiredFields}
/>
<OtherFields
@@ -173,4 +180,27 @@ function FieldsSelector({
);
}
// Outer gate: normalizes `fields` once (populates `key` so downstream code
// can read it directly) and decides whether the inner component renders.
// When isOpen flips false→true, the inner remounts → draft state seeds fresh.
function FieldsSelector({
isOpen,
fields,
...rest
}: FieldsSelectorProps): JSX.Element | null {
const normalizedFields = useMemo<TelemetryFieldKey[]>(
() =>
fields.map((f) => ({
...f,
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
})),
[fields],
);
if (!isOpen) {
return null;
}
return <FieldsSelectorContent {...rest} fields={normalizedFields} />;
}
export default FieldsSelector;

View File

@@ -4,6 +4,7 @@ import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import {
FieldContext,
@@ -47,15 +48,22 @@ function OtherFields({
const otherFields: TelemetryFieldKey[] = useMemo(() => {
const suggestions = Object.values(data?.data.data.keys || {}).flat();
const addedNames = new Set(addedFields.map((f) => f.name));
return suggestions
.filter((attr) => !addedNames.has(attr.name))
.map((attr) => ({
// Normalize: synthesize `key` once so downstream reads can trust it.
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
(attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext as string),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
}));
}),
);
const addedIds = new Set(
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
);
return normalizedSuggestions.filter(
(attr) => !addedIds.has(attr.key as string),
);
}, [data, addedFields]);
if (isFetching) {
@@ -64,8 +72,13 @@ function OtherFields({
<div className={styles.sectionHeader}>OTHER FIELDS</div>
<div className={styles.otherList}>
{Array.from({ length: 5 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={i} />
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className={cx(styles.fieldItem, styles.otherFieldItem)}
>
<Skeleton.Input active size="small" block />
</div>
))}
</div>
</div>
@@ -83,7 +96,7 @@ function OtherFields({
) : (
otherFields.map((attr) => (
<div
key={attr.name}
key={attr.key}
className={cx(styles.fieldItem, styles.otherFieldItem)}
>
<span className={styles.fieldKey}>{attr.name}</span>

View File

@@ -0,0 +1,111 @@
import { render, screen } from 'tests/test-utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import AddedFields from '../AddedFields';
// AddedFields assumes the caller has populated `key` (the parent
// FieldsSelector does this via its normalization useMemo). Tests pre-populate
// it directly.
const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
name,
signal: 'logs',
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
fieldDataType: 'string',
key: `${fieldContext}.${name}`,
});
describe('AddedFields — requiredFields', () => {
it('renders a Remove button for every field when no requiredFields are passed', () => {
const fields = [makeField('a'), makeField('b'), makeField('c')];
render(
<AddedFields inputValue="" fields={fields} onFieldsChange={jest.fn()} />,
);
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(3);
});
it('hides the Remove button for fields whose composite key is in requiredFields', () => {
const fields = [makeField('a'), makeField('b'), makeField('c')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a', 'log.c']}
/>,
);
// Only 'b' is removable.
const removeButtons = screen.getAllByRole('button', { name: /remove/i });
expect(removeButtons).toHaveLength(1);
});
it('still renders the field name for required fields', () => {
const fields = [makeField('a'), makeField('b')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a']}
/>,
);
expect(screen.getByText('a')).toBeInTheDocument();
expect(screen.getByText('b')).toBeInTheDocument();
});
it('locks ONLY the canonical variant — a same-name field from another context stays removable', () => {
// Two `body` fields with different contexts. requiredFields holds the
// canonical composite key only, so the attribute variant is deletable.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
/>,
);
// One Remove button: the attribute variant. log variant is locked.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
it('does not lock anything when a bare name is passed (composite key required)', () => {
// Bare `body` no longer matches — matching is composite-key only now.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['body']}
/>,
);
// Neither variant locked → both removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2);
});
it('treats requiredFields as exact composite-key match (substring does not lock)', () => {
const fields = [makeField('body'), makeField('body_extra')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
/>,
);
// 'log.body' locked, 'log.body_extra' removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
});

View File

@@ -1,8 +1,10 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot, Sparkles } from '@signozhq/icons';
import { Dot } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from 'container/AIAssistant/events';
@@ -21,6 +23,7 @@ import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { Typography } from '@signozhq/ui/typography';
interface HeaderRightSectionProps {
enableAnnouncements: boolean;
@@ -107,21 +110,22 @@ function HeaderRightSection({
</span>
) : null}
<TooltipSimple title="AI Assistant">
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="solid"
color="secondary"
className="noz-wave"
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open AI Assistant, 1 action needs your response'
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
: 'Open AI Assistant'
? 'Open Noz, 1 action needs your response'
: `Open Noz, ${pendingUserInputCount} actions need your response`
: 'Open Noz'
}
prefix={<Sparkles size={14} color="var(--primary)" />}
prefix={<Noz size={20} />}
>
AI Assistant
<Typography.Text>Noz</Typography.Text>
</Button>
</TooltipSimple>
</div>

View File

@@ -0,0 +1,145 @@
import { renderHook } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
import { useLogsTableColumns } from '../useLogsTableColumns';
jest.mock('providers/Timezone', () => ({
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
}),
}));
const field = (name: string, type = ''): IField => ({
name,
type,
dataType: 'string',
});
describe('useLogsTableColumns — selectColumns-order respected', () => {
it('prepends stateIndicator and renders user fields in array order', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('c'), field('a'), field('b')],
fontSize: FontSize.SMALL,
}),
);
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'c',
'a',
'b',
]);
});
it('slots body and timestamp at their position in the fields array', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [
field('service.name'),
field('body', 'log'),
field('request.id'),
field('timestamp', 'log'),
],
fontSize: FontSize.SMALL,
}),
);
// body/timestamp appear where the caller placed them, keyed by their
// composite IDs ('log.*'); contextless user fields collapse to bare name.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'service.name',
'log.body',
'request.id',
'log.timestamp',
]);
});
it('renders a same-name field from another context as a DISTINCT column (no collision)', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body', 'log'), field('body', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// Attribute variant is its own column, not a duplicate 'log.body'.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.body',
'attribute.body',
]);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
});
it('applies the same distinct-column treatment to timestamp variants', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('timestamp', 'log'), field('timestamp', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.timestamp',
'attribute.timestamp',
]);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
});
it('skips the synthetic "id" field name', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('id'), field('a'), field('b')],
fontSize: FontSize.SMALL,
}),
);
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'a',
'b',
]);
});
it('uses the special body/timestamp coldefs (canBeHidden=false), not the generic user field def', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [
field('body', 'log'),
field('timestamp', 'log'),
field('user_field'),
],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// body + timestamp are locked from the table-X removal pathway.
expect(byId.get('log.body')?.canBeHidden).toBe(false);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
// User-added fields stay removable. User field has type='' so composite
// collapses to bare name.
expect(byId.get('user_field')?.enableRemove).toBe(true);
});
it('renders only the stateIndicator when fields is empty', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [],
fontSize: FontSize.SMALL,
}),
);
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
});
});

View File

@@ -7,6 +7,7 @@ import {
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
@@ -18,13 +19,11 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
type UseLogsTableColumnsProps = {
fields: IField[];
fontSize: FontSize;
appendTo?: 'center' | 'end';
};
export function useLogsTableColumns({
fields,
fontSize,
appendTo = 'center',
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
@@ -47,71 +46,74 @@ export function useLogsTableColumns({
),
};
const fieldColumns: TableColumnDef<ILog>[] = fields
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
.map(
(f): TableColumnDef<ILog> => ({
id: f.name,
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
}),
);
const timestampCol: TableColumnDef<ILog> = {
id: buildCompositeKey('timestamp', 'log'),
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
canBeHidden: false,
enableRemove: false,
width: { default: 170, min: 170 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
},
};
const timestampCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'timestamp',
)
? {
id: 'timestamp',
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
width: { default: 170, min: 170 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
},
const bodyCol: TableColumnDef<ILog> = {
id: buildCompositeKey('body', 'log'),
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
enableRemove: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
/>
),
};
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
id: buildCompositeKey(f.name, f.type),
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
});
// Match body/timestamp by composite key, not bare name — else a variant
// like `attribute.body` collapses onto `log.body`, duplicating the column.
const fieldCols = fields
.map((f): TableColumnDef<ILog> | null => {
if (f.name === 'id') {
return null;
}
: null;
const bodyCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'body',
)
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
/>
),
const compositeKey = buildCompositeKey(f.name, f.type);
if (compositeKey === timestampCol.id) {
return timestampCol;
}
: null;
if (compositeKey === bodyCol.id) {
return bodyCol;
}
return makeUserFieldCol(f);
})
.filter((c): c is TableColumnDef<ILog> => c !== null);
return [
stateIndicatorCol,
...(timestampCol ? [timestampCol] : []),
...(appendTo === 'center' ? fieldColumns : []),
...(bodyCol ? [bodyCol] : []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
return [stateIndicatorCol, ...fieldCols];
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -256,6 +256,34 @@
}
}
.edit-columns-container {
padding: 12px;
.edit-columns-btn {
display: flex;
width: 100%;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.edit-columns-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
.edit-columns-btn:hover {
background-color: unset !important;
}
}
.add-new-column-container {
display: flex;
flex-direction: column;

View File

@@ -1,12 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import {
Check,
ChevronLeft,
@@ -14,7 +11,6 @@ import {
Minus,
Plus,
SlidersVertical,
X,
} from '@signozhq/icons';
import './LogsFormatOptionsMenu.styles.scss';
@@ -23,14 +19,21 @@ interface LogsFormatOptionsMenuProps {
items: any;
selectedOptionFormat: any;
config: OptionsMenuConfig;
onOpenColumns?: () => void;
}
interface OptionsMenuContentProps extends LogsFormatOptionsMenuProps {
closePopover: () => void;
}
function OptionsMenu({
items,
selectedOptionFormat,
config,
}: LogsFormatOptionsMenuProps): JSX.Element {
const { maxLines, format, addColumn, fontSize } = config;
onOpenColumns,
closePopover,
}: OptionsMenuContentProps): JSX.Element {
const { maxLines, format, fontSize } = config;
const [selectedItem, setSelectedItem] = useState(selectedOptionFormat);
const maxLinesNumber = (maxLines?.value as number) || 1;
const [maxLinesPerRow, setMaxLinesPerRow] = useState<number>(maxLinesNumber);
@@ -40,13 +43,6 @@ function OptionsMenu({
const [isFontSizeOptionsOpen, setIsFontSizeOptionsOpen] =
useState<boolean>(false);
const [showAddNewColumnContainer, setShowAddNewColumnContainer] =
useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false);
const onChange = useCallback(
(key: LogViewMode) => {
if (!format) {
@@ -61,7 +57,6 @@ function OptionsMenu({
const handleMenuItemClick = (key: LogViewMode): void => {
setSelectedItem(key);
onChange(key);
setShowAddNewColumnContainer(false);
};
const incrementMaxLinesPerRow = (): void => {
@@ -76,20 +71,6 @@ function OptionsMenu({
}
};
const handleSearchValueChange = useDebouncedFn((event): void => {
// @ts-expect-error
const value = event?.target?.value || '';
if (addColumn && addColumn?.onSearch) {
addColumn?.onSearch(value);
}
}, 300);
const handleToggleAddNewColumn = (): void => {
addColumn?.onSearch?.('');
setShowAddNewColumnContainer(!showAddNewColumnContainer);
};
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
if (
maxLinesPerRow &&
@@ -100,6 +81,11 @@ function OptionsMenu({
}
};
const handleEditColumns = (): void => {
onOpenColumns?.();
closePopover();
};
useEffect(() => {
if (maxLinesPerRow && config && config.maxLines?.onChange) {
config.maxLines.onChange(maxLinesPerRow);
@@ -112,110 +98,10 @@ function OptionsMenu({
}
}, [fontSizeValue]);
function handleColumnSelection(
currentIndex: number,
optionsData: DefaultOptionType[],
): void {
const currentItem = optionsData[currentIndex];
const itemLength = optionsData.length;
if (addColumn && addColumn?.onSelect) {
addColumn?.onSelect(selectedValue, {
label: currentItem.label,
disabled: false,
});
// if the last element is selected then select the previous one
if (currentIndex === itemLength - 1) {
// there should be more than 1 element in the list
if (currentIndex - 1 >= 0) {
const prevValue = optionsData[currentIndex - 1]?.value || null;
setSelectedValue(prevValue as string | null);
} else {
// if there is only one element then just select and do nothing
setSelectedValue(null);
}
} else {
// selecting any random element from the list except the last one
const nextIndex = currentIndex + 1;
const nextValue = optionsData[nextIndex]?.value || null;
setSelectedValue(nextValue as string | null);
}
}
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (!selectedValue) {
return;
}
const optionsData = addColumn?.options || [];
const currentIndex = optionsData.findIndex(
(item) => item?.value === selectedValue,
);
const itemLength = optionsData.length;
switch (e.key) {
case 'ArrowUp': {
const newValue = optionsData[Math.max(0, currentIndex - 1)]?.value;
setSelectedValue(newValue as string | null);
e.preventDefault();
break;
}
case 'ArrowDown': {
const newValue =
optionsData[Math.min(itemLength - 1, currentIndex + 1)]?.value;
setSelectedValue(newValue as string | null);
e.preventDefault();
break;
}
case 'Enter':
e.preventDefault();
handleColumnSelection(currentIndex, optionsData);
break;
default:
break;
}
};
useEffect(() => {
// Scroll the selected item into view
const listNode = listRef.current;
if (listNode && selectedValue) {
const optionsData = addColumn?.options || [];
const currentIndex = optionsData.findIndex(
(item) => item?.value === selectedValue,
);
const itemNode = listNode.children[currentIndex] as HTMLElement;
if (itemNode) {
itemNode.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}
}, [selectedValue]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [selectedValue]);
return (
<div
className={cx(
'nested-menu-container',
showAddNewColumnContainer ? 'active' : '',
)}
className="nested-menu-container"
onClick={(event): void => {
// this is to restrict click events to propogate to parent
event.stopPropagation();
}}
>
@@ -269,71 +155,7 @@ function OptionsMenu({
</Button>
</div>
</div>
) : null}
{showAddNewColumnContainer && (
<div className="add-new-column-container">
<div className="add-new-column-header">
<div className="title">
<div className="periscope-btn ghost" onClick={handleToggleAddNewColumn}>
<ChevronLeft
size={14}
className="back-icon"
onClick={handleToggleAddNewColumn}
/>
</div>
Add New Column
</div>
<Input
tabIndex={0}
type="text"
autoFocus
onFocus={addColumn?.onFocus}
onChange={handleSearchValueChange}
placeholder="Search..."
/>
</div>
<div className="add-new-column-content">
{addColumn?.isFetching && (
<div className="loading-container"> Loading ... </div>
)}
<div className="column-format-new-options" ref={listRef}>
{addColumn?.options?.map(({ label, value }, index) => (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name">
<Tooltip placement="left" title={label}>
{label}
</Tooltip>
</div>
</div>
))}
</div>
</div>
</div>
)}
{!isFontSizeOptionsOpen && !showAddNewColumnContainer && (
) : (
<div>
<div className="font-size-container">
<div className="title">Font Size</div>
@@ -373,72 +195,52 @@ function OptionsMenu({
{selectedItem && (
<>
<>
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </div>
<div className="raw-format max-lines-per-row-input">
<button
type="button"
className="periscope-btn"
onClick={decrementMaxLinesPerRow}
>
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
/>
<button
type="button"
className="periscope-btn"
onClick={incrementMaxLinesPerRow}
>
{' '}
<Plus size={12} />{' '}
</button>
</div>
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </div>
<div className="raw-format max-lines-per-row-input">
<button
type="button"
className="periscope-btn"
onClick={decrementMaxLinesPerRow}
>
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
/>
<button
type="button"
className="periscope-btn"
onClick={incrementMaxLinesPerRow}
>
{' '}
<Plus size={12} />{' '}
</button>
</div>
</>
</div>
</>
)}
<div className="selected-item-content-container active">
{!showAddNewColumnContainer && <div className="horizontal-line" />}
<div className="item-content">
{!showAddNewColumnContainer && (
<div className="title">
columns
<Plus size={14} onClick={handleToggleAddNewColumn} />{' '}
</div>
)}
<div className="column-format">
{addColumn?.value?.map(({ name }) => (
<div className="column-name" key={name}>
<div className="name">
<Tooltip placement="left" title={name}>
{name}
</Tooltip>
</div>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(name)}
/>
)}
</div>
))}
{addColumn && addColumn?.value?.length === 0 && (
<div className="column-name no-columns-selected">
No columns selected
</div>
)}
</div>
</div>
{selectedItem === 'table' && onOpenColumns && (
<>
<div className="horizontal-line" />
<div className="edit-columns-container">
<Button
className="edit-columns-btn"
type="text"
onClick={handleEditColumns}
data-testid="periscope-btn-edit-columns"
>
<Typography.Text className="edit-columns-text">
Edit columns
</Typography.Text>
<ChevronRight size={14} className="icon" />
</Button>
</div>
</>
)}
@@ -452,6 +254,7 @@ function LogsFormatOptionsMenu({
items,
selectedOptionFormat,
config,
onOpenColumns,
}: LogsFormatOptionsMenuProps): JSX.Element {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
return (
@@ -461,6 +264,8 @@ function LogsFormatOptionsMenu({
items={items}
selectedOptionFormat={selectedOptionFormat}
config={config}
onOpenColumns={onOpenColumns}
closePopover={(): void => setIsPopoverOpen(false)}
/>
}
trigger="click"

View File

@@ -62,6 +62,7 @@ describe('LogsFormatOptionsMenu (unit)', () => {
onSearch: jest.fn(),
onSelect: jest.fn(),
onRemove: jest.fn(),
onReorder: jest.fn(),
},
}}
/>,
@@ -154,4 +155,66 @@ describe('LogsFormatOptionsMenu (unit)', () => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
});
});
function renderWithOnOpen(
onOpenColumns?: jest.Mock,
selectedOptionFormat: 'table' | 'raw' | 'list' = 'table',
): { getByTestId: ReturnType<typeof render>['getByTestId'] } {
const items = [
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
{ key: 'list', label: 'Default' },
{ key: 'table', label: 'Column', data: { title: 'columns' } },
];
const { getByTestId } = render(
<LogsFormatOptionsMenu
items={items}
selectedOptionFormat={selectedOptionFormat}
config={{
format: { value: selectedOptionFormat, onChange: jest.fn() },
maxLines: { value: 1, onChange: jest.fn() },
fontSize: { value: FontSize.SMALL, onChange: jest.fn() },
}}
onOpenColumns={onOpenColumns}
/>,
);
fireEvent.click(getByTestId('periscope-btn-format-options'));
return { getByTestId };
}
it('renders "Edit columns" row when format=table and onOpenColumns provided', () => {
const onOpenColumns = jest.fn();
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
expect(getByTestId('periscope-btn-edit-columns')).toBeInTheDocument();
});
it('does not render "Edit columns" row when onOpenColumns is not provided', () => {
renderWithOnOpen(undefined, 'table');
expect(
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
).toBeNull();
});
it('does not render "Edit columns" row when format is not table', () => {
renderWithOnOpen(jest.fn(), 'raw');
expect(
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
).toBeNull();
});
it('fires onOpenColumns and closes the popover when "Edit columns" is clicked', async () => {
const onOpenColumns = jest.fn();
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
fireEvent.click(getByTestId('periscope-btn-edit-columns'));
expect(onOpenColumns).toHaveBeenCalledTimes(1);
await waitFor(() => {
// Popover content unmounts on close.
expect(document.querySelector('.menu-container')).toBeNull();
});
});
});

View File

@@ -109,6 +109,16 @@ $custom-border-color: #2c3044;
color: color-mix(in srgb, var(--l2-foreground) 45%, transparent);
}
.ant-select-clear {
background-color: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover {
color: var(--l1-foreground);
}
}
// Customize tags in multiselect (dark mode by default)
.ant-select-selection-item {
background-color: var(--l1-border);
@@ -392,7 +402,9 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 350px;
// Tall enough to hold the react-virtuoso list (<=300px) + header/footer
// so only the list scrolls (avoids a second scrollbar on this container).
max-height: 410px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -483,8 +495,12 @@ $custom-border-color: #2c3044;
.option-checkbox {
width: 100%;
> span:not(.ant-checkbox) {
width: 100%;
// @signozhq/ui Checkbox renders children inside a <label> that is
// content-sized by default. Make it fill the row (min-width: 0 lets it
// shrink) so the option text below can truncate instead of overflowing.
> label {
flex: 1 1 auto;
min-width: 0;
}
.all-option-text {
@@ -501,7 +517,12 @@ $custom-border-color: #2c3044;
width: 100%;
.option-label-text {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.option-badge {
@@ -514,26 +535,30 @@ $custom-border-color: #2c3044;
}
}
.only-btn {
display: none;
}
// Size the buttons to the row's resting content height (20px) and fully
// override antd's default 32px Button box, so revealing them on hover
// never changes the row height.
.only-btn,
.toggle-btn {
display: none;
}
align-items: center;
justify-content: center;
height: 18px;
min-height: 0;
padding: 0 6px;
font-size: 12px;
line-height: 1;
border: none;
box-shadow: none;
.only-btn:hover {
background-color: unset;
}
.toggle-btn:hover {
background-color: unset;
&:hover {
background-color: unset;
}
}
.option-content:hover {
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.toggle-btn {
display: none;
@@ -548,9 +573,6 @@ $custom-border-color: #2c3044;
.option-checkbox:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.option-badge {
display: none;

View File

@@ -0,0 +1,2 @@
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';

View File

@@ -0,0 +1,86 @@
@keyframes noz-wave-wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
75% {
transform: rotate(-20deg);
}
}
.noz {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
overflow: visible;
svg {
display: block;
overflow: visible;
}
:global {
.noz-arm-left {
transform-origin: 4.18383px 13.4752px;
transform: rotate(0deg);
transition: transform 0.55s cubic-bezier(0.34, 1.7, 0.5, 1);
will-change: transform;
}
.noz-arm-wiggle {
transform-origin: 4.18383px 13.4752px;
transform: rotate(0deg);
will-change: transform;
}
.noz-head {
transform-origin: 12.02px 18.37px;
transform: rotate(0deg);
transition: transform 0.5s cubic-bezier(0.34, 1.7, 0.5, 1);
will-change: transform;
}
}
&:hover {
:global(.noz-arm-left) {
transform: rotate(145deg) scale(1, 1.7);
}
:global(.noz-arm-wiggle) {
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
}
:global(.noz-head) {
transform: rotate(9deg);
}
}
}
:global(.noz-wave):hover {
:global(.noz-arm-left) {
transform: rotate(145deg) scale(1, 1.7);
}
:global(.noz-arm-wiggle) {
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
}
:global(.noz-head) {
transform: rotate(9deg);
}
}
@media (prefers-reduced-motion: reduce) {
.noz {
:global(.noz-arm-left),
:global(.noz-arm-wiggle),
:global(.noz-head) {
transition: none;
animation: none !important;
}
}
}

View File

@@ -0,0 +1,100 @@
import cx from 'classnames';
import styles from './Noz.module.scss';
interface NozProps {
size?: number;
className?: string;
}
/**
* Noz — the SigNoz AI assistant mascot. Waves on hover.
*
* Hover behavior:
* - Hovering the icon itself triggers the wave.
* - To make a parent element (e.g. a Button) trigger the wave on its own
* hover, add the class `noz-wave` to that parent.
*/
export default function Noz({ size = 24, className }: NozProps): JSX.Element {
return (
<span
className={cx(styles.noz, className)}
style={{ width: size, height: size }}
aria-hidden="true"
>
<svg
viewBox="-2 0.5 28 28"
width={size}
height={size}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* body */}
<rect
x="4.35938"
y="8.49908"
width="15.4569"
height="11.978"
rx="1.76147"
fill="#E5484D"
/>
{/* legs */}
<rect
x="6.87012"
y="19.0679"
width="3.34679"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
<rect
x="13.916"
y="19.0679"
width="3.34679"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
{/* right arm (static) */}
<rect
x="18.7598"
y="13.4752"
width="2.11376"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
{/* left arm: outer group does the "lift", inner does the wiggle */}
<g className="noz-arm-left">
<g className="noz-arm-wiggle">
<rect
x="3.12695"
y="13.4752"
width="2.11376"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
</g>
</g>
{/* head: face + eye + hat tilt together */}
<g className="noz-head">
<circle cx="12.0217" cy="14.4881" r="3.87523" fill="#F5F5F5" />
<path
d="M12.0237 12.8024C12.0237 13.7328 11.2673 14.4892 10.337 14.4892C10.0339 14.4892 9.74926 14.4101 9.50152 14.2678C9.47517 14.5551 9.49888 14.8502 9.57795 15.1428C9.93901 16.4921 11.3279 17.2933 12.6773 16.9323C14.0267 16.5712 14.8279 15.1823 14.4668 13.8329C14.1453 12.6285 13.0041 11.8616 11.8023 11.967C11.942 12.2121 12.0237 12.4967 12.0237 12.8024Z"
fill="#0A0C10"
/>
<path
d="M8.33833 7.94578L9.83358 4.31319C10.1302 3.59261 10.6676 2.99939 11.355 2.63299L13.9181 1.26684C14.1327 1.15169 14.3804 1.34885 14.3194 1.58439L13.6703 4.06892C13.6511 4.14046 13.6424 4.21374 13.6424 4.28876C13.6424 4.39868 13.6633 4.5086 13.7052 4.61154L15.0382 7.94578H11.4248L11.6307 7.32813L12.3356 7.09259C12.449 7.05421 12.5257 6.94778 12.5257 6.82739C12.5257 6.707 12.449 6.60057 12.3356 6.56218L11.6307 6.32664L11.3951 5.62176C11.3568 5.51009 11.2503 5.43333 11.1299 5.43333C11.0096 5.43333 10.9031 5.5101 10.8647 5.6235L10.6292 6.32839L9.92431 6.56393C9.8109 6.60231 9.73413 6.70874 9.73413 6.82913C9.73413 6.94952 9.8109 7.05595 9.92431 7.09434L10.6292 7.32988L10.8351 7.94752H8.33833V7.94578ZM12.1 3.43558C12.0808 3.378 12.0285 3.33962 11.9674 3.33962C11.9064 3.33962 11.854 3.378 11.8348 3.43558L11.7179 3.78802L11.3655 3.90492C11.3079 3.92411 11.2695 3.97645 11.2695 4.03752C11.2695 4.09859 11.3079 4.15093 11.3655 4.17012L11.7179 4.28702L11.8348 4.63946C11.854 4.69704 11.9064 4.73542 11.9674 4.73542C12.0285 4.73542 12.0808 4.69704 12.1 4.63946L12.2169 4.28702L12.5694 4.17012C12.6269 4.15093 12.6653 4.09859 12.6653 4.03752C12.6653 3.97645 12.6269 3.92411 12.5694 3.90492L12.2169 3.78802L12.1 3.43558ZM7.78 7.91088H15.5965C15.9053 7.91088 16.1548 8.16038 16.1548 8.4692C16.1548 8.77803 15.9053 9.02753 15.5965 9.02753H7.78C7.47118 9.02753 7.22168 8.77803 7.22168 8.4692C7.22168 8.16038 7.47118 7.91088 7.78 7.91088Z"
fill="#4E74F8"
/>
</g>
</svg>
</span>
);
}
Noz.defaultProps = {
size: 24,
className: undefined,
};

View File

@@ -72,6 +72,7 @@ function TanStackTableInner<TData>(
data,
columns,
columnStorageKey,
respectColumnOrder = true,
columnSizing: columnSizingProp,
onColumnSizingChange,
onColumnOrderChange,
@@ -175,6 +176,7 @@ function TanStackTableInner<TData>(
storageKey: columnStorageKey,
columns,
isGrouped,
respectColumnOrder,
});
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
@@ -206,6 +208,7 @@ function TanStackTableInner<TData>(
handleRemoveColumn,
} = useColumnHandlers({
columnStorageKey,
respectColumnOrder,
effectiveSizing,
storeSetSizing,
storeSetOrder,
@@ -322,9 +325,7 @@ function TanStackTableInner<TData>(
});
const hasSingleColumn = useMemo(
() =>
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
1,
() => effectiveColumns.filter((c) => !c.pin).length <= 1,
[effectiveColumns],
);

View File

@@ -24,6 +24,8 @@ jest.mock('../TanStackTable.module.scss', () => ({
}));
beforeAll(() => {
// jsdom doesn't include ResizeObserver — must direct-assign rather than
// spyOn (spyOn requires the property to already exist).
window.ResizeObserver = jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
@@ -867,4 +869,110 @@ describe('TanStackTableView Integration', () => {
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('hasSingleColumn — gates the Remove popover per-column', () => {
const hasSingleColumnFlagPresent = (): boolean =>
Boolean(document.querySelector('th[data-single-column="true"]'));
it('is true when only one non-pinned column exists', async () => {
renderTanStackTable({
props: {
data: [{ id: '1', name: 'Item 1', value: 100 }],
columns: [
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
],
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
expect(hasSingleColumnFlagPresent()).toBe(true);
});
it('is false when multiple non-pinned columns exist (all removable)', async () => {
renderTanStackTable({});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// 3 default columns (id/name/value), none pinned, none non-removable
// → table is not single-column.
expect(hasSingleColumnFlagPresent()).toBe(false);
});
it('is false when removable + non-removable mix exists (the body/timestamp case)', async () => {
renderTanStackTable({
props: {
data: [{ id: '1', name: 'Item 1', value: 100 }],
columns: [
{
id: 'name',
header: 'Timestamp',
accessorKey: 'name',
enableRemove: false,
cell: ({ value }): string => String(value),
},
{
id: 'value',
header: 'Body',
accessorKey: 'value',
enableRemove: false,
cell: ({ value }): string => String(value),
},
{
id: 'id',
header: 'User',
accessorKey: 'id',
enableRemove: true,
cell: ({ value }): string => String(value),
},
],
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
expect(hasSingleColumnFlagPresent()).toBe(false);
});
it('does not count pinned columns toward the total', async () => {
renderTanStackTable({
props: {
data: [{ id: '1', name: 'Item 1', value: 100 }],
columns: [
{
id: 'stateIndicator',
header: '',
pin: 'left',
accessorKey: 'id',
cell: ({ value }): string => String(value),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
],
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// 1 pinned + 1 non-pinned → only the non-pinned counts → single-column.
expect(hasSingleColumnFlagPresent()).toBe(true);
});
});
});

View File

@@ -168,6 +168,53 @@ describe('useColumnState', () => {
'a',
]);
});
it('ignores stored columnOrder when respectColumnOrder=false', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
});
const { result } = renderHook(() =>
useColumnState({
storageKey: TEST_KEY,
columns,
respectColumnOrder: false,
}),
);
// Falls through to natural columns-array order; stored order is ignored.
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
'a',
'b',
'c',
]);
});
it('honors stored columnOrder when respectColumnOrder=true (default)', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
});
const { result } = renderHook(() =>
useColumnState({
storageKey: TEST_KEY,
columns,
respectColumnOrder: true,
}),
);
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
'c',
'a',
'b',
]);
});
});
describe('actions', () => {

View File

@@ -152,6 +152,8 @@ export type TanStackTableProps<TData> = {
columns: TableColumnDef<TData>[];
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
columnStorageKey?: string;
/** When false, the table renders columns in their natural array order and the persisted columnOrder slot is ignored on read and skipped on write. Use when an external source (e.g. preferences.selectColumns) is the canonical order. Default true. */
respectColumnOrder?: boolean;
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;

View File

@@ -7,6 +7,8 @@ import { TableColumnDef } from './types';
export interface UseColumnHandlersOptions<TData> {
/** Storage key for persisting column state (enables store mode) */
columnStorageKey?: string;
/** When false, drag-reorder skips the persisted columnOrder write — order flows back via onColumnOrderChange only. */
respectColumnOrder?: boolean;
effectiveSizing: ColumnSizingState;
storeSetSizing: (sizing: ColumnSizingState) => void;
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
@@ -28,6 +30,7 @@ export interface UseColumnHandlersResult<TData> {
*/
export function useColumnHandlers<TData>({
columnStorageKey,
respectColumnOrder = true,
effectiveSizing,
storeSetSizing,
storeSetOrder,
@@ -50,12 +53,12 @@ export function useColumnHandlers<TData>({
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (columnStorageKey) {
if (columnStorageKey && respectColumnOrder) {
storeSetOrder(cols);
}
onColumnOrderChange?.(cols);
},
[columnStorageKey, storeSetOrder, onColumnOrderChange],
[columnStorageKey, respectColumnOrder, storeSetOrder, onColumnOrderChange],
);
const handleRemoveColumn = useCallback(

View File

@@ -20,6 +20,7 @@ type UseColumnStateOptions<TData> = {
storageKey?: string;
columns: TableColumnDef<TData>[];
isGrouped?: boolean;
respectColumnOrder?: boolean;
};
type UseColumnStateResult<TData> = {
@@ -40,6 +41,7 @@ export function useColumnState<TData>({
storageKey,
columns,
isGrouped = false,
respectColumnOrder = true,
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
useEffect(() => {
if (storageKey) {
@@ -130,7 +132,7 @@ export function useColumnState<TData>({
}, [hiddenColumnIds, columns, isGrouped]);
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
if (columnOrder.length === 0) {
if (!respectColumnOrder || columnOrder.length === 0) {
return columns;
}
@@ -144,7 +146,7 @@ export function useColumnState<TData>({
});
return [...pinned, ...sortedRest];
}, [columns, columnOrder]);
}, [columns, columnOrder, respectColumnOrder]);
const hideColumn = useCallback(
(columnId: string) => {

View File

@@ -76,4 +76,19 @@
.cmd-item-icon {
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
svg {
height: 16px !important;
width: 16px !important;
}
&.noz-icon {
svg {
height: 20px !important;
width: 20px !important;
}
}
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import cx from 'classnames';
import { useLocation } from 'react-router-dom';
import {
CommandDialog,
@@ -162,7 +163,11 @@ export function CmdKPalette({
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
<span
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>

View File

@@ -42,4 +42,5 @@ export enum LOCALSTORAGE {
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
}

View File

@@ -15,10 +15,10 @@ import {
ListMinus,
ScrollText,
Settings,
Sparkles,
TowerControl,
Workflow,
} from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import { ROLES } from 'types/roles';
export type CmdAction = {
@@ -292,11 +292,11 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
if (aiAssistant) {
actions.unshift({
id: 'ai-assistant',
name: 'Open AI Assistant',
name: 'Open Noz',
shortcut: ['cmd+j'],
keywords: 'ai assistant chat ask sparkles copilot',
section: 'AI Assistant',
icon: <Sparkles size={14} />,
keywords: 'noz ai assistant chat ask sparkles copilot',
section: 'Noz',
icon: <Noz size={16} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: aiAssistant.open,
});

View File

@@ -4,7 +4,8 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
import { Maximize2, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
@@ -46,9 +47,9 @@ export default function AIAssistantDrawer(): JSX.Element {
closeIcon={null}
title={
<div>
<div>
<MessageSquare size={16} />
<span>AI Assistant</span>
<div className="noz-wave">
<Noz size={16} />
<span>Noz</span>
</div>
<div>

View File

@@ -4,7 +4,8 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -142,15 +143,15 @@ export default function AIAssistantModal(): JSX.Element | null {
className={styles.backdrop}
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
aria-label="Noz"
onClick={handleBackdropClick}
>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={16} color="var(--primary)" />
<span>AI Assistant</span>
<div className={`${styles.title} noz-wave`}>
<Noz size={16} />
<span>Noz</span>
<kbd className={styles.shortcut}>
<span></span>
<span>J</span>

View File

@@ -3,7 +3,8 @@ import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import { History, Maximize2, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -137,9 +138,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={18} color="var(--primary)" />
<span>AI Assistant</span>
<div className={`${styles.title} noz-wave`}>
<Noz size={18} />
<span>Noz</span>
</div>
<div className={styles.actions}>

View File

@@ -4,7 +4,8 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
@@ -42,16 +43,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
}
return (
<TooltipSimple title="AI Assistant">
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="solid"
color="primary"
className={styles.trigger}
className={`${styles.trigger} noz-wave`}
onClick={handleOpen}
aria-label="Open AI Assistant"
>
<Bot size={20} />
</Button>
aria-label="Open Noz"
prefix={<Noz size={24} />}
/>
</TooltipSimple>
);
}

View File

@@ -28,7 +28,6 @@
.emptyIcon {
margin-bottom: 4px;
opacity: 0.85;
}
.emptyTitle {

View File

@@ -7,8 +7,8 @@ import {
ChartBar,
Search,
Zap,
Sparkles,
} from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -177,10 +177,10 @@ export default function VirtualizedMessages({
if (messages.length === 0 && !showStreamingSlot) {
return (
<div className={styles.empty}>
<div className={styles.emptyIcon}>
<Sparkles size={24} color="var(--primary)" />
<div className={`${styles.emptyIcon} noz-wave`}>
<Noz size={48} />
</div>
<h3 className={styles.emptyTitle}>SigNoz AI Assistant</h3>
<h3 className={styles.emptyTitle}>Noz</h3>
<p className={styles.emptySubtitle}>
Ask questions about your traces, logs, metrics, and infrastructure.
</p>

View File

@@ -67,8 +67,8 @@
gap: 4px;
&--success {
background: color-mix(in srgb, var(--success-background) 10%, transparent);
color: var(--success-foreground);
background: color-mix(in srgb, var(--text-forest-500) 10%, transparent);
color: var(--text-forest-400);
}
&--error {

View File

@@ -196,7 +196,11 @@ function Footer(): JSX.Element {
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
button = (
<Tooltip title={alertValidationMessage}>
<span>{button}</span>
</Tooltip>
);
}
return button;
}, [
@@ -224,7 +228,11 @@ function Footer(): JSX.Element {
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
button = (
<Tooltip title={alertValidationMessage}>
<span>{button}</span>
</Tooltip>
);
}
return button;
}, [

View File

@@ -153,6 +153,7 @@
font-size: 10px;
color: var(--l2-foreground);
margin-top: 4px;
display: block;
}
}

View File

@@ -47,18 +47,10 @@
}
.ant-tabs-tab-active {
.overview-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.variables-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.overview-btn,
.variables-btn,
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
color: var(--primary-background);
background: var(--l1-border);
}
}

View File

@@ -127,6 +127,15 @@
align-items: center;
justify-content: center;
gap: 4px;
.sidenav-beta-tag {
margin-left: 4px;
}
div {
display: flex;
align-items: center;
}
}
.variable-type-btn + .variable-type-btn {
@@ -177,6 +186,7 @@
.multiple-values-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
@@ -193,6 +203,7 @@
.all-option-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {

View File

@@ -518,7 +518,6 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
@@ -614,7 +613,6 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}

View File

@@ -19,6 +19,7 @@
}
.ant-btn-default {
border-color: transparent;
box-shadow: none;
}
}
.ant-tabs-tab-active {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -121,11 +121,6 @@ function Hosts(): JSX.Element {
[dotMetricsEnabled],
);
const primaryFilterKeys = useMemo(
() => [dotMetricsEnabled ? 'host.name' : 'host_name'],
[dotMetricsEnabled],
);
const controlListPrefix = !showFilters ? (
<div className={styles.quickFiltersToggleContainer}>
<Button
@@ -188,7 +183,6 @@ function Hosts(): JSX.Element {
getEntityName={hostGetEntityName}
getInitialLogTracesFilters={getInitialLogTracesFilters}
getInitialEventsFilters={hostInitialEventsFilter}
primaryFilterKeys={primaryFilterKeys}
metadataConfig={hostDetailsMetadataConfig}
entityWidgetInfo={hostWidgetInfo}
getEntityQueryPayload={getHostMetricsQueryPayload}

View File

@@ -101,10 +101,6 @@ export interface K8sBaseDetailsProps<T> {
getEntityName: (entity: T) => string;
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
getInitialEventsFilters: (entity: T) => TagFilterItem[];
/**
* @deprecated It's not needed anymore, remove in the next PR
*/
primaryFilterKeys: string[];
metadataConfig: K8sDetailsMetadataConfig<T>[];
entityWidgetInfo: {
title: string;

View File

@@ -15,7 +15,6 @@ import {
k8sClusterGetEntityName,
k8sClusterGetSelectedItemFilters,
k8sClusterInitialEventsFilter,
k8sClusterInitialFilters,
k8sClusterInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sClustersList({
getEntityName={k8sClusterGetEntityName}
getInitialLogTracesFilters={k8sClusterInitialLogTracesFilter}
getInitialEventsFilters={k8sClusterInitialEventsFilter}
primaryFilterKeys={k8sClusterInitialFilters}
metadataConfig={k8sClusterDetailsMetadataConfig}
entityWidgetInfo={clusterWidgetInfo}
getEntityQueryPayload={getClusterMetricsQueryPayload}

View File

@@ -33,8 +33,6 @@ export const k8sClusterGetSelectedItemFilters = (
export const k8sClusterDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sClusterData>[] =
[{ label: 'Cluster Name', getValue: (p): string => p.meta.k8s_cluster_name }];
export const k8sClusterInitialFilters = [QUERY_KEYS.K8S_CLUSTER_NAME];
export const k8sClusterInitialEventsFilter = (
item: K8sClusterData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -15,7 +15,6 @@ import {
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemFilters,
k8sDaemonSetInitialEventsFilter,
k8sDaemonSetInitialFilters,
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sDaemonSetsList({
getEntityName={k8sDaemonSetGetEntityName}
getInitialLogTracesFilters={k8sDaemonSetInitialLogTracesFilter}
getInitialEventsFilters={k8sDaemonSetInitialEventsFilter}
primaryFilterKeys={k8sDaemonSetInitialFilters}
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDaem
},
];
export const k8sDaemonSetInitialFilters = [
QUERY_KEYS.K8S_DAEMON_SET_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sDaemonSetInitialEventsFilter = (
item: K8sDaemonSetsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -15,7 +15,6 @@ import {
k8sDeploymentGetEntityName,
k8sDeploymentGetSelectedItemFilters,
k8sDeploymentInitialEventsFilter,
k8sDeploymentInitialFilters,
k8sDeploymentInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sDeploymentsList({
getEntityName={k8sDeploymentGetEntityName}
getInitialLogTracesFilters={k8sDeploymentInitialLogTracesFilter}
getInitialEventsFilters={k8sDeploymentInitialEventsFilter}
primaryFilterKeys={k8sDeploymentInitialFilters}
metadataConfig={k8sDeploymentDetailsMetadataConfig}
entityWidgetInfo={deploymentWidgetInfo}
getEntityQueryPayload={getDeploymentMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDep
},
];
export const k8sDeploymentInitialFilters = [
QUERY_KEYS.K8S_DEPLOYMENT_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sDeploymentInitialEventsFilter = (
item: K8sDeploymentsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -15,7 +15,6 @@ import {
k8sJobGetEntityName,
k8sJobGetSelectedItemFilters,
k8sJobInitialEventsFilter,
k8sJobInitialFilters,
k8sJobInitialLogTracesFilter,
} from './constants';
import {
@@ -106,7 +105,6 @@ function K8sJobsList({
getEntityName={k8sJobGetEntityName}
getInitialLogTracesFilters={k8sJobInitialLogTracesFilter}
getInitialEventsFilters={k8sJobInitialEventsFilter}
primaryFilterKeys={k8sJobInitialFilters}
metadataConfig={k8sJobDetailsMetadataConfig}
entityWidgetInfo={jobWidgetInfo}
getEntityQueryPayload={getJobMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sJobsData>[
},
];
export const k8sJobInitialFilters = [
QUERY_KEYS.K8S_JOB_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sJobInitialEventsFilter = (
item: K8sJobsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -14,7 +14,6 @@ import {
k8sNamespaceGetEntityName,
k8sNamespaceGetSelectedItemFilters,
k8sNamespaceInitialEventsFilter,
k8sNamespaceInitialFilters,
k8sNamespaceInitialLogTracesFilter,
namespaceWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sNamespacesList({
getEntityName={k8sNamespaceGetEntityName}
getInitialLogTracesFilters={k8sNamespaceInitialLogTracesFilter}
getInitialEventsFilters={k8sNamespaceInitialEventsFilter}
primaryFilterKeys={k8sNamespaceInitialFilters}
metadataConfig={k8sNamespaceDetailsMetadataConfig}
entityWidgetInfo={namespaceWidgetInfo}
getEntityQueryPayload={getNamespaceMetricsQueryPayload}

View File

@@ -14,7 +14,6 @@ import {
k8sNodeGetEntityName,
k8sNodeGetSelectedItemFilters,
k8sNodeInitialEventsFilter,
k8sNodeInitialFilters,
k8sNodeInitialLogTracesFilter,
nodeWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sNodesList({
getEntityName={k8sNodeGetEntityName}
getInitialLogTracesFilters={k8sNodeInitialLogTracesFilter}
getInitialEventsFilters={k8sNodeInitialEventsFilter}
primaryFilterKeys={k8sNodeInitialFilters}
metadataConfig={k8sNodeDetailsMetadataConfig}
entityWidgetInfo={nodeWidgetInfo}
getEntityQueryPayload={getNodeMetricsQueryPayload}

View File

@@ -14,7 +14,6 @@ import {
k8sPodGetEntityName,
k8sPodGetSelectedItemFilters,
k8sPodInitialEventsFilter,
k8sPodInitialFilters,
k8sPodInitialLogTracesFilter,
podWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sPodsList({
getEntityName={k8sPodGetEntityName}
getInitialLogTracesFilters={k8sPodInitialLogTracesFilter}
getInitialEventsFilters={k8sPodInitialEventsFilter}
primaryFilterKeys={k8sPodInitialFilters}
metadataConfig={k8sPodDetailsMetadataConfig}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}

View File

@@ -42,12 +42,6 @@ export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sPodsData>[
{ label: 'Node', getValue: (p): string => p.meta.k8s_node_name },
];
export const k8sPodInitialFilters = [
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sPodInitialEventsFilter = (
pod: K8sPodsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -14,7 +14,6 @@ import {
k8sStatefulSetGetEntityName,
k8sStatefulSetGetSelectedItemFilters,
k8sStatefulSetInitialEventsFilter,
k8sStatefulSetInitialFilters,
k8sStatefulSetInitialLogTracesFilter,
statefulSetWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sStatefulSetsList({
getEntityName={k8sStatefulSetGetEntityName}
getInitialLogTracesFilters={k8sStatefulSetInitialLogTracesFilter}
getInitialEventsFilters={k8sStatefulSetInitialEventsFilter}
primaryFilterKeys={k8sStatefulSetInitialFilters}
metadataConfig={k8sStatefulSetDetailsMetadataConfig}
entityWidgetInfo={statefulSetWidgetInfo}
getEntityQueryPayload={getStatefulSetMetricsQueryPayload}

View File

@@ -42,11 +42,6 @@ export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sSt
},
];
export const k8sStatefulSetInitialFilters = [
QUERY_KEYS.K8S_STATEFUL_SET_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sStatefulSetInitialEventsFilter = (
item: K8sStatefulSetsData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -14,7 +14,6 @@ import {
k8sVolumeGetEntityName,
k8sVolumeGetSelectedItemFilters,
k8sVolumeInitialEventsFilter,
k8sVolumeInitialFilters,
k8sVolumeInitialLogTracesFilter,
volumeWidgetInfo,
} from './constants';
@@ -106,7 +105,6 @@ function K8sVolumesList({
getEntityName={k8sVolumeGetEntityName}
getInitialLogTracesFilters={k8sVolumeInitialLogTracesFilter}
getInitialEventsFilters={k8sVolumeInitialEventsFilter}
primaryFilterKeys={k8sVolumeInitialFilters}
metadataConfig={k8sVolumeDetailsMetadataConfig}
entityWidgetInfo={volumeWidgetInfo}
getEntityQueryPayload={getVolumeMetricsQueryPayload}

View File

@@ -46,11 +46,6 @@ export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sVolumes
},
];
export const k8sVolumeInitialFilters = [
QUERY_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sVolumeInitialEventsFilter = (
item: K8sVolumesData,
): ReturnType<typeof createFilterItem>[] => [

View File

@@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import GoToTop from 'container/GoToTop';
import { useOptionsMenu } from 'container/OptionsMenu';
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useDebouncedFn from 'hooks/useDebouncedFunction';
@@ -56,6 +58,8 @@ function LiveLogsContainer({
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const formatItems = [
{
key: 'raw',
@@ -238,6 +242,7 @@ function LiveLogsContainer({
items={formatItems}
selectedOptionFormat={options.format}
config={config}
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
/>
</div>
@@ -261,6 +266,17 @@ function LiveLogsContainer({
</div>
<GoToTop />
{config.fieldsSelector && (
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.LOGS}
requiredFields={LOGS_REQUIRED_COLUMNS}
/>
)}
</div>
);
}

View File

@@ -16,7 +16,6 @@ import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColum
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
@@ -24,13 +23,11 @@ import { QueryParams } from 'constants/query';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -54,9 +51,6 @@ function LiveLogsList({
const { isConnectionLoading } = useEventSource();
const { activeLogId } = useCopyLogLink();
const { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
@@ -72,7 +66,7 @@ function LiveLogsList({
[logs],
);
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: StringOperators.NOOP,
@@ -83,47 +77,13 @@ function LiveLogsList({
[formattedLogs, activeLogId],
);
const selectedFields = convertKeysToColumnFields([
...defaultLogsSelectedColumns,
...options.selectColumns,
]);
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const selectedFields = convertKeysToColumnFields(options.selectColumns);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
fontSize: options.fontSize,
appendTo: 'end',
});
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
},
[options.selectColumns, logsPreferences],
);
const makeOnLogCopy = useCallback(
(log: ILog) =>
(event: MouseEvent<HTMLElement>): void => {
@@ -237,7 +197,8 @@ function LiveLogsList({
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={handleColumnRemove}
respectColumnOrder={false}
onColumnRemove={config?.addColumn?.onRemove}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={formattedLogs}

View File

@@ -18,21 +18,19 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import Spinner from 'components/Spinner';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import APIError from 'types/api/error';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -69,10 +67,6 @@ function LogsExplorerList({
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const { activeLogId } = useCopyLogLink();
const { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
onAddToQuery,
@@ -81,7 +75,7 @@ function LogsExplorerList({
handleCloseLogDetail,
} = useLogDetailHandlers();
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator:
@@ -97,34 +91,20 @@ function LogsExplorerList({
);
const selectedFields = useMemo(
() =>
convertKeysToColumnFields([
...defaultLogsSelectedColumns,
...options.selectColumns,
]),
[options],
() => convertKeysToColumnFields(options.selectColumns),
[options.selectColumns],
);
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<ILog>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
},
[options.selectColumns, logsPreferences],
[config],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
fontSize: options.fontSize,
appendTo: 'end',
});
const makeOnLogCopy = useCallback(
@@ -161,20 +141,6 @@ function LogsExplorerList({
}
}, [isLoading, isFetching, isError, logs.length]);
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
@@ -237,7 +203,9 @@ function LogsExplorerList({
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={handleColumnRemove}
respectColumnOrder={false}
onColumnRemove={config?.addColumn?.onRemove}
onColumnOrderChange={handleColumnOrderChange}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={logs}

View File

@@ -1,16 +1,17 @@
import { useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useOptionsMenu } from 'container/OptionsMenu';
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import QueryStatus from './QueryStatus';
function LogsActionsContainer({
listQuery,
selectedPanelType,
@@ -18,10 +19,6 @@ function LogsActionsContainer({
handleToggleFrequencyChart,
orderBy,
setOrderBy,
isFetching,
isLoading,
isError,
isSuccess,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
@@ -29,10 +26,6 @@ function LogsActionsContainer({
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
isFetching: boolean;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -40,6 +33,8 @@ function LogsActionsContainer({
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const formatItems = [
{
key: 'raw',
@@ -102,23 +97,24 @@ function LogsActionsContainer({
items={formatItems}
selectedOptionFormat={options.format}
config={config}
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
/>
</div>
</>
)}
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
selectedPanelType === PANEL_TYPES.TABLE) && (
<div className="query-stats">
<QueryStatus
loading={isLoading || isFetching}
error={isError}
success={isSuccess}
/>
</div>
)}
</div>
</div>
{config.fieldsSelector && (
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.LOGS}
requiredFields={LOGS_REQUIRED_COLUMNS}
/>
)}
</div>
);
}

View File

@@ -155,40 +155,6 @@
}
}
.query-stats {
display: flex;
align-items: center;
gap: 12px;
align-self: flex-end;
.rows {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: 0.36px;
}
.divider {
width: 1px;
height: 14px;
background: var(--l3-background);
}
.time {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: 0.36px;
}
}
.ant-btn {
border: none;
}

View File

@@ -1,4 +0,0 @@
.query-status {
display: flex;
align-items: center;
}

View File

@@ -1,49 +0,0 @@
import React, { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { LoaderCircle, CircleCheck } from '@signozhq/icons';
import { Spin } from 'antd';
import solidXCircleUrl from '@/assets/Icons/solid-x-circle.svg';
import './QueryStatus.styles.scss';
interface IQueryStatusProps {
loading: boolean;
error: boolean;
success: boolean;
}
export default function QueryStatus(
props: IQueryStatusProps,
): React.ReactElement {
const { loading, error, success } = props;
const content = useMemo((): React.ReactElement => {
if (loading) {
return (
<Spin
spinning
size="small"
indicator={<LoaderCircle className="animate-spin" size="md" />}
/>
);
}
if (error) {
return (
<img
src={solidXCircleUrl}
alt="header"
className="error"
style={{ height: '14px', width: '14px' }}
/>
);
}
if (success) {
return (
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
);
}
return <div />;
}, [error, loading, success]);
return <div className="query-status">{content}</div>;
}

View File

@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
'custom',
);
const { data, isLoading, isFetching, isError, isSuccess, error } =
const { data, isLoading, isFetching, isError, error } =
useGetExplorerQueryRange(
requestData,
selectedPanelType,
@@ -437,10 +437,6 @@ function LogsExplorerViewsContainer({
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
isFetching={isFetching}
isLoading={isLoading}
isError={isError}
isSuccess={isSuccess}
/>
)}

View File

@@ -59,7 +59,7 @@ function AllAttributes({
);
const attributes = useMemo(
() => attributesData?.data.attributes ?? [],
() => attributesData?.data?.attributes ?? [],
[attributesData],
);

View File

@@ -56,7 +56,7 @@ function MetricDetails({
);
const metadata = useMemo(() => {
if (!metricMetadataResponse) {
if (!metricMetadataResponse?.data) {
return null;
}
const { type, description, unit, temporality, isMonotonic } =

View File

@@ -0,0 +1,89 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
ensureLogsRequiredColumns,
} from '../constants';
const TIMESTAMP = defaultLogsSelectedColumns.find(
(c) => c.name === 'timestamp',
);
const BODY = defaultLogsSelectedColumns.find((c) => c.name === 'body');
if (!TIMESTAMP || !BODY) {
throw new Error('defaults missing timestamp/body — test fixture invalid');
}
const ATTR_A: TelemetryFieldKey = {
name: 'service.name',
signal: 'logs',
fieldContext: 'resource',
fieldDataType: 'string',
};
const ATTR_B: TelemetryFieldKey = {
name: 'severity_text',
signal: 'logs',
fieldContext: 'log',
fieldDataType: 'string',
};
describe('ensureLogsRequiredColumns', () => {
it('prepends both timestamp + body to an empty list', () => {
expect(ensureLogsRequiredColumns([])).toStrictEqual([TIMESTAMP, BODY]);
});
it('prepends only `body` when `timestamp` is already present', () => {
expect(ensureLogsRequiredColumns([TIMESTAMP, ATTR_A])).toStrictEqual([
BODY,
TIMESTAMP,
ATTR_A,
]);
});
it('prepends only `timestamp` when `body` is already present', () => {
expect(ensureLogsRequiredColumns([BODY, ATTR_A])).toStrictEqual([
TIMESTAMP,
BODY,
ATTR_A,
]);
});
it('returns the same array when both are present (no duplicates, original order preserved)', () => {
const input = [TIMESTAMP, BODY, ATTR_A, ATTR_B];
expect(ensureLogsRequiredColumns(input)).toBe(input);
});
it('preserves a non-default order when both are present', () => {
const input = [ATTR_A, BODY, ATTR_B, TIMESTAMP];
expect(ensureLogsRequiredColumns(input)).toStrictEqual(input);
});
it('prepends both when neither is present in a list of user attributes', () => {
expect(ensureLogsRequiredColumns([ATTR_A, ATTR_B])).toStrictEqual([
TIMESTAMP,
BODY,
ATTR_A,
ATTR_B,
]);
});
it('collapses composite-key duplicates in the input', () => {
// Two identical `body` entries → deduped to one, then timestamp prepended.
const input = [BODY, BODY, ATTR_A];
const result = ensureLogsRequiredColumns(input);
expect(result).toStrictEqual([TIMESTAMP, BODY, ATTR_A]);
expect(result.filter((c) => c.name === 'body')).toHaveLength(1);
});
it('keeps same-name fields with different contexts as distinct columns', () => {
// Different composite keys → both legitimate, neither deduped.
const ATTR_BODY: TelemetryFieldKey = {
name: 'body',
signal: 'logs',
fieldContext: 'attribute',
fieldDataType: 'string',
};
const input = [TIMESTAMP, BODY, ATTR_BODY];
expect(ensureLogsRequiredColumns(input)).toStrictEqual(input);
});
});

View File

@@ -232,4 +232,159 @@ describe('useOptionsMenu', () => {
expect(optionNames).toContain('level');
expect(optionNames).toContain('timestamp');
});
describe('reorderSelectColumns / handleRemoveSelectedColumn — composite ID lookup', () => {
const baseFormatting = {
format: 'table',
maxLines: 1,
fontSize: 'small',
};
// Two entries share the same `name` but differ in `fieldContext` — the
// exact case composite IDs are meant to disambiguate.
const seedColumns = [
{
name: 'body',
fieldContext: 'log',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'service.name',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'timestamp',
fieldContext: 'log',
fieldDataType: '',
signal: 'logs',
},
];
beforeEach(() => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: { columns: [], formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: seedColumns, formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
});
it('reorders by composite IDs — preserves both same-name variants distinctly', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
);
// New order: [attribute.service.name, log.body, resource.service.name, log.timestamp]
result.current.config.addColumn?.onReorder([
'attribute.service.name',
'log.body',
'resource.service.name',
'log.timestamp',
]);
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const reordered = mockUpdateColumns.mock.calls[0][0];
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
),
).toStrictEqual([
'attribute.service.name',
'log.body',
'resource.service.name',
'log.timestamp',
]);
});
it('reorder ignores ids that are not columns (e.g. state-indicator) and skips unknown ids', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
);
result.current.config.addColumn?.onReorder([
'state-indicator',
'log.timestamp',
'unknown.composite',
'log.body',
'resource.service.name',
'attribute.service.name',
]);
const reordered = mockUpdateColumns.mock.calls[0][0];
// state-indicator + unknown.composite filtered; rest preserved in given order.
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
),
).toStrictEqual([
'log.timestamp',
'log.body',
'resource.service.name',
'attribute.service.name',
]);
});
it('removes only the variant whose composite ID matches', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
);
// Removing 'resource.service.name' should drop ONLY the resource variant.
result.current.config.addColumn?.onRemove('resource.service.name');
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const remaining = mockUpdateColumns.mock.calls[0][0];
expect(
remaining.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}.${c.name}`,
),
).toStrictEqual(['log.body', 'attribute.service.name', 'log.timestamp']);
});
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
);
result.current.config.addColumn?.onRemove('unknown.composite');
const remaining = mockUpdateColumns.mock.calls[0][0];
// No entries match → full list preserved.
expect(remaining).toHaveLength(seedColumns.length);
});
});
});

View File

@@ -2,6 +2,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
import { DataSource } from 'types/common/queryBuilder';
import { FontSize, OptionsQuery } from './types';
import { buildCompositeKey } from './utils';
export const URL_OPTIONS = 'options';
@@ -35,6 +36,58 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
},
];
// Names that must always be present in logs selectColumns (writer invariant).
const LOGS_REQUIRED_COLUMN_NAMES = defaultLogsSelectedColumns.map(
(c) => c.name,
);
// Composite keys (not bare names) so the picker locks ONLY the canonical
// `log.body`/`log.timestamp` — a same-name variant like `attribute.body` stays
// removable.
export const LOGS_REQUIRED_COLUMNS = defaultLogsSelectedColumns.map((c) =>
buildCompositeKey(c.name, c.fieldContext),
);
// Drop composite-key duplicates (never legitimate — they only come from
// corrupted state). Returns the same array reference when nothing to dedupe.
export function dedupeColumnsByCompositeKey(
columns: TelemetryFieldKey[],
): TelemetryFieldKey[] {
const seen = new Set<string>();
let hasDuplicate = false;
const deduped = columns.filter((c) => {
const key = buildCompositeKey(c.name, c.fieldContext);
if (seen.has(key)) {
hasDuplicate = true;
return false;
}
seen.add(key);
return true;
});
return hasDuplicate ? deduped : columns;
}
// Logs selectColumns invariant: no composite-key duplicates, and body +
// timestamp always present. Applied at loader + writer boundaries.
export function ensureLogsRequiredColumns(
columns: TelemetryFieldKey[],
): TelemetryFieldKey[] {
const deduped = dedupeColumnsByCompositeKey(columns);
const missing = LOGS_REQUIRED_COLUMN_NAMES.filter(
(name) => !deduped.some((c) => c.name === name),
);
if (missing.length === 0) {
return deduped;
}
const defaultsByName = new Map(
defaultLogsSelectedColumns.map((c) => [c.name, c]),
);
const prepended = missing
.map((name) => defaultsByName.get(name))
.filter((c): c is TelemetryFieldKey => c !== undefined);
return [...prepended, ...deduped];
}
export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
{
name: 'service.name',

View File

@@ -40,5 +40,10 @@ export type OptionsMenuConfig = {
isFetching: boolean;
value: TelemetryFieldKey[];
onRemove: (key: string) => void;
onReorder: (orderedIds: string[]) => void;
};
fieldsSelector?: {
value: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
};
};

View File

@@ -36,7 +36,7 @@ import {
OptionsMenuConfig,
OptionsQuery,
} from './types';
import { getOptionsFromKeys } from './utils';
import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
@@ -187,30 +187,6 @@ const useOptionsMenu = ({
searchedAttributesDataV5?.data.data.keys || {},
).flat();
if (searchedAttributesDataList.length) {
if (dataSource === DataSource.LOGS) {
const logsSelectedColumns: TelemetryFieldKey[] =
defaultLogsSelectedColumns.map((e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
}));
return [
...logsSelectedColumns,
...searchedAttributesDataList
.filter((attribute) => attribute.name !== 'body')
// eslint-disable-next-line sonarjs/no-identical-functions
.map((e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
})),
];
}
// eslint-disable-next-line sonarjs/no-identical-functions
return searchedAttributesDataList.map((e) => ({
...e,
name: e.name,
@@ -297,57 +273,27 @@ const useOptionsMenu = ({
return [...acc, column];
}, [] as TelemetryFieldKey[]);
const optionsData: OptionsQuery = {
...defaultOptionsQuery,
selectColumns: newSelectedColumns,
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateColumns(newSelectedColumns);
handleRedirectWithOptionsData(optionsData);
},
[
searchedAttributeKeys,
selectedColumnKeys,
preferences,
handleRedirectWithOptionsData,
updateColumns,
],
[searchedAttributeKeys, selectedColumnKeys, preferences, updateColumns],
);
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
const newSelectedColumns = preferences?.columns?.filter(
({ name }) => name !== columnKey,
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
notifications.error({
message: 'There must be at least one selected column',
});
} else {
const optionsData: OptionsQuery = {
...defaultOptionsQuery,
selectColumns: newSelectedColumns || [],
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines:
preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize:
preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateColumns(newSelectedColumns || []);
handleRedirectWithOptionsData(optionsData);
return;
}
updateColumns(newSelectedColumns || []);
},
[
dataSource,
notifications,
preferences,
handleRedirectWithOptionsData,
updateColumns,
],
[dataSource, notifications, preferences, updateColumns],
);
const handleFormatChange = useCallback(
@@ -414,6 +360,20 @@ const useOptionsMenu = ({
setSearchText(value);
}, []);
const reorderSelectColumns = useCallback(
(orderedIds: string[]): void => {
const current = preferences?.columns ?? [];
const byCompositeKey = new Map(
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
);
const reordered = orderedIds
.map((id) => byCompositeKey.get(id))
.filter((f): f is TelemetryFieldKey => f !== undefined);
updateColumns(reordered);
},
[preferences, updateColumns],
);
const handleFocus = (): void => {
setIsFocused(true);
};
@@ -436,6 +396,11 @@ const useOptionsMenu = ({
onSelect: handleSelectColumns,
onRemove: handleRemoveSelectedColumn,
onSearch: handleSearchAttribute,
onReorder: reorderSelectColumns,
},
fieldsSelector: {
value: preferences?.columns ?? [],
onFieldsChange: updateColumns,
},
format: {
value: preferences?.formatting?.format || defaultOptionsQuery.format,
@@ -457,6 +422,8 @@ const useOptionsMenu = ({
handleSelectColumns,
handleRemoveSelectedColumn,
handleSearchAttribute,
reorderSelectColumns,
updateColumns,
handleFormatChange,
handleMaxLinesChange,
handleFontSizeChange,

View File

@@ -14,3 +14,9 @@ export const getOptionsFromKeys = (
({ value }) => !selectedKeys.find((key) => key === value),
);
};
// Composite identity for a column. Disambiguates same-name fields across
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
// Falls back to bare name when context is missing.
export const buildCompositeKey = (name: string, context?: string): string =>
context ? `${context}.${name}` : name;

View File

@@ -103,7 +103,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return {
...rest,
...(domainToAdminEmail && { domainToAdminEmail }),
domainToAdminEmail: domainToAdminEmail ?? {},
};
}, [form]);
@@ -129,7 +129,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return {
...rest,
...(groupMappings && { groupMappings }),
groupMappings: groupMappings ?? {},
};
}, [form]);

View File

@@ -0,0 +1,144 @@
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
import {
convertDomainMappingsToList,
convertDomainMappingsToRecord,
convertGroupMappingsToList,
convertGroupMappingsToRecord,
prepareInitialValues,
} from './CreateEdit.utils';
describe('convertGroupMappingsToRecord', () => {
it('returns undefined for an empty list', () => {
expect(convertGroupMappingsToRecord([])).toBeUndefined();
});
it('returns undefined when input is undefined', () => {
expect(convertGroupMappingsToRecord(undefined)).toBeUndefined();
});
it('converts entries to a Record', () => {
expect(
convertGroupMappingsToRecord([
{ groupName: 'admins', role: 'ADMIN' },
{ groupName: 'viewers', role: 'VIEWER' },
]),
).toStrictEqual({ admins: 'ADMIN', viewers: 'VIEWER' });
});
it('skips entries with missing groupName or role', () => {
expect(
convertGroupMappingsToRecord([
{ groupName: 'admins', role: 'ADMIN' },
{ groupName: '', role: 'VIEWER' },
{ role: 'EDITOR' },
]),
).toStrictEqual({ admins: 'ADMIN' });
});
});
describe('convertDomainMappingsToRecord', () => {
it('returns undefined for an empty list', () => {
expect(convertDomainMappingsToRecord([])).toBeUndefined();
});
it('returns undefined when input is undefined', () => {
expect(convertDomainMappingsToRecord(undefined)).toBeUndefined();
});
it('converts entries to a Record', () => {
expect(
convertDomainMappingsToRecord([
{ domain: 'example.com', adminEmail: 'admin@example.com' },
{ domain: 'corp.io', adminEmail: 'it@corp.io' },
]),
).toStrictEqual({
'example.com': 'admin@example.com',
'corp.io': 'it@corp.io',
});
});
});
describe('round-trip fidelity', () => {
it('Record → list → Record preserves group mappings', () => {
const original = { admins: 'ADMIN', devs: 'EDITOR', viewers: 'VIEWER' };
expect(
convertGroupMappingsToRecord(convertGroupMappingsToList(original)),
).toStrictEqual(original);
});
it('Record → list → Record preserves domain mappings', () => {
const original = {
'example.com': 'admin@example.com',
'corp.io': 'it@corp.io',
};
expect(
convertDomainMappingsToRecord(convertDomainMappingsToList(original)),
).toStrictEqual(original);
});
});
describe('prepareInitialValues', () => {
it('returns empty defaults when no record is provided', () => {
expect(prepareInitialValues(undefined)).toStrictEqual({
name: '',
ssoEnabled: false,
ssoType: '',
});
});
it('hydrates groupMappings Record into groupMappingsList for the form', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
},
},
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([
{ groupName: 'admins', role: 'ADMIN' },
{ groupName: 'viewers', role: 'VIEWER' },
]);
});
it('hydrates domainToAdminEmail Record into domainToAdminEmailList for the form', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'id',
clientSecret: 'secret',
domainToAdminEmail: { 'example.com': 'admin@example.com' },
},
},
});
expect(result.googleAuthConfig?.domainToAdminEmailList).toStrictEqual([
{ domain: 'example.com', adminEmail: 'admin@example.com' },
]);
});
it('sets groupMappingsList to empty array when roleMapping has no groupMappings', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
},
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,169 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockGoogleAuthWithWorkspaceGroups,
mockUpdateSuccessResponse,
} from './mocks';
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
// which mocks @signozhq/ui/switch for the same reason.
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
describe('CreateEdit — save payload correctness', () => {
afterEach(() => {
server.resetHandlers();
});
it('sends groupMappings: {} when all group mappings are deleted', async () => {
let capturedPayload: unknown = null;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={jest.fn()}
/>,
);
// Open the Role Mapping collapse (Ant Design Collapse responds to click events)
fireEvent.click(screen.getByText(/role mapping \(advanced\)/i));
// Wait for the 3 group mapping rows to appear
await waitFor(() =>
expect(
screen.getAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(3),
);
// Delete each row; re-query after each removal
fireEvent.click(
screen.getAllByRole('button', { name: /remove mapping/i })[0],
);
await waitFor(() =>
expect(
screen.getAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(2),
);
fireEvent.click(
screen.getAllByRole('button', { name: /remove mapping/i })[0],
);
await waitFor(() =>
expect(
screen.getAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(1),
);
fireEvent.click(
screen.getAllByRole('button', { name: /remove mapping/i })[0],
);
await waitFor(() =>
expect(
screen.queryAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(0),
);
// Submit — MSW intercepts the PUT request
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => expect(capturedPayload).not.toBeNull());
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
roleMapping: expect.objectContaining({ groupMappings: {} }),
}),
});
});
it('sends domainToAdminEmail: {} when all domain mappings are deleted', async () => {
let capturedPayload: unknown = null;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthWithWorkspaceGroups}
onClose={jest.fn()}
/>,
);
// Open the Google Workspace Groups collapse
fireEvent.click(screen.getByText(/google workspace groups/i));
// Wait for the single domain mapping row
await waitFor(() =>
expect(
screen.getByRole('button', { name: /remove mapping/i }),
).toBeInTheDocument(),
);
// Delete the row
fireEvent.click(screen.getByRole('button', { name: /remove mapping/i }));
await waitFor(() =>
expect(
screen.queryAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(0),
);
// Submit
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => expect(capturedPayload).not.toBeNull());
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
googleAuthConfig: expect.objectContaining({
domainToAdminEmail: {},
}),
}),
});
});
});

View File

@@ -72,8 +72,20 @@
.alert-rule-scope {
margin-bottom: 12px;
.ant-radio-wrapper {
color: var(--l1-foreground);
// `.createForm label` styles field labels (font-weight 500, 14px,
// 6px bottom padding). Those bleed into the @signozhq/ui RadioGroup
// option labels, making them bold and vertically misaligned with the
// radio control. Reset them back to plain option-text styling.
label {
padding: 0;
font-weight: 400;
line-height: normal;
}
// Loosen the design-system default (grid gap 0.5rem) between options.
.silence-alerts-radio-group {
margin-top: 8px;
gap: 12px;
}
}
@@ -144,10 +156,7 @@
display: flex;
align-items: center;
gap: 8px;
.ant-btn {
margin-top: 8px;
}
margin-top: 12px;
}
.schedule-created-at {

View File

@@ -54,8 +54,7 @@ import {
} from './PlannedDowntimeutils';
import './PlannedDowntime.styles.scss';
import { RadioGroupItem } from '@signozhq/ui/radio-group';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { RadioGroupItem, RadioGroup } from '@signozhq/ui/radio-group';
dayjs.locale('en');
dayjs.extend(utc);
@@ -471,7 +470,7 @@ export function PlannedDowntimeForm(
initialValue="specific"
className="alert-rule-scope"
>
<RadioGroup>
<RadioGroup className="silence-alerts-radio-group">
<RadioGroupItem value="all">All alert rules</RadioGroupItem>
<RadioGroupItem value="specific">Specific alert rules</RadioGroupItem>
</RadioGroup>

View File

@@ -9,6 +9,19 @@
width: 0.2rem;
height: 0.2rem;
}
.option-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.option-meta-data-container {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.option-renderer-tooltip {

View File

@@ -24,7 +24,7 @@ export const StyledCheckOutlined = styled(Check)`
export const TagContainer = styled(Badge)`
&&& {
display: inline-block;
display: flex;
border-radius: 3px;
padding: 0.1rem 0.2rem;
font-weight: 300;

View File

@@ -233,8 +233,10 @@
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding: 0px;
gap: 0;
margin: 4px;
.ant-typography {
.qb-tag-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px !important;
@@ -244,7 +246,7 @@
padding: 2px 6px;
}
.close-icon {
> button {
display: flex;
align-items: center;
justify-content: center;
@@ -259,26 +261,26 @@
&.resource {
border: 1px solid color-mix(in srgb, var(--bg-aqua-400) 13%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-aqua-400);
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
}
}
&.tag {
border: 1px solid color-mix(in srgb, var(--bg-sienna-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
}
}
@@ -286,13 +288,13 @@
&.scope {
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-robin-400);
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
}
}

View File

@@ -966,6 +966,7 @@ function QueryBuilderSearchV2(
>
<Tooltip title={chipValue}>
<TypographyText
className="qb-tag-text"
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {

View File

@@ -21,6 +21,10 @@
justify-content: center;
margin-bottom: 8px;
color: var(--semantic-primary-foreground);
&--error {
color: var(--destructive);
}
}
.reset-password-header-title {

View File

@@ -0,0 +1,67 @@
import { CircleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import APIError from 'types/api/error';
import './ResetPassword.styles.scss';
interface TokenErrorContent {
title: string;
subtitle: string;
}
function getErrorContent(error?: APIError): TokenErrorContent {
const code = error?.getErrorCode();
if (code === 'reset_password_token_expired') {
return {
title: 'Reset Password token is expired',
subtitle:
'Password reset links are single-use and expire after a set period. Please request a new password reset link.',
};
}
if (code === 'reset_password_token_not_found') {
return {
title: 'Invalid Reset Link',
subtitle:
'This reset password link is invalid or has already been used. Please request a new password reset link.',
};
}
return {
title: 'Reset Link Unavailable',
subtitle:
'We could not validate your reset password link. Please request a new one.',
};
}
interface TokenErrorProps {
error?: APIError;
}
function TokenError({ error }: TokenErrorProps): JSX.Element {
const { title, subtitle } = getErrorContent(error);
return (
<AuthPageContainer>
<div className="reset-password-card reset-password-card--centered">
<div className="reset-password-header">
<div className="reset-password-header-icon reset-password-header-icon--error">
<CircleAlert size={32} />
</div>
<Typography.Title level={4} className="reset-password-header-title">
{title}
</Typography.Title>
<Typography.Text className="reset-password-header-subtitle">
{subtitle}
</Typography.Text>
</div>
{error && <AuthError error={error} />}
</div>
</AuthPageContainer>
);
}
export default TokenError;

View File

@@ -1,4 +1,3 @@
import { Logout } from 'api/utils';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { rest, server } from 'mocks-server/server';
@@ -17,10 +16,6 @@ jest.mock('lib/history', () => ({
},
}));
jest.mock('api/utils', () => ({
Logout: jest.fn(),
}));
const mockSuccessNotification = jest.fn();
const mockErrorNotification = jest.fn();
@@ -70,17 +65,6 @@ describe('ResetPassword Component', () => {
).toBeInTheDocument();
expect(screen.getByText(/signoz 1\.0\.0/i)).toBeInTheDocument();
});
it('redirects to login when token is missing', () => {
window.history.pushState({}, '', '/password-reset');
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset',
});
expect(Logout).toHaveBeenCalled();
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});
describe('Form Validation', () => {

View File

@@ -1,11 +1,10 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Form, Input as AntdInput } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Logout } from 'api/utils';
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
@@ -38,13 +37,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const { notifications } = useNotifications();
const [form] = Form.useForm<FormValues>();
useEffect(() => {
if (!token) {
Logout();
history.push(ROUTES.LOGIN);
}
}, [token]);
const handleFormSubmit: () => Promise<void> = async () => {
try {
setLoading(true);

View File

@@ -35,10 +35,7 @@
display: flex;
align-items: center;
gap: 8px;
.ant-btn {
margin-top: 8px;
}
margin-top: 12px;
}
.routing-policies-table {

View File

@@ -5,7 +5,6 @@ import { Pin, PinOff } from '@signozhq/icons';
import { SidebarItem } from '../sideNav.types';
import './NavItem.styles.scss';
import './NavItem.styles.scss';
export default function NavItem({
@@ -27,7 +26,7 @@ export default function NavItem({
showIcon?: boolean;
dataTestId?: string;
}): JSX.Element {
const { label, icon, isBeta, isNew } = item;
const { label, icon, isBeta, isNew, isEarlyAccess, tooltip } = item;
const handleTogglePinClick = (
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
@@ -36,7 +35,7 @@ export default function NavItem({
onTogglePin?.(item);
};
return (
const navItem = (
<div
className={cx(
'nav-item',
@@ -53,7 +52,11 @@ export default function NavItem({
>
{showIcon && <div className="nav-item-active-marker" />}
<div className={cx('nav-item-data', isBeta ? 'beta-tag' : '')}>
{showIcon && <div className="nav-item-icon">{icon}</div>}
{showIcon && (
<div className={cx('nav-item-icon', isEarlyAccess ? 'noz-wave' : '')}>
{icon}
</div>
)}
<div className="nav-item-label">{label}</div>
@@ -73,6 +76,12 @@ export default function NavItem({
</div>
)}
{isEarlyAccess && (
<div className="nav-item-early-access">
<Badge color="robin">Early Access</Badge>
</div>
)}
{onTogglePin && !isPinned && (
<Tooltip title="Add to shortcuts" placement="right">
<Pin
@@ -97,6 +106,15 @@ export default function NavItem({
</div>
</div>
);
// Only non-pinnable items set `tooltip`; it would nest with the pin tooltip.
return tooltip ? (
<Tooltip title={tooltip} placement="right">
{navItem}
</Tooltip>
) : (
navItem
);
}
NavItem.defaultProps = {

View File

@@ -579,7 +579,8 @@
}
.nav-item-beta,
.nav-item-new {
.nav-item-new,
.nav-item-early-access {
display: none;
}
@@ -623,6 +624,23 @@
background: var(--l3-background);
}
.sidenav-early-access-tag {
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings:
'case' on,
'cpsp' on,
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 9px;
font-style: normal;
font-weight: 300;
line-height: 12px;
letter-spacing: 0.3px;
text-transform: uppercase;
border-radius: 12px;
}
&:not(.pinned) {
.nav-item {
.nav-item-data {
@@ -839,7 +857,8 @@
}
.nav-item-beta,
.nav-item-new {
.nav-item-new,
.nav-item-early-access {
display: block;
}
}
@@ -1012,7 +1031,8 @@
}
.nav-item-beta,
.nav-item-new {
.nav-item-new,
.nav-item-early-access {
display: block;
}
}

View File

@@ -44,6 +44,8 @@ import {
SidebarItem,
} from './sideNav.types';
import { Style } from '@signozhq/design-tokens';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
export const getStartedMenuItem = {
key: ROUTES.GET_STARTED,
@@ -92,9 +94,11 @@ const AI_ASSISTANT_NAV_KEY = '/ai-assistant/new';
export const aiAssistantMenuItem = {
key: AI_ASSISTANT_NAV_KEY,
label: 'AI Assistant',
icon: <Sparkles size={16} className="ai-assistant-icon" />,
label: 'Noz',
icon: <Noz size={16} />,
itemKey: 'ai-assistant',
isEarlyAccess: true,
tooltip: NOZ_TOOLTIP_TITLE,
};
export const shortcutMenuItem = {

View File

@@ -14,6 +14,9 @@ export interface SidebarItem {
label?: ReactNode;
isBeta?: boolean;
isNew?: boolean;
isEarlyAccess?: boolean;
/** Hover copy for the whole item row (e.g. Noz's early-access tagline). */
tooltip?: ReactNode;
isPinned?: boolean;
children?: SidebarItem[];
isExternal?: boolean;

View File

@@ -8,7 +8,7 @@
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0;
@@ -34,9 +34,9 @@
}
.refresh-interval-text {
padding: 12px 14px 8px 14px;
padding: 12px 14px 8px 14px !important;
color: var(--muted-foreground);
font-size: 11px;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */

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