Compare commits

..

14 Commits

Author SHA1 Message Date
aks07
5981344654 feat: add download option to trace view 2026-07-14 21:48:57 +05:30
Nityananda Gohain
891106d1ca fix: set correct openapi response struct for span mapper list (#12094)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: set correct opapi response model for span mapper list

* fix: change group_id to groupId in response

* fix: format properly

* fix: update fixtures
2026-07-14 15:34:52 +00:00
Naman Verma
707f01bd9c chore: support non-dsl search in dashboards v2 list apis (#12051)
* chore: support non-dsl search in dashboards v2 list apis

* chore: move free text compilation to visitor

* chore: shorten comments

* fix: support both plain text and dsl together

* fix: ensure empty description dashboards are not excluded in NOT checks
2026-07-14 13:02:58 +00:00
Vinicius Lourenço
3cd0c21703 feat(daemonsets): add ready/misscheduled columns (#12102) 2026-07-14 12:33:21 +00:00
Abhi kumar
181feb3eb9 fix(dashboard-v2): only refetch on-screen panels on time change/auto-refresh (#12113)
Panel visibility was latched (isObserverOnce), so once every panel had
scrolled into view they all stayed query-enabled. A time change or
auto-refresh re-keys every panel's query, refetching all of them at once.

Track the live viewport instead so off-screen panels stay query-disabled
and skip the refetch; they fetch lazily when scrolled back in. Add
staleTime: Infinity so re-entering an unchanged window serves cache rather
than thrash-refetching (key changes and manual refetch still run).
2026-07-14 12:23:04 +00:00
Naman Verma
bf35d12398 chore: add functional unique index for tags (#12029)
* feat: add functional unique index

* chore: add tag migration

* chore: add failing scratch test (to discuss)

* chore: fetch expressions as well in getindices call

* fix: remove tag unique index (will be added in a separate PR)

* fix: remove tag unique index (will be added in a separate PR)

* chore: remove temporary tests

* chore: add functional unique index for tags

* fix: go lint fix

* fix: go lint fix

* chore: better comment for unique index

* test: add test for case insensitive expression equality

* test: add equality test for columns with quotes and capital letters

* test: add test for quoted columns

* chore: add separate type for unique indices with expressions

* fix: update migration file to use correct index

* test: add test for postgres provider

* test: add test for postgres provider

* fix: remove round trip tests
2026-07-14 11:08:59 +00:00
Abhi kumar
1a1f7b2d78 fix(dashboard-v2): bug bash fixes IV (#12056)
* fix(context-menu): keep overlay clickable when opened inside a modal

Portal the popover to body and set pointer-events:auto so a modal's dialog/backdrop can't trap it.

* fix(dashboard-v2): center scrolled-to panels and sections

Default the scroll block to 'center' so a targeted panel lands mid-viewport.

* fix(dashboard-v2): make the view modal refresh button secondary

Use outlined/secondary so refresh doesn't compete with the primary action.

* fix(dashboard-v2): fetch only on-screen panels on dashboard load

RGL unfolds panels from the top-left origin on mount, briefly piling them into the viewport.

The latched observer then marked all visible; suppress the item transition for the first frame.

* chore: readded entry animation
2026-07-14 10:43:14 +00:00
dependabot[bot]
a2778aa45f chore(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 (#11965)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 10:01:13 +00:00
Srikanth Chekuri
16c1fce326 fix(ruletypes): address rule read round-trip gaps (#12110)
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-07-14 08:36:30 +00:00
Gaurav Tewari
06b762b7ef fix(llm o11y): update unpriced models tab test case (#12108)
* fix: update unpriced models tab test case

* chore: update ci timming

* chore: update comment

* chore: remove comment

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-14 08:22:03 +00:00
Aditya Singh
466edf1f1c feat: timeseries export in logs, traces and metrics explorers (2) (#12055)
* feat(data-export): surface raw V5 response and legend map from GetMetricQueryRange

Keeps the pre-conversion V5 queryRange response and the per-query legend map on the returned MetricQueryRangeSuccessResponse so explorer views can feed client-side export without a refetch. Additive optional fields; the legacy V3 render path is untouched. One chokepoint covers Logs, Traces and Metrics.

* fix(data-export): omit display-only format ids from export headers

'short'/'none' are Grafana-style display formats, not physical units — meaningful on a chart axis but misleading as 'value (short)' in a CSV header. Real units (ms, bytes, ...) are unchanged.

* feat(data-export): timeseries export menu and opt-in TimeSeriesView header

New TimeseriesExportMenu (csv/jsonl, LONG shape default; same look as DownloadOptionsMenu, which stays raw/list-only) rendered from a new opt-in TimeSeriesView header row: unit selector left (onYAxisUnitChange — rendering only, unit state stays with each view), download right (allowExport, needs the surfaced raw V5). Both default off, so views opt in explicitly. Includes gating + menu tests.

* feat(explorer): enable timeseries export in Logs, Traces and Metrics

Logs and Traces pass allowExport + onYAxisUnitChange and drop their external unit-filter headers (one header row: unit left, download right). Metrics passes allowExport only, per chart — its YAxisUnitSelector stays screen-level (persists to the metric, spans N charts). convertDataValueToMs now converts the raw V5 tree too, so Traces duration exports carry the ms values the chart shows instead of raw nanoseconds.

* refactor(data-export): use @signozhq/ui components in the export popover

Review feedback: swap antd Button/Popover/Tooltip for the design-system equivalents (radix-style Popover with controlled open, TooltipSimple, Button with native loading). Styles restructured for the DS DOM with periscope font tokens; test renders via tests/test-utils for the TooltipProvider.
2026-07-14 06:54:51 +00:00
Nikhil Mantri
849353b5c0 feat(infra-monitoring): counts query for counting related entities (#12000)
* chore: added types and open api spec changes

* chore: added method to calculate reason

* chore: per group pod status counts with req metric checks method added

* chore: wired up pod status counts

* chore: pod restarts type added

* chore: added restart counts for the group

* chore: bug in query fix

* chore: onboarding API changes

* chore: integration tests added

* chore: added podcountsbyphase in other entities

* chore: added pod status counts for other entities

* chore: added integration tests for other entities

* chore: added checks api changes for other entities

* chore: rearrangement

* chore: removed succeeded status and mark it as completed

* chore: query beautified

* chore: corrected metrics list for metadata lookup

* chore: removed dead constants

* chore: goroutines for ListHosts

* chore: goroutines for ListPods

* chore: goroutines for ListNodes

* chore: goroutines for ListNamespaces

* chore: goroutines for ListClusters

* chore: goroutines for ListDeployments

* chore: goroutines for ListStatefulsets

* chore: added goroutines for ListStatefulsets, ListJobs and ListDaemonsets

* chore: added function

* chore: added struct changes

* chore: added count attr keys

* chore: wired counts to the response fields

* chore: regenerated API spec

* chore: merged main, resolved conflicts

* chore: nodes count surfacing

* chore: integration tests added

* fix: use tuple mapping to uniquely identify attrs:

* chore: integration tests update
2026-07-14 06:38:27 +00:00
Ashwin Bhatkal
8e4d7c68b3 feat(dashboard-v2): query-first DSL filter for the dashboards list (#12046)
* feat(dashboard-v2): stage-aware DSL autocomplete engine for the list filter

* feat(dashboard-v2): single query-string list-filter state with reflect/splice

* feat(dashboard-v2): drive saved/built-in views off the query string

* feat(dashboard-v2): CodeMirror query box with DSL autocomplete popup

* feat(dashboard-v2): antd Created-by/Updated filter selects, drop the Tags dropdown

* feat(dashboard-v2): wire the DSL filter into the dashboards list (sticky header, pagination)

* fix: small css elevation

* refactor(dashboard-v2): reuse shared operator constants in the DSL grammar

Build STRING_OPS / OPERATOR_MATRIX / VALUELESS_OPERATORS from the shared OPERATORS + negateOperator (constants/antlrQueryConstants) instead of hardcoded strings; drop a stale lint-disable in FilterChips. Addresses review comments.

* fix(dashboard-v2): wrap IN/NOT IN autocomplete values in a bracketed list

Picking a value for an IN/NOT IN clause in the DSL search box inserted a
bare literal ('x') instead of a list (['x']). The value stage now wraps a
fresh pick in a closed bracketed list, appends subsequent picks inside the
existing brackets (deduping already-entered values), and keeps the caret
inside the list so multi-select continues.

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-07-14 05:29:21 +00:00
Gaurav Tewari
2704317c6a feat(llm-pricing): add Unpriced Models tab with billing-model mapping [6/6] (#11813)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* feat(llm-pricing): add listing page and table

* chore(llm-pricing): drop search + source filters from list request

The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.

* refactor(llm-pricing): extract getRelativeTime helper in utils

Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.

* refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion

Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.

* refactor(llm-pricing): centralize constants and shared types

Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.

* refactor(llm-pricing): use nuqs for list pagination URL state

Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.

* refactor(llm-pricing): inline pagination, drop useModelPricingFilters

The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.

* feat(llm-pricing): disable currency selector (USD-only for now)

Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).

* refactor(llm-pricing): render model costs inside its tab + tab URL param

The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.

* style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules

The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.

* fix(llm-pricing): constrain currency dropdown width, drop tab URL param

- Currency SelectSimple stretched to fill the filters bar; give it a fixed
  160px width (min-width couldn't cap the trigger).
- Model costs is the only enabled tab for now, so use Tabs defaultValue
  instead of a URL-backed param. Removes the nuqs tab state plus the now-unused
  TAB_KEYS/TAB_QUERY_KEY constants and TabKey type.

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

* feat(llm-pricing): add model cost drawer and wire into listing page

* fix(llm-pricing): restrict pricing management to admins

Align the frontend write gate with the backend, which protects the
LLM pricing create/update/delete endpoints with AdminAccess (admin
only). Previously manage_llm_pricing allowed EDITOR/AUTHOR, so those
roles saw the Add/Save affordances but their writes were rejected with
a 403. Also removes the AUTHOR entry, which could never reach the page
(the route gate excludes it).

* fix(llm-pricing): read-only drawer shows View title, hides source picker

Non-managers open the drawer in view mode (write APIs are Admin-only), so:
- the heading reads "View model cost" instead of "Edit model cost"
- the Source (auto vs. override) picker is hidden, since switching source is
  a manager-only action with nothing actionable for a viewer.

* refactor: form in edit / add modal

* chore: update color tokens

* fix: add error handling

* chore: update more self review changes

* chore: self review changes

* chore: self review changes

* fix: minor grammer thing

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* feat: add search , dropdown and flag

* feat: feature flag on entire route and add mode costs tabs

* fix: add isFetchingFeatureFlags

* feat: llm pricing unpriced model

* chore: update flag

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* empty commit

* feat: add search to search model name

* chore: empty commit

* fix: add demo side nav on sidenav

* chore: remove demo side nav

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* refactor(llm-pricing): break model-cost drawer into per-component files + tokens

Apply the CSS-module/component conventions to the drawer that came from
drawer-3:
- Move the drawer under ModelCostTabPanel/components/ModelCostDrawer/ to mirror
  the ModelCostsTable structure
- Split the single 395-LOC ModelCostDrawer.module.scss into per-component
  co-located modules; cross-component selectors live in shared.module.scss and
  are pulled in via CSS-modules `composes`
- shared.module.scss is a composes target (parsed as plain CSS), so it is kept
  flat with block comments — no SCSS nesting or // comments
- Use --text-vanilla-* (not --bg-vanilla-*) for text colors, matching the
  listing code

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* refactor: css variables

* refactor: use signoz button and minor css update

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* refactor: side nav changes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: add enable check

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

* feat: move ui to easily accessable tabs

* feat: update test cases

* chore: update text

* chore: self review changes

* chore: self review refactor

* chore: self review changes

* feat(llm-pricing): batch-map unpriced models via top Save + confirm drawer

Replace the per-row "Map to model" button + inline confirm with a single
top-level Save button that opens a confirm drawer listing every pending
mapping in a table (model -> billing model + pricing).

- Batch the save: group selections by target rule so multiple models mapped
  to the same rule append their patterns in one PUT instead of clobbering
- Capped, skeleton-loaded unpriced table (max-height) to avoid layout shift,
  matching the model-costs table
- Restore the dropped .modelCellName style; drop dead inline-confirm styles
- Tests: batch/clobber hook test, tab integration flow, and fix the stale
  'unpriced tab disabled' assertion (the tab is now enabled)

* chore: remove worktree

* chore: revert env.ts

* chore: update llm pricing

* chore: update ui

* chore: sync unpriced modal

* chore(llm-pricing): address Copilot review on unpriced confirm table

- formatSpanCount: use Intl.NumberFormat compact so 999,999 renders as
  "1M" (not "1000.0K") and trailing ".0" is dropped
- confirm drawer extra-buckets container: non-wrapping inline-flex so
  buckets stay on one line at the fixed row height
- soften the misleading "renders identically" comment (extra buckets are
  plain inline text here, not the model-costs Badge chips)

* chore: flex wrap

* chore: self review changes

* feat: add support to remove model

* chore: update ux

* chore: update test cases

* chore: remove unused test cases

* chore: update tests

* chore: remove extra integration

* chore: refactor

* refactor: imports

* refactor: un priced modal

* chore: update unpriced modal

* chore: update map billing mode select

* refactor: code

* refactor: remove comments

* chore: make bg great again

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-14 04:43:28 +00:00
132 changed files with 4993 additions and 1601 deletions

View File

@@ -4259,6 +4259,34 @@ components:
type: number
clusterName:
type: string
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
namespaces:
format: int64
type: integer
nodes:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- nodes
- namespaces
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4279,6 +4307,7 @@ components:
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesClusters:
@@ -4800,6 +4829,26 @@ components:
type: object
InframonitoringtypesNamespaceRecord:
properties:
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4823,6 +4872,7 @@ components:
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesNamespaces:
@@ -7998,6 +8048,15 @@ components:
required:
- items
type: object
SpantypesGettableSpanMappers:
properties:
items:
items:
$ref: '#/components/schemas/SpantypesSpanMapper'
type: array
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -8150,7 +8209,7 @@ components:
type: boolean
fieldContext:
$ref: '#/components/schemas/SpantypesFieldContext'
group_id:
groupId:
type: string
id:
type: string
@@ -8163,7 +8222,7 @@ components:
type: string
required:
- id
- group_id
- groupId
- name
- fieldContext
- config
@@ -13742,7 +13801,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
status:
type: string
required:

View File

@@ -5669,6 +5669,39 @@ export interface InframonitoringtypesChecksDTO {
type: InframonitoringtypesCheckTypeDTO;
}
export type InframonitoringtypesClusterRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
namespaces: number;
/**
* @type integer
* @format int64
*/
nodes: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -5813,6 +5846,10 @@ export interface InframonitoringtypesClusterRecordDTO {
* @type string
*/
clusterName: string;
/**
* @type object
*/
counts: InframonitoringtypesClusterRecordDTOCounts;
/**
* @type object,null
*/
@@ -6368,6 +6405,29 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesNamespaceRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6379,6 +6439,10 @@ export type InframonitoringtypesNamespaceRecordDTOMeta =
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNamespaceRecordDTO {
/**
* @type object
*/
counts: InframonitoringtypesNamespaceRecordDTOCounts;
/**
* @type object,null
*/
@@ -9194,6 +9258,76 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
groupId: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesGettableSpanMappersDTO {
/**
* @type array
*/
items: SpantypesSpanMapperDTO[];
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -9440,30 +9574,6 @@ export interface SpantypesPostableFlamegraphDTO {
selectedSpanId?: string;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesPostableSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
@@ -9512,45 +9622,6 @@ export interface SpantypesPostableWaterfallDTO {
uncollapsedSpans?: string[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
group_id: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesUpdatableSpanMapperDTO {
config?: SpantypesSpanMapperConfigDTO;
/**
@@ -10852,7 +10923,7 @@ export type ListSpanMappersPathParameters = {
groupId: string;
};
export type ListSpanMappers200 = {
data: SpantypesGettableSpanMapperGroupsDTO;
data: SpantypesGettableSpanMappersDTO;
/**
* @type string
*/

View File

@@ -3,6 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
@@ -18,11 +19,13 @@ import './DownloadOptionsMenu.styles.scss';
interface DownloadOptionsMenuProps {
dataSource: DataSource;
selectedColumns?: TelemetryFieldKey[];
panelType?: PANEL_TYPES;
}
export default function DownloadOptionsMenu({
dataSource,
selectedColumns,
panelType,
}: DownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
@@ -33,6 +36,7 @@ export default function DownloadOptionsMenu({
const { isDownloading, handleExportRawData } = useExportRawData({
dataSource,
panelType,
});
const handleExport = useCallback(async (): Promise<void> => {

View File

@@ -7,13 +7,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup-guide button only exists in the classic form, which is reachable
// via the unadvertised showClassicCreateAlertsPage param.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
}),
}));
@@ -22,7 +20,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
hash: '',
state: null,
})),

View File

@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render new flow when ruleType is anomaly_rule', () => {
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -163,8 +163,8 @@ describe('CreateAlertRule', () => {
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {

View File

@@ -100,9 +100,10 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
// The classic experience is no longer offered in the UI; the query param
// is kept as an unadvertised escape hatch until the flow is removed.
if (showClassicCreateAlertsPageFlag) {
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}

View File

@@ -2,9 +2,7 @@ import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { FeatureKeys } from 'constants/features';
import { Activity, ChartLine } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { ChartLine } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
@@ -21,7 +19,6 @@ import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const { featureFlags } = useAppContext();
const {
data,
@@ -33,15 +30,9 @@ function AlertCondition(): JSX.Element {
});
const channels = data?.data || [];
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
// Anomaly alerts always show both tabs so existing rules stay editable;
// metric alerts only offer the anomaly tab when the feature is enabled.
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
alertType === AlertTypes.METRICS_BASED_ALERT;
const tabs = [
{
@@ -49,15 +40,16 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {

View File

@@ -188,7 +188,7 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{!notificationSettings.routingPolicies ? (
{notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"

View File

@@ -1,11 +1,7 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -100,23 +96,6 @@ const createTestQueryClient = (): QueryClient =>
},
});
const ANOMALY_DETECTION_FLAG: FeatureFlagProps = {
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
};
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockAppContext = (isAnomalyDetectionEnabled: boolean): void => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState(),
featureFlags: isAnomalyDetectionEnabled ? [ANOMALY_DETECTION_FLAG] : [],
});
};
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
@@ -134,10 +113,6 @@ const renderAlertCondition = (
};
describe('AlertCondition', () => {
beforeEach(() => {
mockAppContext(true);
});
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
@@ -150,9 +125,10 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -160,7 +136,8 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -175,27 +152,13 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('renders anomaly tab when alert type supports multiple tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('does not offer the anomaly tab when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition();
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
});
it('shows both tabs for anomaly alerts even when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition('ANOMALY_BASED_ALERT');
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
});
it('shows AlertThreshold component when alert type is not anomaly based', () => {
renderAlertCondition();
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
@@ -204,7 +167,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
@@ -215,7 +179,8 @@ describe('AlertCondition', () => {
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
it('switches between threshold and anomaly tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
@@ -240,7 +205,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('applies active tab styling correctly', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -261,10 +227,11 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// TODO: uncomment this when anomaly tab is implemented
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -272,8 +239,9 @@ describe('AlertCondition', () => {
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -1,7 +1,14 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RotateCcw } from '@signozhq/icons';
import { useAlertRuleOptional } from 'providers/Alert';
import { Labels } from 'types/api/alerts/def';
@@ -15,6 +22,8 @@ function CreateAlertHeader(): JSX.Element {
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
@@ -37,6 +46,14 @@ function CreateAlertHeader(): JSX.Element {
[groupByLabels],
);
const handleSwitchToClassicExperience = useCallback(() => {
logEvent('Alert: Switch to classic experience button clicked', {});
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
safeNavigate(url, { replace: true });
}, [safeNavigate, urlQuery]);
return (
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
@@ -44,6 +61,15 @@ function CreateAlertHeader(): JSX.Element {
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
<Button
prefix={<RotateCcw size={12} />}
onClick={handleSwitchToClassicExperience}
variant="solid"
color="secondary"
size="sm"
>
Switch to Classic Experience
</Button>
</div>
)}
<div className="alert-header__content">

View File

@@ -1,12 +1,20 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as rulesHook from '../../../../api/generated/services/rules';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
@@ -98,8 +106,34 @@ describe('CreateAlertHeader', () => {
).toHaveValue('TEST_ALERT');
});
it('should not render "switch to classic experience" button', () => {
it('should navigate to classic experience when button is clicked', () => {
renderCreateAlertHeader();
const switchToClassicExperienceButton = screen.getByText(
'Switch to Classic Experience',
);
expect(switchToClassicExperienceButton).toBeInTheDocument();
fireEvent.click(switchToClassicExperienceButton);
const params = new URLSearchParams();
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
expect(mockSafeNavigate).toHaveBeenCalledWith(
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
{ replace: true },
);
});
it('should not render "switch to classic experience" button when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
expect(
screen.queryByText('Switch to Classic Experience'),
).not.toBeInTheDocument();

View File

@@ -14,7 +14,10 @@ import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
import './styles.scss';
import {
@@ -82,7 +85,7 @@ function Footer(): JSX.Element {
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -119,7 +122,7 @@ function Footer(): JSX.Element {
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,

View File

@@ -18,8 +18,6 @@ import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateAlertRulePayload,
buildCreateAnomalyAlertRulePayload,
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
@@ -552,115 +550,4 @@ describe('Footer utils', () => {
},
);
});
describe('buildCreateAnomalyAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const ANOMALY_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: {
...mockCreateAlertContextState.thresholdState,
thresholds: [
{
...mockCreateAlertContextState.thresholdState.thresholds[0],
thresholdValue: 3,
},
],
},
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
};
it('builds a v2alpha1 anomaly rule payload', () => {
const props = buildCreateAnomalyAlertRulePayload(ANOMALY_PAYLOAD_ARGS);
expect(props.ruleType).toBe('anomaly_rule');
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.version).toBe('v5');
// The stored alertType is metric based; anomaly is a rule type
expect(props.alertType).toBe('METRIC_BASED_ALERT');
expect(props.condition.algorithm).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.algorithm,
);
expect(props.condition.seasonality).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.seasonality,
);
expect(props.condition.selectedQueryName).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.selectedQuery,
);
// Evaluation comes from the anomaly condition's own window
expect(props.evaluation).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: ANOMALY_PAYLOAD_ARGS.thresholdState.evaluationWindow,
frequency: '1m',
},
});
});
it('keeps the target positive for the above operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'above',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
expect(props.condition.thresholds?.spec[0].op).toBe('above');
});
it.each([['below'], ['2']])(
'negates the target for the below operator (%s)',
(op) => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: op,
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(-3);
expect(props.condition.thresholds?.spec[0].op).toBe(op);
},
);
it('keeps the target positive for the outside_bounds operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'outside_bounds',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
});
});
describe('buildCreateAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const args: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('builds an anomaly payload for anomaly based alerts', () => {
const props = buildCreateAlertRulePayload({
...args,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
});
expect(props.ruleType).toBe('anomaly_rule');
});
it('builds a threshold payload for other alert types', () => {
const props = buildCreateAlertRulePayload(args);
expect(props.ruleType).toBe('threshold_rule');
});
});
});

View File

@@ -2,7 +2,6 @@ import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
BasicThreshold,
PostableAlertRuleV2,
@@ -12,11 +11,9 @@ import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelo
import {
AdvancedOptionsState,
AlertThresholdOperator,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
import { normalizeOperator } from '../utils';
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
@@ -291,15 +288,16 @@ export function buildCreateThresholdAlertRulePayload(
}
// Build Create Anomaly Alert Rule Payload
// TODO: Update this function before enabling anomaly alert rule creation
export function buildCreateAnomalyAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
thresholdState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
@@ -315,55 +313,19 @@ export function buildCreateAnomalyAlertRulePayload(
unit: basicAlertState.yAxisUnit,
});
// v2alpha1 thresholds are literal: "3 deviations below the predicted data"
// means the anomaly z-score must drop under -3, so the target is negated
// for the below operator (the deviations input is always positive).
const isBelowOperator =
normalizeOperator(thresholdState.operator) ===
AlertThresholdOperator.IS_BELOW;
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
(threshold) => {
const deviations = Math.abs(parseFloat(threshold.thresholdValue.toString()));
return {
name: threshold.label,
target: isBelowOperator ? -deviations : deviations,
matchType: thresholdState.matchType,
op: thresholdState.operator,
channels: threshold.channels,
targetUnit: threshold.unit,
};
},
);
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps =
getEnforceMinimumDatapointsProps(advancedOptions);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps =
getNotificationSettingsProps(notificationSettings);
// The anomaly condition carries its own evaluation window
// ("during the last X"), so the evaluation is always a rolling window.
const frequency = getFormattedTimeValue(
advancedOptions.evaluationCadence.default.value,
advancedOptions.evaluationCadence.default.timeUnit,
);
return {
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertTypes.METRICS_BASED_ALERT
: alertType,
alertType,
condition: {
thresholds: {
kind: 'basic',
spec: thresholds,
},
compositeQuery,
selectedQueryName: thresholdState.selectedQuery,
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
@@ -373,25 +335,9 @@ export function buildCreateAnomalyAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: {
kind: 'rolling',
spec: {
evalWindow: thresholdState.evaluationWindow,
frequency,
},
},
version: 'v5',
schemaVersion: 'v2alpha1',
evaluation: evaluationProps,
version: '',
schemaVersion: '',
source: window?.location.toString(),
};
}
// Build the create/test alert rule payload for the selected alert type
export function buildCreateAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
if (args.alertType === AlertTypes.ANOMALY_BASED_ALERT) {
return buildCreateAnomalyAlertRulePayload(args);
}
return buildCreateThresholdAlertRulePayload(args);
}

View File

@@ -316,60 +316,6 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
const anomalyAlertDef: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'daily',
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: -3,
targetUnit: '',
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_BELOW,
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '1h0m0s',
frequency: '1m',
},
},
};
it('shows the absolute deviation value for negative anomaly targets', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.thresholds[0].thresholdValue).toBe(3);
expect(props.operator).toBe(AlertThresholdOperator.IS_BELOW);
});
it('hydrates the anomaly evaluation window, algorithm and seasonality', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.evaluationWindow).toBe('1h0m0s');
expect(props.algorithm).toBe('standard');
expect(props.seasonality).toBe('daily');
});
it('does not touch the target for non-anomaly rules', () => {
const props = getThresholdStateFromAlertDef({
...anomalyAlertDef,
ruleType: 'threshold_rule',
});
expect(props.thresholds[0].thresholdValue).toBe(-3);
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],

View File

@@ -239,39 +239,6 @@ describe('CreateAlertV2 Context Utils', () => {
});
});
it('should set evaluation window', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_EVALUATION_WINDOW',
payload: TimeDuration.ONE_HOUR,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
evaluationWindow: TimeDuration.ONE_HOUR,
});
});
it('should set algorithm', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_ALGORITHM',
payload: Algorithm.STANDARD,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
algorithm: Algorithm.STANDARD,
});
});
it('should set seasonality', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_SEASONALITY',
payload: Seasonality.WEEKLY,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
seasonality: Seasonality.WEEKLY,
});
});
it('should set thresholds', () => {
const newThresholds = [
{

View File

@@ -124,12 +124,6 @@ export const alertThresholdReducer = (
return { ...state, operator: action.payload };
case 'SET_MATCH_TYPE':
return { ...state, matchType: action.payload };
case 'SET_EVALUATION_WINDOW':
return { ...state, evaluationWindow: action.payload };
case 'SET_ALGORITHM':
return { ...state, algorithm: action.payload };
case 'SET_SEASONALITY':
return { ...state, seasonality: action.payload };
case 'SET_THRESHOLDS':
return { ...state, thresholds: action.payload };
case 'RESET':

View File

@@ -4,7 +4,6 @@ import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -304,19 +303,13 @@ export function normalizeMatchType(
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
// Anomaly targets are stored as literal z-scores (negative for the below
// operator); the deviations select always shows the positive value.
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: isAnomalyRule
? Math.abs(threshold.target)
: threshold.target,
thresholdValue: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
@@ -328,18 +321,6 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
...(isAnomalyRule
? {
evaluationWindow:
alertDef.evaluation?.spec?.evalWindow ||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality ||
INITIAL_ALERT_THRESHOLD_STATE.seasonality,
}
: {}),
};
}

View File

@@ -139,21 +139,31 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.currentNodes,
width: { min: 180 },
width: { min: 210 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
<GroupedStatusCounts
items={[
{
value: row.readyNodes,
label: 'Ready',
color: Color.BG_FOREST_500,
},
{
value: row.currentNodes,
label: 'Current',
color: Color.BG_FOREST_500,
color: Color.BG_ROBIN_500,
},
{
value: row.desiredNodes,
label: 'Desired',
color: Color.BG_ROBIN_500,
color: Color.BG_SAKURA_400,
},
{
value: row.misscheduledNodes,
label: 'Misscheduled',
color: Color.BG_AMBER_500,
},
]}
/>
@@ -312,6 +322,30 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'ready_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
Ready Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.readyNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
>
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'current_nodes',
header: (): React.ReactNode => (
@@ -360,4 +394,28 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'misscheduled_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
Misscheduled Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.misscheduledNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"
>
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -30,7 +30,7 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
return {
id: 'mapper-1',
group_id: 'group-1',
groupId: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
@@ -89,5 +89,5 @@ export const mockGroups: MapperGroup[] = [
];
export const mockMappers: Mapper[] = [
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
makeMapper({ id: 'mapper-1', groupId: 'group-1' }),
];

View File

@@ -3,3 +3,13 @@
flex-direction: column;
gap: var(--spacing-8);
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tabBadge {
margin: 0;
}

View File

@@ -1,30 +1,58 @@
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from '@signozhq/ui/tabs';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import ModelCostTabPanel from './ModelCostTabPanel';
import { MODEL_COSTS_TAB, TAB_KEY, UNPRICED_MODELS_TAB } from './constants';
import styles from './LLMObservabilityModelPricing.module.scss';
import ModelCostTabPanel from './ModelCostTabPanel';
import UnpricedModelsTab from './UnpricedModelsTab';
function LLMObservabilityModelPricing(): JSX.Element {
const [activeTab, setActiveTab] = useQueryState(
TAB_KEY,
parseAsStringEnum([MODEL_COSTS_TAB, UNPRICED_MODELS_TAB]).withDefault(
MODEL_COSTS_TAB,
),
);
// Count powers the tab badge; deduped with the tab's own fetch by react-query.
const { data } = useListUnmappedLLMModels();
const unpricedCount = data?.data?.items?.length ?? 0;
return (
<div
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands in a later PR.
defaultValue="model-costs"
value={activeTab}
onChange={(key): void => {
void setActiveTab(key as typeof activeTab);
}}
items={[
{
key: 'model-costs',
key: MODEL_COSTS_TAB,
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
key: UNPRICED_MODELS_TAB,
label: (
<span className={styles.tabLabel}>
Unpriced models
{unpricedCount > 0 && (
<Badge
variant="default"
className={styles.tabBadge}
data-testid="unpriced-models-count"
>
{unpricedCount}
</Badge>
)}
</span>
),
children: <UnpricedModelsTab />,
},
]}
/>

View File

@@ -3,6 +3,7 @@ import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
@@ -10,9 +11,16 @@ import {
EMPTY_DRAFT,
TOAST_MODEL_COST_ADDED,
TOAST_MODEL_COST_UPDATED,
} from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type {
DrawerDraft,
DrawerMode,
PricingRule,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
buildRulePayload,
draftFromRule,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
@@ -38,17 +46,26 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
// Adding pricing can also resolve a model that was showing up as unpriced, so
// refresh the unmapped list (and its tab-badge count) alongside the rules list.
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
]);
}, [queryClient]);
const openForAdd = useCallback((): void => {
// prefillModelName seeds the billing model ID when adding from an unpriced
// model row, so the user only has to fill in pricing.
const openForAdd = useCallback((prefillModelName?: string): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: '',
modelName: prefillModelName ?? '',
patterns: [],
});
setSelectedRuleId(null);

View File

@@ -25,7 +25,6 @@
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}

View File

@@ -0,0 +1,27 @@
.unpricedModelsTab {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
}
.bannerIcon {
flex-shrink: 0;
margin-top: var(--spacing-1);
}
.error {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
}

View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { TriangleAlert } from '@signozhq/icons';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import styles from './UnpricedModelsTab.module.scss';
import ModelCostDrawer, {
useModelCostDrawer,
} from '../ModelCostTabPanel/components/ModelCostDrawer';
import type { PricingRule, UnpricedModel } from '../types';
import MapConfirmDialog from './components/MapConfirmDialog';
import type { UnpricedColumnsConfig } from './components/UnpricedModelsTable/TableConfig';
import UnpricedModelsTable from './components/UnpricedModelsTable';
import { useUnpricedModelMapping } from './hooks/useUnpricedModelMapping';
import { usePendingMappingStore } from './usePendingMappingStore';
function UnpricedModelsTab(): JSX.Element {
const { data, isLoading, isError } = useListUnmappedLLMModels();
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const models: UnpricedModel[] = useMemo(() => data?.data?.items || [], [data]);
// Picking a billing model stages a single mapping for confirmation; the
// mapping only commits once the user confirms in the dialog. Kept in a store
// (not local state) so the row's memoized select trigger can mirror the pick —
// see usePendingMappingStore.
const pendingMapping = usePendingMappingStore((state) => state.pending);
const setPending = usePendingMappingStore((state) => state.setPending);
const clearPending = usePendingMappingStore((state) => state.clearPending);
const { mapModel, isSaving } = useUnpricedModelMapping();
// Reset any staged mapping when leaving the tab so a stale pick doesn't reopen
// the dialog on remount (the store outlives this component).
useEffect(() => (): void => clearPending(), [clearPending]);
// Reuses the "Model costs" add/edit drawer to define brand-new pricing for a
// model that has no matching billing model to map onto. Saving resolves the
// model, so it drops off this tab (the drawer invalidates the unmapped list).
const drawer = useModelCostDrawer();
const onRequestMap = useCallback(
(model: UnpricedModel, rule: PricingRule): void => {
setPending({ model, rule });
},
[setPending],
);
const onConfirmMap = useCallback(async (): Promise<void> => {
if (!pendingMapping) {
return;
}
const didSave = await mapModel(pendingMapping);
if (didSave) {
clearPending();
}
}, [mapModel, pendingMapping, clearPending]);
// openForAdd is stable (useCallback with no deps); drawer itself is a fresh
// object each render, so depend on the method, not the object.
const { openForAdd } = drawer;
const onCreateNew = useCallback(
(modelName: string): void => openForAdd(modelName),
[openForAdd],
);
const columnsConfig = useMemo<UnpricedColumnsConfig>(
() => ({
canManage: canManagePricing,
onRequestMap,
onCreateNew,
}),
[canManagePricing, onRequestMap, onCreateNew],
);
return (
<div className={styles.unpricedModelsTab}>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Typography.Text>
</div>
{isError && (
<div className={styles.error}>
<Typography.Text as="p" size="small" color="danger" role="alert">
Failed to load unpriced models. Please try again.
</Typography.Text>
</div>
)}
<UnpricedModelsTable
models={models}
isLoading={isLoading}
columnsConfig={columnsConfig}
/>
{pendingMapping && (
<MapConfirmDialog
open
model={pendingMapping.model}
rule={pendingMapping.rule}
isSaving={isSaving}
onConfirm={onConfirmMap}
onCancel={clearPending}
/>
)}
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
initialDraft={drawer.initialDraft}
onClose={drawer.close}
onSave={drawer.save}
isSaving={drawer.isSaving}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
)}
</div>
);
}
export default UnpricedModelsTab;

View File

@@ -0,0 +1,180 @@
import type { LlmpricingruletypesUpdatableLLMPricingRulesDTO } from 'api/generated/services/sigNoz.schemas';
import {
LLM_PRICING_ENDPOINT,
LLM_UNMAPPED_ENDPOINT,
makeListResponse,
makeUnmappedResponse,
mockRules,
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import UnpricedModelsTab from '../UnpricedModelsTab';
const toastSuccess = jest.fn();
const toastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): void => toastSuccess(...args),
error: (...args: unknown[]): void => toastError(...args),
},
}));
const MODEL = 'gpt-4o-mini-2024-07-18';
function setup(): void {
server.use(
rest.get(LLM_UNMAPPED_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeUnmappedResponse())),
),
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(mockRules))),
),
);
}
// Picks a billing model in a row's dropdown: open the combobox, then click the
// option. The dropdown fetches its options from the rules list (mocked above).
async function selectRule(
user: ReturnType<typeof userEvent.setup>,
modelName: string,
ruleId: string,
): Promise<void> {
await user.click(screen.getByTestId(`map-to-select-${modelName}`));
await user.click(await screen.findByTestId(`map-to-option-${ruleId}`));
}
describe('UnpricedModelsTab (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setup();
});
afterEach(() => {
server.resetHandlers();
toastSuccess.mockClear();
toastError.mockClear();
});
it('opens the confirm dialog with the target rule pricing when a model is picked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Picking a rule stages the mapping in the confirm dialog.
await selectRule(user, MODEL, 'rule-openai');
const confirmItem = await screen.findByTestId(
`unpriced-map-confirm-item-${MODEL}`,
);
expect(confirmItem).toBeInTheDocument();
// Shows the target billing model + its pricing so the user can eyeball it.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
expect(screen.getByText('$3.00')).toBeInTheDocument();
expect(screen.getByText('$9.00')).toBeInTheDocument();
// While the dialog is open, the row's trigger mirrors the staged pick
// instead of the placeholder.
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
).toBeInTheDocument();
});
it('reverts the row trigger to the placeholder when the mapping is cancelled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
expect(
within(screen.getByTestId(`map-to-select-${MODEL}`)).getByText(
'openai:gpt-4o ($3.00/$9.00)',
),
).toBeInTheDocument();
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() => {
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
});
});
it('commits the mapping in one request when confirmed', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-confirm-btn'));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].rules).toHaveLength(1);
expect(sent[0].rules?.[0].modelPattern).toContain(MODEL);
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith('Mapped model'),
);
// Dialog closes on success.
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
});
it('cancels the mapping without committing', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
expect(sent).toHaveLength(0);
});
it('opens the add-cost drawer prefilled when creating pricing for a model', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Open the row's dropdown and take the "Create pricing for …" escape hatch
// instead of mapping onto an existing billing model.
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));
// The shared add-cost drawer opens with the model name prefilled.
const drawerTitle = await screen.findByText('Add model cost');
expect(drawerTitle).toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toHaveValue(MODEL);
});
});

View File

@@ -0,0 +1,40 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.mapping {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
.arrow {
flex-shrink: 0;
opacity: 0.6;
}
.pricing {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid var(--l2-border);
background: var(--l3-background);
}
.pricingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,124 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight, Check, Link2, X } from '@signozhq/icons';
import { startCase } from 'lodash-es';
import styles from './MapConfirmDialog.module.scss';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
interface MapConfirmDialogProps {
open: boolean;
model: UnpricedModel;
rule: PricingRule;
isSaving: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Per-row confirm step before mapping an unpriced model onto an existing billing
// model. Mapping appends the span's model name as a match pattern on the chosen
// rule, so the model inherits that rule's pricing — shown here so the user can
// eyeball the rates before committing. Dismissible (outside click / close), since
// mapping is reversible (re-map or edit the rule's patterns afterwards).
function MapConfirmDialog({
open,
model,
rule,
isSaving,
onConfirm,
onCancel,
}: MapConfirmDialogProps): JSX.Element {
const extraBuckets = getExtraBuckets(rule);
const pricingRows = [
{ key: 'input', label: 'Input / 1M', value: rule.pricing?.input },
{ key: 'output', label: 'Output / 1M', value: rule.pricing?.output },
...extraBuckets.map((bucket) => ({
key: bucket.key,
label: startCase(bucket.key),
value: bucket.pricePerMillion,
})),
];
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onCancel}
disabled={isSaving}
prefix={<X size={12} />}
testId="unpriced-map-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
onClick={onConfirm}
prefix={<Check size={12} />}
testId="unpriced-map-confirm-btn"
>
Map model
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="base"
title="Map to billing model"
titleIcon={<Link2 size={16} />}
footer={footer}
testId="unpriced-map-confirm-dialog"
>
<div className={styles.body}>
<Typography.Text as="p" size="small" color="muted">
Spans from this model will be priced using the selected billing model.
</Typography.Text>
<div className={styles.mapping}>
<Typography.Text
weight="semibold"
testId={`unpriced-map-confirm-item-${model.modelName}`}
>
{model.modelName}
</Typography.Text>
<ArrowRight size={14} className={styles.arrow} />
<Typography.Text weight="semibold">{getCanonicalId(rule)}</Typography.Text>
</div>
<div className={styles.pricing}>
{pricingRows.map((row) => (
<div className={styles.pricingRow} key={row.key}>
<Typography.Text as="span" size="small" color="muted">
{row.label}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(row.value)}
</Typography.Text>
</div>
))}
</div>
</div>
</DialogWrapper>
);
}
export default MapConfirmDialog;

View File

@@ -0,0 +1 @@
export { default } from './MapConfirmDialog';

View File

@@ -0,0 +1,27 @@
.mapToCell {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
}
.mapToSelect {
width: 100%;
max-width: 280px;
}
.mapToDropdown {
width: 280px;
}
.skeletonList {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
}
.skeletonRow {
display: block;
width: 100%;
}

View File

@@ -0,0 +1,138 @@
import { useState } from 'react';
import {
Combobox,
ComboboxCommand,
ComboboxContent,
ComboboxCreateItem,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Plus } from '@signozhq/icons';
import { Skeleton } from 'antd';
import styles from './MapToBillingModelSelect.module.scss';
import { RULE_OPTIONS_LIMIT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
import { getRuleOptionLabel } from 'container/LLMObservability/Settings/ModelPricing/utils';
import { usePendingMappingLabel } from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/usePendingMappingStore';
import { useMapToBillingModelSearch } from './useMapToBillingModelSearch';
// One placeholder row per fetched option, so the skeleton height matches the
// loaded list. Stable keys derived from the fetch limit.
const SKELETON_ROW_KEYS = Array.from(
{ length: RULE_OPTIONS_LIMIT },
(_, index) => `skeleton-${index}`,
);
interface MapToBillingModelSelectProps {
modelName: string;
disabled: boolean;
onSelect: (rule: PricingRule) => void;
onCreateNew: () => void;
}
// Searchable, server-paged dropdown for picking the billing model an unpriced
// model maps onto. Only RULE_OPTIONS_LIMIT rules are fetched at a time; typing
// narrows the set via the rules API rather than client-side filtering, so cmdk's
// own filter is disabled (shouldFilter={false}). The dropdown is a pure picker —
// choosing a rule hands it up to the confirm dialog rather than persisting a
// selection here. The trigger only mirrors the staged pick (read from the
// pending-mapping store) while that dialog is open, reverting on confirm/cancel.
function MapToBillingModelSelect({
modelName,
disabled,
onSelect,
onCreateNew,
}: MapToBillingModelSelectProps): JSX.Element {
const [open, setOpen] = useState(false);
const { searchText, setSearchText, rules, rulesById, isFetching } =
useMapToBillingModelSearch(open);
const selectedLabel = usePendingMappingLabel(modelName);
const handleSelect = (ruleId: string): void => {
const rule = rulesById.get(ruleId);
if (rule) {
onSelect(rule);
}
setOpen(false);
};
const handleCreateNew = (): void => {
setOpen(false);
onCreateNew();
};
return (
<div className={styles.mapToCell}>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
className={styles.mapToSelect}
disabled={disabled}
placeholder="Select / Create a pricing model"
value={selectedLabel}
testId={`map-to-select-${modelName}`}
/>
<ComboboxContent className={styles.mapToDropdown}>
<ComboboxCommand shouldFilter={false}>
<ComboboxInput
value={searchText}
onValueChange={setSearchText}
placeholder="Search billing models"
testId={`map-to-search-${modelName}`}
/>
<ComboboxList>
{rules.map((rule) => (
<ComboboxItem
key={rule.id}
value={rule.id}
onSelect={(): void => handleSelect(rule.id)}
data-testid={`map-to-option-${rule.id}`}
>
{getRuleOptionLabel(rule)}
</ComboboxItem>
))}
{isFetching && (
<div
className={styles.skeletonList}
data-testid={`map-to-loading-${modelName}`}
>
{SKELETON_ROW_KEYS.map((key) => (
<Skeleton.Input
key={key}
active
block
size="small"
className={styles.skeletonRow}
/>
))}
</div>
)}
{!isFetching && rules.length === 0 && (
<ComboboxEmpty>No billing models found</ComboboxEmpty>
)}
</ComboboxList>
{/* Kept outside ComboboxList so it stays pinned as a footer while the
options scroll. Escape hatch when no existing billing model fits:
define this model's own pricing rather than mapping onto another. */}
<ComboboxSeparator alwaysRender />
<ComboboxCreateItem
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create pricing for &quot;{modelName}&quot;
</ComboboxCreateItem>
</ComboboxCommand>
</ComboboxContent>
</Combobox>
</div>
);
}
export default MapToBillingModelSelect;

View File

@@ -0,0 +1 @@
export { default } from './MapToBillingModelSelect';

View File

@@ -0,0 +1,44 @@
import { useMemo, useState } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useDebounce from 'hooks/useDebounce';
import {
RULE_OPTIONS_LIMIT,
SEARCH_DEBOUNCE_MS,
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
interface UseMapToBillingModelSearch {
searchText: string;
setSearchText: (value: string) => void;
rules: PricingRule[];
// Fetched rules keyed by id, so a pick can resolve to its full rule object.
rulesById: Map<string, PricingRule>;
isFetching: boolean;
}
// Server-side search for the per-row "Map to billing model" dropdown. Only
// RULE_OPTIONS_LIMIT rules are fetched at a time; typing narrows the set via the
// rules API's `q` param instead of filtering client-side. The fetch is gated on
// `enabled` (the dropdown's open state) so closed rows don't fetch, and
// react-query dedupes identical query keys so rows sharing a term hit the network
// once.
export function useMapToBillingModelSearch(
enabled: boolean,
): UseMapToBillingModelSearch {
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
const { data, isFetching } = useListLLMPricingRules(
{ offset: 0, limit: RULE_OPTIONS_LIMIT, q: debouncedSearch || undefined },
{ query: { enabled } },
);
const rules = useMemo<PricingRule[]>(() => data?.data?.items || [], [data]);
const rulesById = useMemo(
() => new Map(rules.map((rule) => [rule.id, rule])),
[rules],
);
return { searchText, setSearchText, rules, rulesById, isFetching };
}

View File

@@ -0,0 +1,2 @@
export { getUnpricedModelsColumns } from './table.config';
export type { UnpricedColumnsConfig } from './table.config';

View File

@@ -0,0 +1,88 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import MapToBillingModelSelect from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/components/MapToBillingModelSelect';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { formatSpanCount } from 'container/LLMObservability/Settings/ModelPricing/utils';
import styles from './tableConfig.module.scss';
export interface UnpricedColumnsConfig {
canManage: boolean;
// Picking a billing model requests a mapping — the tab opens a confirm dialog
// and commits that single mapping there (no batch/selection state here).
onRequestMap: (model: UnpricedModel, rule: PricingRule) => void;
// Opens the add-cost drawer prefilled with the row's model name.
onCreateNew: (modelName: string) => void;
}
// Column definitions for the unpriced-models TanStackTable. Sorting is off — the
// unmapped-models endpoint returns the full set in one shot with no ordering knob.
// Each row's action is self-contained: pick a billing model to map onto (confirmed
// in a dialog) or create pricing for the model, so there's no bulk-save column.
export function getUnpricedModelsColumns({
canManage,
onRequestMap,
onCreateNew,
}: UnpricedColumnsConfig): TableColumnDef<UnpricedModel>[] {
return [
{
id: 'model',
header: 'Model (from spans)',
accessorFn: (row): string => row.modelName,
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text
weight="semibold"
truncate={1}
testId={`unpriced-model-name-${row.modelName}`}
>
{row.modelName}
</Typography.Text>
),
},
{
id: 'provider',
header: 'Provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider || 'Unknown',
},
{
id: 'spans',
header: 'Spans',
width: { min: 100 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color="cherry"
variant="outline"
className={styles.spansBadge}
data-testid={`unpriced-spans-${row.modelName}`}
>
{formatSpanCount(row.spanCount)}
</Badge>
),
},
{
id: 'mapTo',
header: 'Map to billing model',
width: { min: 280, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<MapToBillingModelSelect
modelName={row.modelName}
disabled={!canManage}
onSelect={(rule): void => onRequestMap(row, rule)}
onCreateNew={(): void => onCreateNew(row.modelName)}
/>
),
},
];
}

View File

@@ -0,0 +1,4 @@
.spansBadge {
margin: 0;
font-family: var(--code-font-family, monospace);
}

View File

@@ -0,0 +1,22 @@
// Capped scroll viewport — mirrors ModelCostsTable so the unpriced tab doesn't
// shift on load, but uses max-height so a short list stays compact instead of
// reserving the full viewport. The extra offset over the model-costs table
// accounts for the banner toolbar above.
.unpricedModelsTable {
--tanstack-table-row-height: 48px;
max-height: calc(100vh - 220px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.unpricedModelsEmpty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { SKELETON_ROW_COUNT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { UnpricedModel } from 'container/LLMObservability/Settings/ModelPricing/types';
import styles from './UnpricedModelsTable.module.scss';
import {
getUnpricedModelsColumns,
type UnpricedColumnsConfig,
} from './TableConfig';
interface UnpricedModelsTableProps {
models: UnpricedModel[];
isLoading: boolean;
columnsConfig: UnpricedColumnsConfig;
}
// The unmapped-models endpoint returns the full set in one response, so there's
// no pagination here — just a content-height list. Virtual scroll is disabled
// because the set is small and bounded.
function UnpricedModelsTable({
models,
isLoading,
columnsConfig,
}: UnpricedModelsTableProps): JSX.Element {
const columns = useMemo(
() => getUnpricedModelsColumns(columnsConfig),
[columnsConfig],
);
if (!isLoading && models.length === 0) {
return (
<div
className={styles.unpricedModelsEmpty}
data-testid="unpriced-models-empty"
>
All models in your traces are priced.
</div>
);
}
return (
<TanStackTable<UnpricedModel>
className={styles.unpricedModelsTable}
data={models}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.modelName}
disableVirtualScroll
testId="unpriced-models-table"
/>
);
}
export default UnpricedModelsTable;

View File

@@ -0,0 +1 @@
export { default } from './UnpricedModelsTable';

View File

@@ -0,0 +1,70 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { buildPatternMappingPayload } from 'container/LLMObservability/Settings/ModelPricing/utils';
// A single row's choice: map this unpriced model onto this billing rule.
export interface UnpricedModelMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface UseUnpricedModelMappingResult {
// Commits a single mapping in one request. Resolves true on success so the
// caller can close the confirm dialog and clear its staged pick.
mapModel: (mapping: UnpricedModelMapping) => Promise<boolean>;
// True while the save is in flight, so the confirm button can spin.
isSaving: boolean;
}
// Maps an unpriced model onto an existing pricing rule. There's no dedicated
// "edit" endpoint — mapping reuses CreateOrUpdate (PUT), appending the model name
// as a match pattern on the chosen rule so the model inherits its pricing. Both
// the unmapped list and the rules list are invalidated on success so the mapped
// model drops out of this tab immediately.
export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
const queryClient = useQueryClient();
const [isSaving, setIsSaving] = useState(false);
const { mutateAsync: createOrUpdate } = useCreateOrUpdateLLMPricingRules();
const mapModel = useCallback(
async ({ model, rule }: UnpricedModelMapping): Promise<boolean> => {
const payload = buildPatternMappingPayload(rule, model.modelName);
setIsSaving(true);
try {
await createOrUpdate({ data: { rules: [payload] } });
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
]);
toast.success('Mapped model');
return true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Mapping failed';
toast.error(message);
return false;
} finally {
setIsSaving(false);
}
},
[createOrUpdate, queryClient],
);
return { mapModel, isSaving };
}

View File

@@ -0,0 +1 @@
export { default } from './UnpricedModelsTab';

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand';
import type { PricingRule, UnpricedModel } from '../types';
import { getRuleOptionLabel } from '../utils';
// A model + the billing rule it's about to be mapped onto, held while the confirm
// dialog is open.
export interface PendingMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface PendingMappingState {
pending: PendingMapping | null;
setPending: (mapping: PendingMapping) => void;
clearPending: () => void;
}
// The staged mapping lives in a store (not component state/props) because the
// row's select trigger, which mirrors the staged pick, sits inside a memoized
// TanStackTable cell whose value is cached per row — a prop/context change would
// not re-render it, but a store subscription does. Picking a billing model stages
// a single mapping for confirmation; it only commits once confirmed in the dialog.
export const usePendingMappingStore = create<PendingMappingState>((set) => ({
pending: null,
setPending: (mapping): void => set({ pending: mapping }),
clearPending: (): void => set({ pending: null }),
}));
// Label to show on the given row's select trigger while its mapping is staged, or
// undefined when nothing is staged for that row (falls back to the placeholder).
export const usePendingMappingLabel = (modelName: string): string | undefined =>
usePendingMappingStore((state) => {
const { pending } = state;
if (!pending || pending.model.modelName !== modelName) {
return undefined;
}
return getRuleOptionLabel(pending.rule);
});

View File

@@ -2,14 +2,19 @@ import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type ListLLMPricingRules200,
type ListUnmappedLLMModels200,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from '../types';
import type { PricingRule, UnpricedModel } from '../types';
// Endpoint glob used by MSW handlers. The generated client hits a relative
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
// Distinct path (extra segment), so it needs its own handler — the list glob
// above does not match it.
export const LLM_UNMAPPED_ENDPOINT =
'*/api/v1/llm_pricing_rules/unmapped_models';
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
// replaced wholesale when provided so callers can shape cache buckets freely.
@@ -60,6 +65,26 @@ export const mockRules: PricingRule[] = [
}),
];
// Unpriced models seen in traces with no matching pricing rule.
export const mockUnpricedModels: UnpricedModel[] = [
{ modelName: 'gpt-4o-mini-2024-07-18', provider: 'openai', spanCount: 18400 },
{
modelName: 'claude-3-7-sonnet-20250219',
provider: 'anthropic',
spanCount: 9200,
},
];
// Wraps unpriced models in the envelope the unmapped-models query reads.
export function makeUnmappedResponse(
items: UnpricedModel[] = mockUnpricedModels,
): ListUnmappedLLMModels200 {
return {
status: 'success',
data: { items },
};
}
// Wraps items in the list response envelope the list query reads
// (`data.data.items` / `data.data.total`).
export function makeListResponse(

View File

@@ -34,6 +34,13 @@ export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const RULE_OPTIONS_LIMIT = 10;
// URL-backed key for the active tab on the model-pricing page.
export const TAB_KEY = 'tab';
export const MODEL_COSTS_TAB = 'model-costs';
export const UNPRICED_MODELS_TAB = 'unpriced-models';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
@@ -62,6 +69,7 @@ export const EMPTY_DRAFT: DrawerDraft = {
provider: 'OpenAI',
patterns: [],
isOverride: true,
enabled: true,
pricing: {
input: null,
output: null,

View File

@@ -1,10 +1,14 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
type LlmpricingruletypesUnmappedModelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
// A model seen in trace data (gen_ai.request.model) that no pricing rule matches.
export type UnpricedModel = LlmpricingruletypesUnmappedModelDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
@@ -29,6 +33,7 @@ export interface DrawerDraft {
provider: string;
patterns: string[];
isOverride: boolean;
enabled: boolean;
pricing: {
input: number | null;
output: number | null;

View File

@@ -86,6 +86,7 @@ export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
enabled: rule.enabled,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
@@ -129,7 +130,7 @@ export const buildRulePayload = (
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: true,
enabled: draft.enabled,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
@@ -161,3 +162,38 @@ export const validatePricing = (
}
return true;
};
const spanCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
});
export const formatSpanCount = (count: number): string =>
spanCountFormatter.format(count);
// Label for the "Map to billing model" dropdown, e.g. "openai:gpt-4o ($15.00/$60.00)".
export const getRuleOptionLabel = (rule: PricingRule): string =>
`${getCanonicalId(rule)} (${formatPricePerMillion(
rule.pricing?.input,
)}/${formatPricePerMillion(rule.pricing?.output)})`;
export const buildPatternMappingPayload = (
rule: PricingRule,
modelName: string,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => {
const existing = rule.modelPattern ?? [];
const modelPattern = existing.includes(modelName)
? existing
: [...existing, modelName];
return {
id: rule.id,
sourceId: rule.sourceId,
modelName: rule.modelName,
provider: rule.provider,
modelPattern,
isOverride: rule.isOverride,
enabled: rule.enabled,
unit: rule.unit,
pricing: rule.pricing,
};
};

View File

@@ -191,14 +191,6 @@
min-height: 0;
overflow-y: visible;
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
.time-series-view {
flex-shrink: 0;
height: 65vh;

View File

@@ -32,7 +32,6 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -461,18 +460,17 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
/>
</div>
)}

View File

@@ -311,6 +311,7 @@ function TimeSeries({
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -3,6 +3,14 @@
min-height: 350px;
padding: 0px 12px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.ant-card-body {
height: 50vh;
min-height: 350px;

View File

@@ -16,6 +16,7 @@ import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
@@ -41,11 +42,14 @@ import { SuccessResponse, Warning } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import './TimeSeriesView.styles.scss';
function TimeSeriesView({
@@ -59,6 +63,8 @@ function TimeSeriesView({
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -244,10 +250,33 @@ function TimeSeriesView({
[baseChartOptions, stackedBands],
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
)}
<div
className="graph-container"
style={{ height: '100%', width: '100%' }}
@@ -295,7 +324,11 @@ function TimeSeriesView({
}
interface TimeSeriesViewProps {
data?: SuccessResponse<MetricRangePayloadProps> & { warning?: Warning };
data?: SuccessResponse<MetricRangePayloadProps> & {
warning?: Warning;
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
yAxisUnit?: string;
isLoading: boolean;
isError: boolean;
@@ -305,6 +338,11 @@ interface TimeSeriesViewProps {
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
}
TimeSeriesView.defaultProps = {

View File

@@ -0,0 +1,33 @@
.timeseries-export-popover {
width: 240px;
padding: 0 12px 12px 12px;
.title {
display: flex;
color: var(--l1-foreground);
font-family: Inter;
font-size: var(--periscope-font-size-small);
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format {
padding: 12px 4px;
display: flex;
flex-direction: column;
// radio option labels — radix popover content inherits the root font
// size; pin to the app's 13px base the antd popover used to impose
label {
font-size: var(--periscope-font-size-base);
}
}
.export-button {
width: 100%;
}
}

View File

@@ -0,0 +1,91 @@
import { Download } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
dataSource,
queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
});
const handleExport = useCallback((): void => {
setIsPopoverOpen(false);
handleClientExport({ format: exportFormat as ExportFormat });
}, [exportFormat, handleClientExport]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipSimple title="Download">
<PopoverTrigger asChild>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Download"
data-testid={`timeseries-export-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
<Download size={14} />
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="timeseries-export-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
</RadioGroup>
</div>
<Button
variant="solid"
color="primary"
className="export-button"
onClick={handleExport}
disabled={isExporting}
loading={isExporting}
prefix={<Download size={16} />}
>
Export
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,138 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import store from 'store';
import { DataSource } from 'types/common/queryBuilder';
import TimeSeriesView from '../TimeSeriesView';
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));
jest.mock('container/QueryBuilder/filters/BuilderUnitsFilter', () => ({
BuilderUnitsFilter: (): JSX.Element => (
<div data-testid="builder-units-filter" />
),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: null }),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: (): unknown => ({}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: (): number[][] => [
[1, 2],
[3, 4],
],
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({ stackSeries: (): unknown => ({ data: [], bands: [] }) }),
);
jest.mock('container/GridCardLayout/GridCard/utils', () => ({
getLocalStorageGraphVisibilityState: (): unknown => ({
graphVisibilityStates: [],
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): unknown => ({ timezone: { value: 'UTC' } }),
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): unknown => ({ width: 800, height: 400 }),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockStore = configureStore([])({ ...store.getState() });
const rawV5Response = {
type: 'time_series',
data: { results: [] },
meta: {},
};
function makeData(withRawV5: boolean): any {
return {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
...(withRawV5 ? { rawV5Response, legendMap: {} } : {}),
};
}
function renderView(props: {
allowExport?: boolean;
withRawV5?: boolean;
onYAxisUnitChange?: (value: string) => void;
}): ReturnType<typeof render> {
const { allowExport, withRawV5 = true, onYAxisUnitChange } = props;
return render(
<Provider store={mockStore}>
<MemoryRouter>
<TimeSeriesView
isLoading={false}
isError={false}
isFilterApplied
dataSource={DataSource.LOGS}
data={makeData(withRawV5)}
allowExport={allowExport}
onYAxisUnitChange={onYAxisUnitChange}
/>
</MemoryRouter>
</Provider>,
);
}
describe('TimeSeriesView header gating', () => {
it('renders the export menu when allowExport is set and raw V5 data is present', () => {
const { queryByTestId } = renderView({ allowExport: true });
expect(queryByTestId('timeseries-export-menu')).toBeInTheDocument();
});
it('renders no export menu without allowExport', () => {
const { queryByTestId } = renderView({});
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders no export menu when the raw V5 response is missing', () => {
const { queryByTestId } = renderView({ allowExport: true, withRawV5: false });
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders the unit selector only when onYAxisUnitChange is passed', () => {
const withUnit = renderView({ onYAxisUnitChange: jest.fn() });
expect(withUnit.queryByTestId('builder-units-filter')).toBeInTheDocument();
withUnit.unmount();
const withoutUnit = renderView({ allowExport: true });
expect(
withoutUnit.queryByTestId('builder-units-filter'),
).not.toBeInTheDocument();
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
});
});

View File

@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
jest.mock('hooks/useExportData/useClientExport', () => ({
useClientExport: (): unknown => ({
isExporting: mockIsExporting,
handleExport: mockHandleExport,
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;
});
it('renders the download trigger button', () => {
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
});
it('shows only format options — no shape, row-count, or column controls', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
});
it('exports as csv by default', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
});
it('exports as jsonl when selected', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
});
});

View File

@@ -1,10 +1,41 @@
import { SuccessResponse } from 'types/api/index';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
type ConvertibleData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
};
// Applies the same ns→ms conversion to the raw V5 tree, so client-side export
// serializes the values the chart displays (not the original nanoseconds).
function convertRawV5ValuesToMs(
response: QueryRangeResponseV5,
): QueryRangeResponseV5 {
if (response.type !== 'time_series') {
return response;
}
const results = (response.data.results as TimeSeriesData[]).map((result) => ({
...result,
aggregations: (result.aggregations ?? []).map((bucket) => ({
...bucket,
series: (bucket.series ?? []).map((series) => ({
...series,
values: (series.values ?? []).map((value) => ({
...value,
value: value.value / 1000000,
})),
})),
})),
}));
return { ...response, data: { ...response.data, results } };
}
export const convertDataValueToMs = (
data?: SuccessResponse<MetricRangePayloadProps>,
): SuccessResponse<MetricRangePayloadProps> | undefined => {
data?: ConvertibleData,
): ConvertibleData | undefined => {
const convertedData = data;
const convertedResult: QueryData[] = data?.payload?.data?.result
@@ -22,5 +53,11 @@ export const convertDataValueToMs = (
convertedData.payload.data.result = convertedResult;
}
if (convertedData?.rawV5Response) {
convertedData.rawV5Response = convertRawV5ValuesToMs(
convertedData.rawV5Response,
);
}
return convertedData;
};

View File

@@ -11,6 +11,7 @@ import {
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -152,6 +153,11 @@ function TracesView({
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}

View File

@@ -19,6 +19,7 @@ interface ExportOptions {
interface UseExportRawDataProps {
dataSource: DataSource;
panelType?: PANEL_TYPES;
}
interface UseExportRawDataReturn {
@@ -28,6 +29,7 @@ interface UseExportRawDataReturn {
export function useExportRawData({
dataSource,
panelType = PANEL_TYPES.LIST,
}: UseExportRawDataProps): UseExportRawDataReturn {
const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -83,7 +85,7 @@ export function useExportRawData({
const { queryPayload } = prepareQueryRangePayloadV5({
query: exportQuery,
graphType: PANEL_TYPES.LIST,
graphType: panelType,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
});
@@ -96,7 +98,7 @@ export function useExportRawData({
setIsDownloading(false);
}
},
[stagedQuery, globalSelectedInterval, dataSource],
[stagedQuery, globalSelectedInterval, dataSource, panelType],
);
return { isDownloading, handleExportRawData };

View File

@@ -4,6 +4,8 @@ export function useIntersectionObserver<T extends HTMLElement>(
ref: RefObject<T>,
options?: IntersectionObserverInit,
isObserverOnce?: boolean,
/** Defer observation by this many ms to let a transient mount layout settle. */
startDelayMs = 0,
): boolean {
const [isIntersecting, setIntersecting] = useState(false);
@@ -23,16 +25,28 @@ export function useIntersectionObserver<T extends HTMLElement>(
}
}, options);
if (currentReference) {
observer.observe(currentReference);
const startObserving = (): void => {
if (currentReference) {
observer.observe(currentReference);
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
if (startDelayMs > 0) {
timer = setTimeout(startObserving, startDelayMs);
} else {
startObserving();
}
return (): void => {
if (timer) {
clearTimeout(timer);
}
if (currentReference) {
observer.unobserve(currentReference);
}
};
}, [ref, options, isObserverOnce]);
}, [ref, options, isObserverOnce, startDelayMs]);
return isIntersecting;
}

View File

@@ -22,7 +22,11 @@ import { SuccessResponseV2, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { ExecStats, MetricRangePayloadV5 } from 'types/api/v5/queryRange';
import {
ExecStats,
MetricRangePayloadV5,
QueryRangeResponseV5,
} from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -192,6 +196,8 @@ export async function GetMetricQueryRange(
| SuccessResponseV2<MetricRangePayloadV5>;
let warning: Warning | undefined;
let meta: ExecStats | undefined;
// Raw V5 response, kept before it's converted to legacy — powers client-side export.
let rawV5Response: QueryRangeResponseV5 | undefined;
const panelType = props.originalGraphType || props.graphType;
@@ -268,6 +274,8 @@ export async function GetMetricQueryRange(
endTime: props.end * 1000,
});
rawV5Response = publicResponse.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -288,6 +296,8 @@ export async function GetMetricQueryRange(
headers,
);
rawV5Response = v5Response.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -366,6 +376,8 @@ export async function GetMetricQueryRange(
...response,
warning,
meta,
rawV5Response,
legendMap,
};
}

View File

@@ -81,6 +81,18 @@ describe('exportTimeseriesData', () => {
]);
});
it('omits display-only format ids (short/none) from headers', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
];
const short = exportTimeseriesData({ data, yAxisUnit: 'short' });
expect(short.headers[short.headers.length - 1]).toBe('value');
const none = exportTimeseriesData({ data, yAxisUnit: 'none' });
expect(none.headers[none.headers.length - 1]).toBe('value');
});
it('multi-query: query is its own column; label keys are unioned', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),

View File

@@ -98,9 +98,16 @@ function flatten(
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
}
function toIso(timestamp: number): string {

View File

@@ -11,7 +11,6 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
@@ -86,18 +85,11 @@ function AlertDetails(): JSX.Element {
return <Spinner />;
}
// Anomaly rules are stored with a metric alertType; the editor's alert
// type is derived from the ruleType so the anomaly condition is shown.
const initialAlertType =
alertRuleDetails?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? AlertTypes.ANOMALY_BASED_ALERT
: (alertRuleDetails?.alertType as AlertTypes);
return (
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={initialAlertType}
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertState={initialAlertState}
>
<div

View File

@@ -92,6 +92,7 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}

View File

@@ -23,6 +23,8 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Panel not yet scrolled into view — its fetch is deferred, so show the loader rather than NoData. */
isVisible?: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
@@ -54,6 +56,7 @@ function PanelBody({
panelId,
data,
isFetching,
isVisible,
isPreviousData,
error,
refetch,
@@ -105,9 +108,9 @@ function PanelBody({
);
}
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
if (isFetching && !hasData) {
// Full-panel loader on first fetch or while the fetch is deferred (off-screen); a refetch
// over existing data keeps the renderer mounted, empty data loads via NoData.
if ((isFetching || isVisible === false) && !hasData) {
return <PanelLoader />;
}

View File

@@ -79,6 +79,21 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('shows the loader while the fetch is deferred (panel not yet scrolled into view)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching={false}
isVisible={false}
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody

View File

@@ -113,8 +113,8 @@ function ViewPanelModalHeader({
/>
<Button
size="icon"
variant="solid"
color="primary"
variant="outlined"
color="secondary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"

View File

@@ -22,6 +22,7 @@ function SectionGrid({
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({

View File

@@ -10,6 +10,10 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
// Start observing after RGL's mount unfold settles, so a panel that only
// transiently overlaps the viewport during layout doesn't fire a throwaway fetch.
const OBSERVER_START_DELAY_MS = 350;
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -17,9 +21,9 @@ interface SectionGridItemProps {
}
/**
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
* Lazy-loads a single panel: tracks its live viewport intersection and passes it to
* the presentational Panel as `isVisible`, so a board of many panels only fetches
* (and refetches on time change / auto-refresh) what's on screen.
*/
function SectionGridItem({
panel,
@@ -30,7 +34,10 @@ function SectionGridItem({
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
true,
// Not once: track the live viewport so a time change / auto-refresh only
// refetches on-screen panels (off-screen ones stay query-disabled).
false,
OBSERVER_START_DELAY_MS,
);
useScrollIntoView(panelId, containerRef);

View File

@@ -23,7 +23,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
block: 'center',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
@@ -38,7 +38,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
block: 'center',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});

View File

@@ -9,7 +9,7 @@ import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
export function useScrollIntoView(
id: string,
ref: RefObject<HTMLElement>,
block: ScrollLogicalPosition = 'start',
block: ScrollLogicalPosition = 'center',
): void {
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);

View File

@@ -57,5 +57,8 @@ export function useGetQueryRangeV5({
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
// A resolved window is immutable per key, so a panel scrolled back into view
// serves cache instead of refetching; a key change or manual refetch still runs.
staleTime: Infinity,
});
}

View File

@@ -24,6 +24,12 @@
}
.headerZone {
// Sticks to the top of the scroll area so the heading + filters stay put while
// the results scroll beneath. Its own stacking context sits above the rows; the
// DSL suggestion popup (z-index 1000) still layers above everything.
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: 14px;
@@ -50,7 +56,6 @@
:global(.ant-table-row:last-child)
:global(.ant-table-cell)
> div {
border-radius: 0 0 6px 6px;
}
:global(.ant-pagination-item) {

View File

@@ -10,7 +10,10 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { toAPIError } from 'utils/errorUtils';
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
import {
type TagPair,
useAccumulatedTags,
} from '../../hooks/useAccumulatedTags';
import { useActiveView } from '../../hooks/useActiveView';
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
@@ -22,7 +25,7 @@ import {
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
import { BuiltinViewId } from '../../types';
import type { SelectedTag, UpdatedWindow } from '../../types';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import type { DashboardListItem } from '../../utils/helpers';
import { applyClientView } from '../../utils/views';
import FilterZone from '../FilterZone/FilterZone';
@@ -50,17 +53,7 @@ function DashboardsList(): JSX.Element {
);
const canEdit = !!editDashboard;
const {
filters,
query,
isEmpty: filtersEmpty,
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
} = useDashboardFilters();
const { query, isEmpty: filtersEmpty, setQuery } = useDashboardFilters();
const [sortColumn, setSortColumn] = useSortColumn();
const [sortOrder, setSortOrder] = useSortOrder();
const [page, setPage] = usePage();
@@ -80,8 +73,8 @@ function DashboardsList(): JSX.Element {
removeView,
renameView,
} = useActiveView({
filters,
applyFilters,
query,
setQuery,
userEmail: user.email,
sortColumn,
sortOrder,
@@ -95,38 +88,13 @@ function DashboardsList(): JSX.Element {
// Any filter change resets to the first page so the user isn't stranded on a
// now-out-of-range offset.
const handleSearchChange = useCallback(
const handleQueryChange = useCallback(
(value: string): void => {
setSearch(value);
setQuery(value);
void setPage(1);
},
[setSearch, setPage],
[setQuery, setPage],
);
const handleCreatedByChange = useCallback(
(emails: string[]): void => {
setCreatedBy(emails);
void setPage(1);
},
[setCreatedBy, setPage],
);
const handleUpdatedChange = useCallback(
(window: UpdatedWindow): void => {
setUpdated(window);
void setPage(1);
},
[setUpdated, setPage],
);
const handleTagsChange = useCallback(
(tags: SelectedTag[]): void => {
setTags(tags);
void setPage(1);
},
[setTags, setPage],
);
const handleClearAll = useCallback((): void => {
clearAll();
void setPage(1);
}, [clearAll, setPage]);
// View actions that change the result set reset pagination too.
const handleSelectView = useCallback(
@@ -195,6 +163,16 @@ function DashboardsList(): JSX.Element {
);
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
// Step back a page when a delete empties the current one, instead of showing nothing.
useEffect(() => {
if (clientView || isFetching) {
return;
}
if (page > 1 && dashboards.length === 0) {
void setPage(page - 1);
}
}, [clientView, isFetching, dashboards.length, page, setPage]);
// Authors present on the loaded page — a fallback for the creator filter until
// the org-wide user list resolves.
const pageAuthorEmails = useMemo<string[]>(
@@ -210,15 +188,34 @@ function DashboardsList(): JSX.Element {
});
// All key:value tags the API reports for the org's dashboards, powering the
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
// previously-seen tags stay selectable even when a filtered page omits them.
const responseTags = useMemo<SelectedTag[]>(
// DSL key/value autocomplete. Accumulated across refetches so previously-seen
// tags stay suggestable even when a filtered page omits them.
const responseTags = useMemo<TagPair[]>(
() =>
(response?.data?.tags ?? []).map((t) => ({ key: t.key, value: t.value })),
[response],
);
const availableTags = useAccumulatedTags(responseTags);
// Autocomplete data source: reserved keys from the response, tag keys/values
// accumulated across pages, and creator emails for `created_by` values.
const source = useMemo<SuggestionSource>(() => {
const tagValuesByKey: Record<string, string[]> = {};
const tagKeys = new Set<string>();
availableTags.forEach((t) => {
tagKeys.add(t.key);
const lower = t.key.toLowerCase();
(tagValuesByKey[lower] ??= []).push(t.value);
});
return {
reservedKeys: response?.data?.reservedKeywords,
tagKeys: Array.from(tagKeys),
tagValuesByKey,
creatorEmails: creatorOptions.map((o) => o.email),
currentUserEmail: user.email,
};
}, [availableTags, creatorOptions, response, user.email]);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const visibleColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
@@ -306,18 +303,10 @@ function DashboardsList(): JSX.Element {
onCreate={openCreate}
/>
<FilterZone
search={filters.search}
createdBy={filters.createdBy}
updated={filters.updated}
tags={filters.tags}
availableTags={availableTags}
query={query}
creatorOptions={creatorOptions}
isEmpty={filtersEmpty}
onSearchChange={handleSearchChange}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onTagsChange={handleTagsChange}
onClearAll={handleClearAll}
source={source}
onQueryChange={handleQueryChange}
/>
</div>
<div className={styles.viewContent}>
@@ -332,7 +321,7 @@ function DashboardsList(): JSX.Element {
errorMessage={errorMessage}
dashboards={dashboards}
activeViewId={activeViewId}
searchValue={filters.search}
searchValue={query}
hasFilters={!filtersEmpty}
sortColumn={sortColumn}
onSortChange={onSortChange}

View File

@@ -1,7 +1,4 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { CalendarClock, ChevronDown, User } from '@signozhq/icons';
import { Checkbox, Select } from 'antd';
import cx from 'classnames';
import type { UpdatedWindow } from '../../types';
@@ -21,6 +18,10 @@ const UPDATED_LABELS: Record<UpdatedWindow, string> = {
};
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
value: w,
label: UPDATED_LABELS[w],
}));
interface Props {
createdBy: string[];
@@ -28,6 +29,10 @@ interface Props {
creatorOptions: CreatorOption[];
onCreatedByChange: (emails: string[]) => void;
onUpdatedChange: (window: UpdatedWindow) => void;
// Run the staged draft — fired when a dropdown closes.
onApply: () => void;
// Clear all Created-by selections and run immediately.
onClearCreatedBy: () => void;
}
function FilterChips({
@@ -36,92 +41,57 @@ function FilterChips({
creatorOptions,
onCreatedByChange,
onUpdatedChange,
onApply,
onClearCreatedBy,
}: Props): JSX.Element {
const createdByLabel = useMemo((): string => {
if (createdBy.length === 0) {
return 'Anyone';
}
if (createdBy.length === 1) {
const match = creatorOptions.find((o) => o.email === createdBy[0]);
return match?.label ?? createdBy[0];
}
return `${createdBy.length} people`;
}, [createdBy, creatorOptions]);
const creatorOptionsData = creatorOptions.map((o) => ({
value: o.email,
label: o.label,
}));
const createdByItems = useMemo<MenuItem[]>(() => {
const items: MenuItem[] = creatorOptions.map((option) => ({
type: 'checkbox',
key: option.email,
label: option.label,
checked: createdBy.includes(option.email),
onCheckedChange: (checked: boolean): void =>
onCreatedByChange(
checked
? [...createdBy, option.email]
: createdBy.filter((e) => e !== option.email),
),
}));
if (createdBy.length > 0) {
items.push({ type: 'divider', key: 'sep' });
items.push({
key: 'clear',
label: 'Clear selection',
onClick: (): void => onCreatedByChange([]),
});
const runOnClose = (open: boolean): void => {
if (!open) {
onApply();
}
return items;
}, [creatorOptions, createdBy, onCreatedByChange]);
const updatedItems = useMemo<MenuItem[]>(
() => [
{
type: 'radio-group',
value: updated,
onChange: (value: string): void => onUpdatedChange(value as UpdatedWindow),
children: UPDATED_WINDOWS.map((window) => ({
type: 'radio',
key: window,
value: window,
label: UPDATED_LABELS[window],
})),
},
],
[updated, onUpdatedChange],
);
};
return (
<div className={styles.chips}>
<DropdownMenuSimple menu={{ items: createdByItems }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<User size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, {
[styles.chipActive]: createdBy.length > 0,
})}
testId="dashboards-filter-created-by"
>
Created by: {createdByLabel}
</Button>
</DropdownMenuSimple>
<DropdownMenuSimple menu={{ items: updatedItems }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<CalendarClock size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, {
[styles.chipActive]: updated !== 'any',
})}
testId="dashboards-filter-updated"
>
Updated: {UPDATED_LABELS[updated]}
</Button>
</DropdownMenuSimple>
<Select
mode="multiple"
showSearch
allowClear
className={cx(styles.select, styles.selectWide)}
placeholder="Created by"
value={createdBy}
options={creatorOptionsData}
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid="dashboards-filter-created-by"
onClear={onClearCreatedBy}
optionRender={(option): JSX.Element => (
<div className={styles.creatorOption}>
<Checkbox
checked={createdBy.includes(option.value as string)}
className={styles.creatorCheck}
/>
<span className={styles.creatorLabel}>{option.label}</span>
</div>
)}
onChange={(value): void => onCreatedByChange(value as string[])}
onDropdownVisibleChange={runOnClose}
/>
<Select
showSearch
className={cx(styles.select, styles.selectNarrow)}
placeholder="Updated"
value={updated}
options={UPDATED_OPTIONS}
optionFilterProp="label"
data-testid="dashboards-filter-updated"
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
/>
</div>
);
}

View File

@@ -37,14 +37,62 @@
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
// Keep the two dropdowns side by side; each is fixed-width so pills can't
// grow the trigger and wrap them onto separate lines.
flex-wrap: nowrap;
}
.chip {
font-size: var(--font-size-sm);
.select {
flex: none;
// Match the search bar: near-black background, --l2-border border.
:global(.ant-select-selector) {
background-color: var(--l1-background) !important;
border-color: var(--l2-border) !important;
}
}
.chipActive {
border-color: var(--primary-background) !important;
color: var(--l1-foreground) !important;
// The single-select (Updated) is compact; the multi-select (Created-by) is wider
// to fit a truncated pill + "+N" + clear.
.selectNarrow {
width: 140px;
}
.selectWide {
width: 240px;
// Keep the pills on one line and let the first pill shrink + ellipsis to fill
// the available width (antd sets `flex: none` on tags, so allow the pill to
// shrink; keep the "+N" count and the search area intact).
:global(.ant-select-selection-overflow) {
flex-wrap: nowrap;
}
:global(.ant-select-selection-overflow-item) {
flex-shrink: 1;
min-width: 0;
}
:global(.ant-select-selection-overflow-item-rest),
:global(.ant-select-selection-overflow-item-suffix) {
flex-shrink: 0;
}
}
// Created-by option: a presentational checkbox + label (antd handles the toggle).
.creatorOption {
display: flex;
align-items: center;
gap: 8px;
}
.creatorCheck {
flex-shrink: 0;
pointer-events: none;
}
.creatorLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -9,79 +9,108 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import { buildSuggestionKeys } from '../../utils/dslSuggestions';
import type { SelectedTag, UpdatedWindow } from '../../types';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import {
createdByClause,
parseReflectedClauses,
spliceClause,
updatedClause,
} from '../../utils/filterQuery';
import type { UpdatedWindow } from '../../types';
import SearchBar from '../SearchBar/SearchBar';
import FilterChips, { type CreatorOption } from './FilterChips';
import TagsFilterChip from './TagsFilterChip';
import styles from './FilterZone.module.scss';
interface Props {
search: string;
createdBy: string[];
updated: UpdatedWindow;
tags: SelectedTag[];
availableTags: SelectedTag[];
// The last-run query (source of truth for fetching + the dirty baseline).
query: string;
creatorOptions: CreatorOption[];
isEmpty: boolean;
onSearchChange: (value: string) => void;
onCreatedByChange: (emails: string[]) => void;
onUpdatedChange: (window: UpdatedWindow) => void;
onTagsChange: (tags: SelectedTag[]) => void;
onClearAll: () => void;
source: SuggestionSource;
// Commit (run) the draft — the only path that triggers a fetch.
onQueryChange: (value: string) => void;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
}
// The filter command zone: name search + structured chips (created-by, updated)
// + clear-all. Search is committed on submit/blur (matching the prior bar);
// chips apply immediately.
// The filter command zone. The query box is a DRAFT: typing and the Created-by /
// Updated dropdowns all edit the draft (dropdowns splice their clause in); nothing
// fetches until the draft is run (Cmd/Ctrl+Enter or the Run button). A dirty dot
// signals unrun changes. The draft re-syncs when the last-run query changes
// externally (view select, back/forward).
function FilterZone({
search,
createdBy,
updated,
tags,
availableTags,
query,
creatorOptions,
isEmpty,
onSearchChange,
onCreatedByChange,
onUpdatedChange,
onTagsChange,
onClearAll,
source,
onQueryChange,
rightSlot,
}: Props): JSX.Element {
const [searchInput, setSearchInput] = useState(search);
const [draft, setDraft] = useState(query);
const suggestionKeys = useMemo(
() => buildSuggestionKeys(availableTags),
[availableTags],
useEffect(() => {
setDraft(query);
}, [query]);
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
const dirty = draft.trim() !== query.trim();
const isEmpty = !draft.trim();
const run = useCallback((): void => {
const next = draft.trim();
if (next !== query) {
onQueryChange(next);
}
}, [draft, query, onQueryChange]);
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
// when the dropdown closes (FilterChips fires onApply), not on each pick.
const handleCreatedByChange = useCallback((emails: string[]): void => {
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
}, []);
// Updated (single-select) is a definitive single choice, so it splices AND runs
// immediately (with the fresh value, avoiding the async draft-state lag).
const handleUpdatedChange = useCallback(
(window: UpdatedWindow): void => {
const next = spliceClause(draft, 'updated_at', updatedClause(window));
setDraft(next);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
},
[draft, query, onQueryChange],
);
// Keep the local input in sync with external search changes (applying a view,
// clear-all, back/forward). User typing only mutates the local copy.
useEffect(() => {
setSearchInput(search);
}, [search]);
const handleSubmit = useCallback((): void => {
const next = searchInput.trim();
if (next !== search) {
onSearchChange(next);
// Clear-all on the Created-by select removes its clause and runs immediately.
const handleClearCreatedBy = useCallback((): void => {
const next = spliceClause(draft, 'created_by', null);
setDraft(next);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
}, [searchInput, search, onSearchChange]);
}, [draft, query, onQueryChange]);
// Clear resets the draft and runs immediately (empty query).
const handleClear = useCallback((): void => {
setDraft('');
if (query !== '') {
onQueryChange('');
}
}, [query, onQueryChange]);
return (
<div className={styles.filterZone}>
<div className={styles.searchRow}>
<div className={styles.searchInput}>
<SearchBar
value={searchInput}
placeholder={`Search with DSL — e.g. name contains "prod" AND env = "staging"`}
suggestionKeys={suggestionKeys}
onChange={setSearchInput}
onSubmit={handleSubmit}
value={draft}
placeholder="DSL Filter — e.g. name CONTAINS 'api' AND env IN ['prod','staging']"
source={source}
dirty={dirty}
onChange={setDraft}
onSubmit={run}
/>
</div>
{rightSlot}
@@ -89,16 +118,13 @@ function FilterZone({
<div className={styles.filtersRow}>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
<FilterChips
createdBy={createdBy}
updated={updated}
createdBy={reflected.createdBy}
updated={reflected.updated}
creatorOptions={creatorOptions}
onCreatedByChange={onCreatedByChange}
onUpdatedChange={onUpdatedChange}
/>
<TagsFilterChip
availableTags={availableTags}
tags={tags}
onTagsChange={onTagsChange}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onApply={run}
onClearCreatedBy={handleClearCreatedBy}
/>
{!isEmpty && (
<Button
@@ -106,7 +132,7 @@ function FilterZone({
color="primary"
size="sm"
prefix={<X size={12} />}
onClick={onClearAll}
onClick={handleClear}
testId="dashboards-filter-clear"
>
Clear

View File

@@ -1,80 +0,0 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { ChevronDown, Tag } from '@signozhq/icons';
import cx from 'classnames';
import type { SelectedTag } from '../../types';
import styles from './FilterZone.module.scss';
interface Props {
// All key:value tags the list API reports across the org's dashboards.
availableTags: SelectedTag[];
tags: SelectedTag[];
onTagsChange: (tags: SelectedTag[]) => void;
}
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
function TagsFilterChip({
availableTags,
tags,
onTagsChange,
}: Props): JSX.Element {
const selectedIds = useMemo(() => new Set(tags.map(tagId)), [tags]);
const label = useMemo((): string => {
if (tags.length === 0) {
return 'Any';
}
if (tags.length === 1) {
return tagId(tags[0]);
}
return `${tags.length} tags`;
}, [tags]);
const items = useMemo<MenuItem[]>(() => {
const options: MenuItem[] = availableTags.map((tag) => {
const id = tagId(tag);
return {
type: 'checkbox',
key: id,
label: id,
checked: selectedIds.has(id),
onCheckedChange: (checked: boolean): void =>
onTagsChange(
checked ? [...tags, tag] : tags.filter((t) => tagId(t) !== id),
),
};
});
if (tags.length > 0) {
options.push({ type: 'divider', key: 'sep' });
options.push({
key: 'clear',
label: 'Clear selection',
onClick: (): void => onTagsChange([]),
});
}
return options;
}, [availableTags, selectedIds, tags, onTagsChange]);
return (
<DropdownMenuSimple menu={{ items }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<Tag size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, { [styles.chipActive]: tags.length > 0 })}
disabled={availableTags.length === 0}
testId="dashboards-filter-tags"
>
Tags: {label}
</Button>
</DropdownMenuSimple>
);
}
export default TagsFilterChip;

View File

@@ -5,8 +5,7 @@
padding: 16px;
height: 44px;
flex-shrink: 0;
border-radius: 6px 6px 0px 0px;
border: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
background: var(--l1-background);
}

View File

@@ -1,70 +1,203 @@
.wrapper {
position: relative;
width: 100%;
// --- CodeMirror internals, scoped to this component ---
:global(.cm-editor) {
background: transparent;
// The completion popup is an absolute child; keep it from being clipped.
overflow: visible;
}
// The query wraps (EditorView.lineWrapping), so no horizontal scrolling.
:global(.cm-scroller) {
overflow-x: hidden;
}
// DSL token colours.
:global(.cm-dsl-key) {
color: var(--bg-robin-400);
}
:global(.cm-dsl-op) {
color: var(--bg-vanilla-400);
}
:global(.cm-dsl-logic) {
color: var(--bg-sakura-400);
font-weight: var(--font-weight-medium);
}
:global(.cm-dsl-string) {
color: var(--bg-forest-400);
}
:global(.cm-dsl-value) {
color: var(--bg-amber-400);
}
// Completion popup — pinned to the input box (absolute, left:0/right:0) like the
// query builder's where-clause editor, not caret-anchored. A "SUGGESTIONS"
// header, a scrolling option list, and a keyboard-hint footer.
:global(.cm-tooltip.cm-tooltip-autocomplete) {
position: absolute !important;
top: calc(100% + 6px) !important;
left: 0 !important;
width: 100% !important;
z-index: 1000;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
/* stylelint-disable-next-line local/prefer-css-variables -- dropdown shadow */
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
&::before {
content: 'SUGGESTIONS';
padding: 12px 14px 8px;
font-size: 11px;
font-weight: var(--font-weight-medium);
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
color: var(--muted-foreground);
}
&::after {
content: '↑↓ to navigate · ↵ to apply · esc to dismiss';
padding: 10px 14px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
& > ul {
flex: 1 1 auto;
// min-height:0 lets the list scroll inside the flex column instead of
// overflowing past the footer; width 100% so the scrollbar sits at the
// popup's right edge (CM otherwise caps the list width, leaving a gap).
min-height: 0;
width: 100% !important;
max-width: 100% !important;
max-height: 46vh;
margin: 0;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background: var(--l3-background);
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
& > ul > li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
color: var(--l2-foreground);
line-height: 20px;
&[aria-selected='true'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
:global(.cm-tooltip-autocomplete .cm-completionLabel) {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Right-aligned "field" / "tag" hint, as a pill (matches the QB type tags).
:global(.cm-tooltip-autocomplete .cm-completionDetail) {
flex-shrink: 0;
padding: 0 8px;
border-radius: 50px;
background: color-mix(in srgb, var(--l1-foreground) 10%, transparent);
font-style: normal;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
text-transform: capitalize;
}
:global(.cm-completionIcon) {
display: none;
}
}
.input::placeholder {
color: var(--l3-foreground);
}
// Flatten the input's bottom corners while the dropdown is attached below it.
.inputOpen {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 260px;
overflow-y: auto;
padding: 4px;
// Input-like shell. The lead icon and Run button overlay the editor (absolute) so
// the editor — and its full-width completion popup — spans the whole box.
.field {
position: relative;
border: 1px solid var(--l2-border);
border-top: none;
border-radius: 0 0 6px 6px;
border-radius: 6px;
background: var(--l1-background);
/* stylelint-disable-next-line local/prefer-css-variables -- matches the V2 dashboard dropdown shadow */
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
&:focus-within {
border-color: var(--bg-robin-500);
}
}
.suggestion {
display: flex;
align-items: center;
padding: 8px 10px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
font-family: 'Space Mono', monospace;
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
transition: background 0.1s;
.leadIcon {
position: absolute;
top: 11px;
left: 10px;
z-index: 1;
}
.suggestionActive {
background: var(--l2-background);
color: var(--l1-foreground);
.editor {
width: 100%;
}
.submit {
position: absolute;
top: 5px;
right: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 6px;
// Bordered light pill so "Run query ⌘↵" reads as an actionable button, not stray
// text.
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--bg-vanilla-400);
}
// Unrun-changes indicator next to "Run query".
.dirtyDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
}
// Keycap-styled shortcut so ⌘↵ reads as "press this", not decoration.
.cmdHint {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
gap: 3px;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
color: var(--bg-vanilla-400);
}

View File

@@ -1,22 +1,24 @@
import { type MouseEvent, useMemo, useRef } from 'react';
import {
ChangeEvent,
KeyboardEvent,
MouseEvent,
useMemo,
useState,
} from 'react';
autocompletion,
closeCompletion,
completionKeymap,
startCompletion,
} from '@codemirror/autocomplete';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { ChevronUp, Command, CornerDownLeft, Search } from '@signozhq/icons';
import cx from 'classnames';
import CodeMirror, {
EditorView,
keymap,
Prec,
type ReactCodeMirrorRef,
} from '@uiw/react-codemirror';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import {
applyKeySuggestion,
getActiveKeyToken,
matchKeys,
} from '../../utils/dslSuggestions';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import { dslCompletionSource } from './dslCompletions';
import { dslHighlight } from './dslHighlight';
import styles from './SearchBar.module.scss';
@@ -25,139 +27,160 @@ interface Props {
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
// Keys offered as you type (reserved DSL columns + tag keys from the API).
suggestionKeys?: string[];
source?: SuggestionSource;
// The draft differs from the last-run query — shows a "run to apply" hint.
dirty?: boolean;
}
const EMPTY_SOURCE: SuggestionSource = {
tagKeys: [],
tagValuesByKey: {},
creatorEmails: [],
};
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
// handles layout — transparent background, monospace, single line.
const editorTheme = EditorView.theme({
'&': { fontSize: '13px', backgroundColor: 'transparent' },
'&.cm-focused': { outline: 'none' },
'.cm-content': {
// Leave room for the overlaid lead icon (left) and Run button (right).
padding: '8px 120px 8px 30px',
fontFamily: "'Space Mono', monospace",
color: 'var(--l1-foreground)',
caretColor: 'var(--l1-foreground)',
},
'.cm-line': { padding: '0' },
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
'.cm-placeholder': { color: 'var(--l3-foreground)' },
});
const BASIC_SETUP = {
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
highlightSelectionMatches: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
// styling and doesn't blink by default).
drawSelection: false,
} as const;
function SearchBar({
value,
onChange,
onSubmit,
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
suggestionKeys = [],
placeholder = "Filter with DSL (e.g. name CONTAINS 'foo')",
source = EMPTY_SOURCE,
dirty = false,
}: Props): JSX.Element {
const [focused, setFocused] = useState(false);
// -1 means nothing is highlighted, so Enter submits the typed query rather
// than picking a suggestion (arrow keys engage selection).
const [highlighted, setHighlighted] = useState(-1);
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const editorRef = useRef<ReactCodeMirrorRef>(null);
const active = useMemo(() => getActiveKeyToken(value), [value]);
const suggestions = useMemo(
() => (active ? matchKeys(suggestionKeys, active.token) : []),
[active, suggestionKeys],
// Refs so the (memoised, stable) extensions always see the latest values.
const sourceRef = useRef(source);
sourceRef.current = source;
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const extensions = useMemo(
() => [
dslHighlight,
editorTheme,
// Wrap a long query onto the next line instead of scrolling horizontally.
EditorView.lineWrapping,
autocompletion({
override: [dslCompletionSource(() => sourceRef.current)],
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 100,
icons: false,
}),
// Open the suggestions on focus/click (not only while typing), so a click
// into the box immediately shows the key list.
EditorView.domEventHandlers({
focus: (_event, view): boolean => {
startCompletion(view);
return false;
},
click: (_event, view): boolean => {
startCompletion(view);
return false;
},
}),
Prec.highest(
keymap.of([
...completionKeymap,
{ key: 'Escape', run: closeCompletion },
// Enter accepts an open suggestion (via completionKeymap above); with
// none open it is a no-op — it never runs the query or inserts a newline.
{ key: 'Enter', preventDefault: true, run: (): boolean => true },
{ key: 'Shift-Enter', preventDefault: true, run: (): boolean => true },
{
key: 'Mod-Enter',
preventDefault: true,
run: (): boolean => {
onSubmitRef.current();
return true;
},
},
]),
),
],
[],
);
const showSuggestions = focused && suggestions.length > 0;
const pickSuggestion = (key: string): void => {
if (active) {
onChange(applyKeySuggestion(value, active, key));
}
setHighlighted(-1);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onSubmit();
return;
}
if (showSuggestions && e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
return;
}
if (showSuggestions && e.key === 'ArrowUp') {
e.preventDefault();
setHighlighted((h) => Math.max(h - 1, 0));
return;
}
if (e.key === 'Enter') {
if (showSuggestions && highlighted >= 0) {
e.preventDefault();
pickSuggestion(suggestions[highlighted]);
} else {
onSubmit();
}
return;
}
if (e.key === 'Escape') {
setFocused(false);
setHighlighted(-1);
}
};
return (
<div className={styles.wrapper}>
<Input
className={cx(styles.input, { [styles.inputOpen]: showSuggestions })}
placeholder={placeholder}
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<Button
variant="ghost"
color="secondary"
size="sm"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Prevent the input's blur from firing first and double-submitting.
e.preventDefault();
}}
onClick={onSubmit}
>
Run query
<span className={styles.cmdHint}>
{isMac ? (
<Command size={12} color={Color.BG_VANILLA_400} />
) : (
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
)}
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</span>
</Button>
}
value={value}
testId="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
onChange(e.target.value);
setHighlighted(-1);
}}
onFocus={(): void => setFocused(true)}
onBlur={(): void => {
setFocused(false);
setHighlighted(-1);
onSubmit();
}}
onKeyDown={handleKeyDown}
/>
{showSuggestions && (
<div
className={styles.suggestions}
data-testid="dashboards-list-search-suggestions"
<div className={styles.field}>
<Search
size={12}
color={Color.BG_VANILLA_400}
className={styles.leadIcon}
/>
<CodeMirror
ref={editorRef}
className={styles.editor}
value={value}
placeholder={placeholder}
extensions={extensions}
basicSetup={BASIC_SETUP}
indentWithTab={false}
data-testid="dashboards-list-search"
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault();
}}
onClick={onSubmit}
>
{suggestions.map((key, index) => (
<button
key={key}
type="button"
className={cx(styles.suggestion, {
[styles.suggestionActive]: index === highlighted,
})}
data-testid={`dashboards-list-search-suggestion-${key}`}
onMouseEnter={(): void => setHighlighted(index)}
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Keep focus on the input so blur doesn't submit before we update.
e.preventDefault();
}}
onClick={(): void => pickSuggestion(key)}
>
{key}
</button>
))}
</div>
)}
{dirty && (
<span
className={styles.dirtyDot}
data-testid="dashboards-list-search-dirty"
/>
)}
Run query
<span className={styles.cmdHint}>
{isMac ? (
<Command size={12} color={Color.BG_VANILLA_400} />
) : (
<ChevronUp size={12} color={Color.BG_VANILLA_400} />
)}
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</span>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
// Bridges our stage-aware DSL suggestion engine into CodeMirror's autocompletion,
// so the query box gets keyboard navigation + a scrollable popup for free.
import {
type CompletionContext,
type CompletionResult,
startCompletion,
} from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';
import {
getSuggestions,
type SuggestionSource,
} from '../../utils/dslSuggestions';
// `getSource` is a getter so the freshest tags/users (which load async) are read
// on every keystroke.
export const dslCompletionSource =
(getSource: () => SuggestionSource) =>
(context: CompletionContext): CompletionResult | null => {
const query = context.state.doc.toString();
const { items, ctx } = getSuggestions(query, context.pos, getSource());
if (items.length === 0) {
return null;
}
return {
from: ctx.replaceStart,
to: ctx.replaceEnd,
// We already filtered/ranked in getSuggestions; keep CodeMirror from
// re-filtering by the typed text.
filter: false,
options: items.map((item) => ({
label: item.label,
type: item.kind,
detail: item.detail,
// Insert the raw text (not the display label) and re-open completion so
// key -> operator -> value chains without extra keystrokes.
apply: (view: EditorView, _c, from: number, to: number): void => {
view.dispatch({
changes: { from, to, insert: item.insertText },
// caretOffset lands the caret inside a `[...]` list (before its
// closing bracket) so multi-select can continue.
selection: {
anchor: from + item.insertText.length - (item.caretOffset ?? 0),
},
});
startCompletion(view);
},
})),
};
};

View File

@@ -0,0 +1,96 @@
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
// tokenizer used for autocomplete (dslTokenizer). Each key / operator / value /
// AND-OR span gets a `cm-dsl-*` class coloured in SearchBar.module.scss.
import { RangeSetBuilder } from '@codemirror/state';
import {
Decoration,
type DecorationSet,
type EditorView,
ViewPlugin,
type ViewUpdate,
} from '@codemirror/view';
import { scanTerm, splitTopLevelTerms } from '../../utils/dslTokenizer';
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
const OP = Decoration.mark({ class: 'cm-dsl-op' });
const STRING = Decoration.mark({ class: 'cm-dsl-string' });
const VALUE = Decoration.mark({ class: 'cm-dsl-value' });
const LOGIC = Decoration.mark({ class: 'cm-dsl-logic' });
interface Marked {
from: number;
to: number;
deco: Decoration;
}
const collectRanges = (query: string): Marked[] => {
const ranges: Marked[] = [];
const terms = splitTopLevelTerms(query);
terms.forEach((term, i) => {
// The AND/OR keyword sits in the gap before this term.
if (i > 0 && term.precedingJoiner) {
const prevEnd = terms[i - 1].end;
const m = /\b(?:AND|OR)\b/i.exec(query.slice(prevEnd, term.start));
if (m) {
ranges.push({
from: prevEnd + m.index,
to: prevEnd + m.index + m[0].length,
deco: LOGIC,
});
}
}
const scan = scanTerm(term.text);
const base = term.start;
if (scan.key) {
ranges.push({
from: base + scan.key.start,
to: base + scan.key.end,
deco: KEY,
});
}
if (scan.operator) {
ranges.push({
from: base + scan.operator.start,
to: base + scan.operator.end,
deco: OP,
});
}
if (scan.value) {
const isString = /^['"]/.test(scan.value.text.trim());
ranges.push({
from: base + scan.value.start,
to: base + scan.value.end,
deco: isString ? STRING : VALUE,
});
}
});
return ranges
.filter((r) => r.to > r.from)
.sort((a, b) => a.from - b.from || a.to - b.to);
};
const buildDecorations = (view: EditorView): DecorationSet => {
const builder = new RangeSetBuilder<Decoration>();
collectRanges(view.state.doc.toString()).forEach((r) => {
builder.add(r.from, r.to, r.deco);
});
return builder.finish();
};
export const dslHighlight = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{ decorations: (plugin): DecorationSet => plugin.decorations },
);

View File

@@ -1,14 +1,18 @@
import { useEffect, useState } from 'react';
import type { SelectedTag } from '../types';
// A key:value tag pair the list API reports across the org's dashboards.
export interface TagPair {
key: string;
value: string;
}
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
const tagId = (tag: TagPair): string => `${tag.key}:${tag.value}`;
// The list response only reports the tags present in the current (filtered) page,
// so tags vanish from the filter options as results narrow. Accumulate every tag
// we've ever seen so previously-surfaced tags stay selectable across refetches.
export function useAccumulatedTags(responseTags: SelectedTag[]): SelectedTag[] {
const [tags, setTags] = useState<SelectedTag[]>([]);
// so tags vanish from the suggestions as results narrow. Accumulate every tag
// we've ever seen so previously-surfaced tags stay suggestable across refetches.
export function useAccumulatedTags(responseTags: TagPair[]): TagPair[] {
const [tags, setTags] = useState<TagPair[]>([]);
useEffect(() => {
if (responseTags.length === 0) {

View File

@@ -1,21 +1,17 @@
import { useCallback, useMemo } from 'react';
import { parseAsString, useQueryState, type Options } from 'nuqs';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
areFilterStatesEqual,
DEFAULT_FILTER_STATE,
filterStateToQuery,
} from '../utils/filterQuery';
import { areQueriesEqual } from '../utils/filterQuery';
import { BuiltinViewId } from '../types';
import type { DashboardFilterState, SavedView } from '../types';
import type { SavedView } from '../types';
import {
BUILTIN_VIEWS,
builtinViewSnapshot,
type BuiltinView,
builtinViewQuery,
isClientView,
} from '../utils/views';
import { useSavedViews } from './useSavedViews';
@@ -23,8 +19,8 @@ import { useSavedViews } from './useSavedViews';
const opts: Options = { history: 'push' };
interface UseActiveViewArgs {
filters: DashboardFilterState;
applyFilters: (next: DashboardFilterState) => void;
query: string;
setQuery: (next: string) => void;
userEmail: string;
sortColumn: DashboardtypesListSortDTO;
sortOrder: DashboardtypesListOrderDTO;
@@ -38,7 +34,7 @@ export interface UseActiveViewResult {
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
// Current filters diverge from the active view's canonical snapshot.
// The current query diverges from the active view's canonical query.
isModified: boolean;
// Whether the active view constrains the list client-side (pinned/recent).
clientView: boolean;
@@ -50,19 +46,13 @@ export interface UseActiveViewResult {
renameView: (id: string, name: string) => void;
}
// The canonical filter snapshot a saved view "is": the backend stores a flat
// query, so a view folds entirely into the search box with empty chips.
const customSnapshot = (view: SavedView): DashboardFilterState => ({
...DEFAULT_FILTER_STATE,
search: view.query,
});
// Orchestrates the active view: which view is selected (URL `view` param),
// merging built-in + org-shared saved views, applying a view's snapshot on
// select, dirty detection, and save/reset/delete via the Views API.
// merging built-in + org-shared saved views, applying a view's query on select,
// dirty detection, and save/reset/delete via the Views API. A view now simply
// "is" a query string, so dirty detection is a trimmed string compare.
export function useActiveView({
filters,
applyFilters,
query,
setQuery,
userEmail,
sortColumn,
sortOrder,
@@ -87,35 +77,34 @@ export function useActiveView({
[customViews, activeViewId],
);
// The filter state the active view "is" — used to detect divergence.
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
// The query the active view "is" — used to detect divergence.
const canonicalQuery = useMemo<string | null>(
() =>
activeCustom
? customSnapshot(activeCustom)
: builtinViewSnapshot(activeViewId, userEmail),
? activeCustom.query
: builtinViewQuery(activeViewId, userEmail),
[activeCustom, activeViewId, userEmail],
);
const isModified = canonicalSnapshot
? !areFilterStatesEqual(filters, canonicalSnapshot)
: false;
const isModified =
canonicalQuery !== null && !areQueriesEqual(query, canonicalQuery);
const selectView = useCallback(
(id: string): void => {
void setActiveViewId(id);
const custom = customViews.find((v) => v.id === id);
if (custom) {
applyFilters(customSnapshot(custom));
setQuery(custom.query);
setSortColumn(custom.sort);
setSortOrder(custom.order);
return;
}
applyFilters(builtinViewSnapshot(id, userEmail) ?? DEFAULT_FILTER_STATE);
setQuery(builtinViewQuery(id, userEmail) ?? '');
},
[
setActiveViewId,
customViews,
applyFilters,
setQuery,
userEmail,
setSortColumn,
setSortOrder,
@@ -124,9 +113,6 @@ export function useActiveView({
const saveView = useCallback(
(name: string): void => {
// The active view's clause already lives in the filter state (e.g. Locked
// seeds `locked = true` into search), so the chips fold into one query.
const query = filterStateToQuery(filters);
void (async (): Promise<void> => {
const created = await createView({
name,
@@ -136,55 +122,44 @@ export function useActiveView({
});
if (created) {
void setActiveViewId(created.id);
// Re-apply the folded representation so the new view isn't
// immediately flagged as modified.
applyFilters(customSnapshot(created));
}
})();
},
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
[query, createView, sortColumn, sortOrder, setActiveViewId],
);
const saveActiveView = useCallback((): void => {
if (!activeCustom) {
return;
}
const query = filterStateToQuery(filters);
updateView(activeCustom.id, {
name: activeCustom.name,
query,
sort: sortColumn,
order: sortOrder,
});
applyFilters({ ...DEFAULT_FILTER_STATE, search: query });
}, [activeCustom, filters, updateView, sortColumn, sortOrder, applyFilters]);
}, [activeCustom, query, updateView, sortColumn, sortOrder]);
const resetView = useCallback((): void => {
if (!canonicalSnapshot) {
if (canonicalQuery === null) {
return;
}
applyFilters(canonicalSnapshot);
setQuery(canonicalQuery);
if (activeCustom) {
setSortColumn(activeCustom.sort);
setSortOrder(activeCustom.order);
}
}, [
canonicalSnapshot,
applyFilters,
activeCustom,
setSortColumn,
setSortOrder,
]);
}, [canonicalQuery, setQuery, activeCustom, setSortColumn, setSortOrder]);
const removeView = useCallback(
(id: string): void => {
deleteView(id);
if (activeViewId === id) {
void setActiveViewId(BuiltinViewId.All);
applyFilters(DEFAULT_FILTER_STATE);
setQuery('');
}
},
[deleteView, activeViewId, setActiveViewId, applyFilters],
[deleteView, activeViewId, setActiveViewId, setQuery],
);
// Rename only touches the view's name; its stored query/sort/order are preserved.

View File

@@ -1,138 +1,33 @@
import { useCallback, useMemo } from 'react';
import {
parseAsArrayOf,
parseAsString,
parseAsStringLiteral,
useQueryState,
type Options,
} from 'nuqs';
import { useCallback } from 'react';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import {
DEFAULT_FILTER_STATE,
filterStateToQuery,
isFilterStateEmpty,
} from '../utils/filterQuery';
import type {
DashboardFilterState,
SelectedTag,
UpdatedWindow,
} from '../types';
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
import { isQueryEmpty } from '../utils/filterQuery';
const opts: Options = { history: 'push' };
// Tags are carried in the URL as `key:value` strings; split on the first colon.
const parseTag = (raw: string): SelectedTag | null => {
const idx = raw.indexOf(':');
if (idx <= 0) {
return null;
}
return { key: raw.slice(0, idx), value: raw.slice(idx + 1) };
};
const serializeTag = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
export interface UseDashboardFiltersResult {
filters: DashboardFilterState;
// The backend list-filter `query` string derived from the current filters.
// The last-run backend list-filter query string — the source of truth for
// fetching. The editable draft lives in FilterZone; this only changes when a
// query is actually run (or a view is applied).
query: string;
isEmpty: boolean;
setSearch: (value: string) => void;
setCreatedBy: (emails: string[]) => void;
setUpdated: (window: UpdatedWindow) => void;
setTags: (tags: SelectedTag[]) => void;
// Replace the whole filter state at once — used when applying a saved view.
applyFilters: (next: DashboardFilterState) => void;
clearAll: () => void;
setQuery: (value: string) => void;
}
// Owns the dashboards-list filter state, synced to the URL (shareable links,
// back/forward) and projected into the backend `query` string. Sort/order/page
// live in their own query-param hooks; this hook is filters-only.
// Owns the run dashboards-list query, synced to the URL (shareable links,
// back/forward). Sort/order/page live in their own query-param hooks.
export function useDashboardFilters(): UseDashboardFiltersResult {
const [search, setSearchState] = useQueryState(
'search',
const [query, setQueryState] = useQueryState(
'query',
parseAsString.withDefault('').withOptions(opts),
);
const [createdBy, setCreatedByState] = useQueryState(
'createdBy',
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
);
const [updated, setUpdatedState] = useQueryState(
'updated',
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
);
const [tagStrings, setTagStringsState] = useQueryState(
'tags',
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
);
const tags = useMemo<SelectedTag[]>(
() => tagStrings.map(parseTag).filter((t): t is SelectedTag => t !== null),
[tagStrings],
);
const filters = useMemo<DashboardFilterState>(
() => ({ search, createdBy, updated, tags }),
[search, createdBy, updated, tags],
);
const query = useMemo(() => filterStateToQuery(filters), [filters]);
const setSearch = useCallback(
const setQuery = useCallback(
(value: string): void => {
void setSearchState(value);
void setQueryState(value.trim() ? value : null);
},
[setSearchState],
[setQueryState],
);
const setCreatedBy = useCallback(
(emails: string[]): void => {
void setCreatedByState(emails.length ? emails : null);
},
[setCreatedByState],
);
const setUpdated = useCallback(
(window: UpdatedWindow): void => {
void setUpdatedState(window);
},
[setUpdatedState],
);
const setTags = useCallback(
(next: SelectedTag[]): void => {
void setTagStringsState(next.length ? next.map(serializeTag) : null);
},
[setTagStringsState],
);
const applyFilters = useCallback(
(next: DashboardFilterState): void => {
void setSearchState(next.search || null);
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
void setUpdatedState(next.updated);
void setTagStringsState(
next.tags.length ? next.tags.map(serializeTag) : null,
);
},
[setSearchState, setCreatedByState, setUpdatedState, setTagStringsState],
);
const clearAll = useCallback((): void => {
applyFilters(DEFAULT_FILTER_STATE);
}, [applyFilters]);
return {
filters,
query,
isEmpty: isFilterStateEmpty(filters),
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
};
return { query, isEmpty: isQueryEmpty(query), setQuery };
}

View File

@@ -3,27 +3,9 @@ import type {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
// Relative "updated within" windows offered by the Updated filter chip.
// Relative "updated within" windows offered by the Updated filter dropdown.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
// A tag selected in the Tags filter chip — a concrete key:value pair drawn from
// the tags the list API reports across the org's dashboards.
export interface SelectedTag {
key: string;
value: string;
}
// The user-controllable filter state a view captures. `search` is a raw filter
// DSL fragment the user types; the structured chips (created-by, updated, tags)
// are AND-ed onto it. Sort/order are handled separately via URL query params and
// are not part of a view snapshot.
export interface DashboardFilterState {
search: string;
createdBy: string[]; // emails (created_by)
updated: UpdatedWindow;
tags: SelectedTag[];
}
// A saved view: a named filter the org shares, persisted via the backend Views
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
// so a view captures the fully-combined DSL query plus the sort/order to apply.

View File

@@ -0,0 +1,122 @@
// Frontend mirror of the backend dashboards-list filter DSL grammar
// (pkg/types/dashboardtypes/list_filter.go). There is no keys/operators API, so
// the valid keys, per-field operators, and value shapes are encoded here. Keep in
// sync with the backend allow-lists.
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
// Reserved keys the backend recognises as dashboard columns. Any other key is a
// tag key. (`updated_by` and `source` are NOT queryable.)
export const RESERVED_KEYS = [
'name',
'description',
'created_by',
'created_at',
'updated_at',
'locked',
] as const;
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
name: 'string',
description: 'string',
created_by: 'string',
created_at: 'timestamp',
updated_at: 'timestamp',
locked: 'bool',
};
// A reserved key maps to its column type; anything else is a tag.
export const classifyField = (key: string): DslFieldType =>
RESERVED_FIELD_TYPES[key.toLowerCase()] ?? 'tag';
// String/tag comparison operators, reusing the shared operator constants
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
const STRING_OPS = [
OPERATORS['='],
OPERATORS['!='],
OPERATORS.CONTAINS,
negateOperator(OPERATORS.CONTAINS),
OPERATORS.LIKE,
negateOperator(OPERATORS.LIKE),
OPERATORS.ILIKE,
negateOperator(OPERATORS.ILIKE),
OPERATORS.IN,
negateOperator(OPERATORS.IN),
];
// Valid operators per field type. Tags additionally support existence checks.
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
string: STRING_OPS,
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
timestamp: [
OPERATORS['='],
OPERATORS['!='],
OPERATORS['<'],
OPERATORS['<='],
OPERATORS['>'],
OPERATORS['>='],
OPERATORS.BETWEEN,
negateOperator(OPERATORS.BETWEEN),
],
bool: [OPERATORS['='], OPERATORS['!=']],
};
// Operators that take no value (the clause is complete after the operator).
export const VALUELESS_OPERATORS = new Set([
OPERATORS.EXISTS,
negateOperator(OPERATORS.EXISTS),
]);
// Operators whose value is a bracketed list (`IN ['a', 'b']`) rather than a
// single literal — value suggestions wrap/append inside the `[...]`.
export const LIST_OPERATORS = new Set([
OPERATORS.IN,
negateOperator(OPERATORS.IN),
]);
// Every operator, longest spelling first, so the tokenizer matches `NOT IN`
// before `NOT`/`IN` and `>=` before `>`. Word operators allow flexible internal
// whitespace (`NOT IN`).
export const OPERATOR_PATTERN = new RegExp(
'^(' +
[
'NOT\\s+CONTAINS',
'NOT\\s+LIKE',
'NOT\\s+ILIKE',
'NOT\\s+BETWEEN',
'NOT\\s+EXISTS',
'NOT\\s+IN',
'CONTAINS',
'ILIKE',
'LIKE',
'BETWEEN',
'EXISTS',
'IN',
'>=',
'<=',
'!=',
'<>',
'==',
'=',
'<',
'>',
].join('|') +
')',
'i',
);
// Canonical (uppercase, single-spaced) form of a matched operator spelling.
export const canonicalOperator = (raw: string): string => {
const collapsed = raw.replace(/\s+/g, ' ').trim().toUpperCase();
return collapsed === '==' ? '=' : collapsed === '<>' ? '!=' : collapsed;
};
// Characters that start an operator symbol — used to end a bare key token that
// abuts an operator without whitespace (`name='x'`).
export const isOperatorSymbolStart = (ch: string): boolean =>
ch === '=' || ch === '!' || ch === '<' || ch === '>';
// A single-quoted DSL string literal with embedded single quotes escaped.
export const literal = (value: string): string =>
`'${value.replace(/'/g, "\\'")}'`;

View File

@@ -1,57 +1,138 @@
import {
applyKeySuggestion,
buildSuggestionKeys,
getActiveKeyToken,
matchKeys,
RESERVED_DSL_KEYS,
} from './dslSuggestions';
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
describe('getActiveKeyToken', () => {
it('returns the partial key at the start', () => {
expect(getActiveKeyToken('nam')).toStrictEqual({ token: 'nam', start: 0 });
const source: SuggestionSource = {
tagKeys: ['env', 'team'],
tagValuesByKey: { env: ['prod', 'dev'] },
creatorEmails: ['alice@x.io', 'bob@x.io'],
currentUserEmail: 'me@x.io',
};
const labels = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source).items.map((i) => i.label);
const inserts = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source).items.map((i) => i.insertText);
describe('getSuggestions — key stage', () => {
it('suggests reserved + tag keys matching the partial', () => {
expect(labels('en', 2)).toStrictEqual(['env']);
});
it('returns the partial key after AND', () => {
const value = 'name = "x" AND en';
expect(getActiveKeyToken(value)).toStrictEqual({ token: 'en', start: 15 });
});
it('is null once an operator (space) has been typed', () => {
expect(getActiveKeyToken('name contains')).toBeNull();
});
it('is null for an empty trailing segment', () => {
expect(getActiveKeyToken('name = "x" AND ')).toBeNull();
it('inserts the key with a trailing space', () => {
expect(inserts('en', 2)).toStrictEqual(['env ']);
});
});
describe('buildSuggestionKeys', () => {
it('lists reserved keys plus distinct tag keys', () => {
const keys = buildSuggestionKeys([
{ key: 'env', value: 'prod' },
{ key: 'env', value: 'dev' },
{ key: 'team', value: 'core' },
describe('getSuggestions — operator stage', () => {
it('offers the full tag operator set as "key op" labels', () => {
const out = labels('env ', 4);
expect(out).toContain('env =');
expect(out).toContain('env CONTAINS');
expect(out).toContain('env NOT IN');
expect(out).toContain('env EXISTS');
expect(out.some((l) => l.includes('REGEXP'))).toBe(false);
});
it('restricts locked to = and !=', () => {
expect(labels('locked ', 7)).toStrictEqual(['locked =', 'locked !=']);
});
it('offers range operators for a timestamp key', () => {
const out = labels('updated_at ', 11);
expect(out).toContain('updated_at >=');
expect(out).toContain('updated_at BETWEEN');
expect(out).not.toContain('updated_at CONTAINS');
});
it('filters operators by the typed partial', () => {
expect(labels('env NOT ', 8)).toHaveLength(0); // "NOT " + space → past partial op, no ops
expect(labels('env NOT', 7)).toStrictEqual([
'env NOT CONTAINS',
'env NOT LIKE',
'env NOT ILIKE',
'env NOT IN',
'env NOT EXISTS',
]);
expect(keys).toStrictEqual([...RESERVED_DSL_KEYS, 'env', 'team']);
});
it('inserts just the operator with a trailing space', () => {
expect(inserts('env C', 5)).toStrictEqual(['CONTAINS ']);
});
});
describe('matchKeys', () => {
it('matches case-insensitively and excludes exact matches', () => {
expect(matchKeys(['name', 'created_by', 'env'], 'NAM')).toStrictEqual([
'name',
]);
expect(matchKeys(['name'], 'name')).toStrictEqual([]);
describe('getSuggestions — value stage', () => {
it('suggests creator emails (current user first, labelled) for created_by', () => {
const out = getSuggestions('created_by = ', 13, source);
expect(out.items[0].label).toBe('me@x.io (me)');
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
});
it('suggests true/false unquoted for locked', () => {
expect(inserts('locked = ', 9)).toStrictEqual(['true ', 'false ']);
});
it('suggests known tag values (quoted, trailing space) for a tag key', () => {
expect(inserts('env = ', 6)).toStrictEqual(["'prod' ", "'dev' "]);
});
it('filters values by the open-quote partial', () => {
expect(inserts("env = 'pr", 9)).toStrictEqual(["'prod' "]);
});
it('offers no value suggestions for free-text keys', () => {
expect(labels('name = ', 7)).toStrictEqual([]);
});
});
describe('applyKeySuggestion', () => {
it('replaces the partial key with the chosen key and a trailing space', () => {
const value = 'name = "x" AND en';
const active = getActiveKeyToken(value);
if (!active) {
throw new Error('expected an active key token');
}
expect(applyKeySuggestion(value, active, 'env')).toBe('name = "x" AND env ');
describe('getSuggestions — IN list values', () => {
it('wraps a fresh IN value in a bracketed list, caret before the close', () => {
const q = 'created_by IN ';
const out = getSuggestions(q, q.length, source);
const alice = out.items.find((i) => i.label === 'alice@x.io');
expect(alice?.insertText).toBe("['alice@x.io']");
expect(alice?.caretOffset).toBe(1);
});
it('wraps tag IN values in a bracketed list', () => {
const q = 'env IN ';
expect(inserts(q, q.length)).toStrictEqual(["['prod']", "['dev']"]);
});
it('appends to an open list and excludes already-entered values', () => {
const q = "env IN ['prod', ]";
const caret = q.indexOf(']'); // inside the brackets, after "'prod', "
const out = getSuggestions(q, caret, source);
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
const dev = out.items[0];
// No closing bracket (the existing `]` stays); caret lands at the end.
expect(dev.insertText).toBe("['prod', 'dev'");
expect(dev.caretOffset).toBe(0);
});
it('treats a complete last literal (no trailing comma) as entered', () => {
const q = "env IN ['prod'";
const out = getSuggestions(q, q.length, source);
// 'prod' is committed; only 'dev' remains and it appends after it.
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
expect(out.items[0].insertText).toBe("['prod', 'dev'");
});
it('filters open-list suggestions by the fragment under the caret', () => {
const q = "env IN ['de";
expect(inserts(q, q.length)).toStrictEqual(["['dev'"]);
});
it('uses a plain literal for a single-value operator (=)', () => {
expect(inserts('env = ', 6)).toStrictEqual(["'prod' ", "'dev' "]);
});
});
describe('getSuggestions — connector stage', () => {
it('chains AND/OR after a valueless operator', () => {
expect(labels('env EXISTS ', 11)).toStrictEqual(['AND', 'OR']);
});
it('chains AND/OR after a complete value', () => {
expect(labels("env = 'prod' ", 13)).toStrictEqual(['AND', 'OR']);
expect(inserts("env = 'prod' ", 13)).toStrictEqual(['AND ', 'OR ']);
});
});

View File

@@ -1,68 +1,255 @@
// Key-name suggestions for the dashboards-list DSL search box. The reserved keys
// mirror the backend filter DSL (pkg/.../listfilter_visitor.go); any other key is
// treated as a tag key, so we also surface the tag keys the list API reports.
import type { SelectedTag } from '../types';
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
// suggests keys, then operators (valid for that key's field type), then values
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
// caret/stage detection in dslTokenizer.ts.
import {
classifyField,
LIST_OPERATORS,
literal,
OPERATOR_MATRIX,
RESERVED_KEYS,
VALUELESS_OPERATORS,
} from './dslGrammar';
import { type CaretContext, getCaretContext } from './dslTokenizer';
// Reserved DSL keys the backend recognises as dashboard columns.
export const RESERVED_DSL_KEYS: string[] = [
'name',
'description',
'created_at',
'updated_at',
'created_by',
'locked',
'source',
];
// Show the full key set (the popup scrolls); values are capped a bit higher too.
const KEY_LIMIT = 100;
const VALUE_LIMIT = 50;
export interface ActiveKeyToken {
token: string;
// Index in the value string where the partial key begins.
start: number;
export interface SuggestionSource {
// Reserved column keys (from the list response, falling back to RESERVED_KEYS).
reservedKeys?: string[];
tagKeys: string[];
// Known values per tag key (lower-cased key → values).
tagValuesByKey: Record<string, string[]>;
// Creator emails for `created_by` value suggestions.
creatorEmails: string[];
currentUserEmail?: string;
}
// The partial key the user is currently typing: the trailing segment after the
// last top-level AND/OR (or the start), provided it hasn't yet reached an
// operator (no whitespace). Returns null once the key is complete.
export const getActiveKeyToken = (value: string): ActiveKeyToken | null => {
const boundaryRe = /\b(?:AND|OR)\b/gi;
let lastEnd = 0;
let match = boundaryRe.exec(value);
while (match !== null) {
lastEnd = match.index + match[0].length;
match = boundaryRe.exec(value);
}
const segment = value.slice(lastEnd);
const leading = segment.length - segment.trimStart().length;
const partial = segment.slice(leading);
if (partial.length === 0 || /[\s(]/.test(partial)) {
return null;
}
return { token: partial, start: lastEnd + leading };
};
export interface Suggestion {
label: string;
insertText: string;
kind: 'key' | 'operator' | 'value' | 'connector';
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
// are distinguishable from org tag keys.
detail?: string;
// Chars to move the caret back from the end of `insertText` after applying.
// Used to land the caret inside a `[...]` list so multi-select can continue.
caretOffset?: number;
}
// Build the de-duplicated, ordered list of keys to offer: reserved columns plus
// distinct tag keys from the list response.
export const buildSuggestionKeys = (availableTags: SelectedTag[]): string[] => {
const tagKeys = availableTags.map((t) => t.key);
return Array.from(new Set([...RESERVED_DSL_KEYS, ...tagKeys]));
};
export interface SuggestionsResult {
items: Suggestion[];
ctx: CaretContext;
}
// Keys matching the partial token (case-insensitive), excluding an exact match.
export const matchKeys = (
keys: string[],
token: string,
limit = 8,
): string[] => {
const lower = token.toLowerCase();
const includesInsensitive = (haystack: string, needle: string): boolean =>
haystack.toLowerCase().includes(needle.toLowerCase());
const dedupe = (values: string[]): string[] => Array.from(new Set(values));
const keySuggestions = (
partial: string,
source: SuggestionSource,
): Suggestion[] => {
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
const reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
// Show reserved keys in our defined (purpose) order — name, description, … —
// rather than the API's alphabetical order; any extra API keys follow, then tags.
const known = RESERVED_KEYS as readonly string[];
const orderedReserved = [
...known.filter((k) => reserved.includes(k)),
...reserved.filter((k) => !known.includes(k)),
];
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
const lower = partial.toLowerCase();
return keys
.filter((k) => k.toLowerCase().includes(lower) && k.toLowerCase() !== lower)
.slice(0, limit);
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
.slice(0, KEY_LIMIT)
.map((k) => ({
label: k,
insertText: `${k} `,
kind: 'key',
// Distinguish built-in columns from org tag keys in the popup.
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
}));
};
// Replace the active partial key in `value` with the chosen key + a space, ready
// for the user to type an operator.
export const applyKeySuggestion = (
value: string,
active: ActiveKeyToken,
key: string,
): string => `${value.slice(0, active.start)}${key} `;
const operatorSuggestions = (
fieldKey: string,
partial: string,
): Suggestion[] => {
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
const upper = partial.toUpperCase();
return ops
.filter((op) => op.startsWith(upper))
.map((op) => ({
// Echo the key with the operator (matches the query-builder UX) so the
// suggestion reads as the clause being built.
label: `${fieldKey} ${op}`,
insertText: `${op} `,
kind: 'operator',
}));
};
// Strip a leading opening quote from a partially-typed value for matching.
const unquotePartial = (partial: string): string =>
partial.replace(/^['"]/, '');
// A complete quoted literal (`'a'` or `"a"`, escapes allowed) — used to tell an
// already-entered list value from the fragment the user is still typing.
const COMPLETE_LITERAL = /^(['"])(?:\\.|(?!\1).)*\1$/;
interface ListPartial {
// Values already entered in the list (unquoted).
committed: string[];
// The value fragment the caret is currently typing (unquoted).
fragment: string;
}
// Index just past the string literal opening at `start` (mirrors the tokenizer's
// escape handling); runs to end-of-input if unterminated.
const skipStringLiteral = (text: string, start: number): number => {
const quote = text[start];
for (let i = start + 1; i < text.length; i += 1) {
if (text[i] === '\\') {
i += 1;
} else if (text[i] === quote) {
return i;
}
}
return text.length - 1;
};
// Split a top-level, comma-separated `[...]` partial (quote-aware) into its
// already-entered values and the fragment under the caret. A trailing comma or a
// complete quoted last piece means the caret starts a fresh value (fragment '').
const parseListPartial = (partial: string): ListPartial => {
const inner = partial.trim().replace(/^[[(]/, '');
const pieces: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < inner.length; i += 1) {
const c = inner[i];
if (c === "'" || c === '"') {
i = skipStringLiteral(inner, i);
} else if (c === '[' || c === '(') {
depth += 1;
} else if (c === ']' || c === ')') {
depth = Math.max(0, depth - 1);
} else if (c === ',' && depth === 0) {
pieces.push(inner.slice(start, i));
start = i + 1;
}
}
pieces.push(inner.slice(start));
const last = pieces[pieces.length - 1].trim();
const lastIsComplete = last !== '' && COMPLETE_LITERAL.test(last);
const committedPieces = lastIsComplete ? pieces : pieces.slice(0, -1);
return {
committed: committedPieces
.map((p) => unquotePartial(p.trim()).replace(/['"]$/, ''))
.filter((p) => p !== ''),
fragment: lastIsComplete ? '' : unquotePartial(last),
};
};
const valueSuggestions = (
ctx: CaretContext,
source: SuggestionSource,
): Suggestion[] => {
if (VALUELESS_OPERATORS.has(ctx.operator)) {
return [];
}
const key = ctx.fieldKey.toLowerCase();
const type = classifyField(ctx.fieldKey);
// List operators (`IN`/`NOT IN`) take a `[...]` list: parse what's already in
// the brackets so we exclude entered values and append rather than replace.
const isList = LIST_OPERATORS.has(ctx.operator);
const list = isList ? parseListPartial(ctx.partial) : null;
// A fresh list has no `[` yet; once inside one the `]` already sits after the
// caret, so we omit the closing bracket when appending.
const inBracket = isList && /^\s*[[(]/.test(ctx.partial);
const needle = list ? list.fragment : unquotePartial(ctx.partial);
if (type === 'bool') {
return ['true', 'false']
.filter((v) => v.startsWith(needle.toLowerCase()))
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
}
let raw: string[] = [];
if (key === 'created_by') {
const emails = source.currentUserEmail
? dedupe([source.currentUserEmail, ...source.creatorEmails])
: source.creatorEmails;
raw = emails;
} else if (type === 'tag') {
raw = source.tagValuesByKey[key] ?? [];
} else {
// name / description / timestamps have no known value set — free text.
return [];
}
const committed = new Set(list?.committed ?? []);
return raw
.filter((v) => !committed.has(v) && includesInsensitive(v, needle))
.slice(0, VALUE_LIMIT)
.map((v) => {
const label =
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
if (isList) {
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
return {
label,
// Append inside the existing `[...]` (its `]` stays after the caret),
// or open a fresh closed list. Either way the caret lands just before
// the `]` so the next pick continues the list.
insertText: inBracket ? `[${values}` : `[${values}]`,
kind: 'value',
caretOffset: inBracket ? 0 : 1,
};
}
return {
label,
// Trailing space so picking a value lands the caret in the connector slot.
insertText: `${literal(v)} `,
kind: 'value',
};
});
};
// AND / OR chaining after a complete term.
const CONNECTORS = ['AND', 'OR'];
const connectorSuggestions = (partial: string): Suggestion[] => {
const upper = partial.toUpperCase();
return CONNECTORS.filter((c) => c.startsWith(upper)).map((c) => ({
label: c,
insertText: `${c} `,
kind: 'connector',
}));
};
// Compute the suggestions for the caret position, plus the caret context the
// caller uses to splice a chosen suggestion back in.
export const getSuggestions = (
query: string,
caret: number,
source: SuggestionSource,
): SuggestionsResult => {
const ctx = getCaretContext(query, caret);
let items: Suggestion[] = [];
if (ctx.stage === 'key') {
items = keySuggestions(ctx.partial, source);
} else if (ctx.stage === 'operator') {
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
} else if (ctx.stage === 'value') {
items = valueSuggestions(ctx, source);
} else if (ctx.stage === 'connector') {
items = connectorSuggestions(ctx.partial);
}
return { items, ctx };
};

View File

@@ -0,0 +1,151 @@
import {
getCaretContext,
spliceAtCaret,
splitTopLevelTerms,
} from './dslTokenizer';
describe('splitTopLevelTerms', () => {
it('splits on top-level AND/OR', () => {
const terms = splitTopLevelTerms('a AND b OR c');
expect(terms.map((t) => t.text.trim())).toStrictEqual(['a', 'b', 'c']);
expect(terms.map((t) => t.precedingJoiner)).toStrictEqual([
null,
'AND',
'OR',
]);
});
it('ignores AND inside quotes', () => {
const terms = splitTopLevelTerms("name = 'a AND b' AND env = 'x'");
expect(terms).toHaveLength(2);
expect(terms[0].text.trim()).toBe("name = 'a AND b'");
expect(terms[1].text.trim()).toBe("env = 'x'");
});
it('ignores OR inside parentheses', () => {
const terms = splitTopLevelTerms('(a OR b) AND c');
expect(terms).toHaveLength(2);
expect(terms[1].precedingJoiner).toBe('AND');
});
it('keeps an IN list as a single term', () => {
const terms = splitTopLevelTerms("created_by IN ['a','b']");
expect(terms).toHaveLength(1);
});
it('does not split inside a bare word containing and/or', () => {
expect(splitTopLevelTerms('command = 1')).toHaveLength(1);
});
});
describe('getCaretContext — stage detection', () => {
const stageAt = (q: string, caret: number): string =>
getCaretContext(q, caret).stage;
it('is the key stage while typing the key', () => {
const ctx = getCaretContext('env', 3);
expect(ctx.stage).toBe('key');
expect(ctx.partial).toBe('env');
expect(ctx.replaceStart).toBe(0);
});
it('moves to the operator stage after the key + space', () => {
const ctx = getCaretContext('env ', 4);
expect(ctx.stage).toBe('operator');
expect(ctx.fieldKey).toBe('env');
expect(ctx.partial).toBe('');
});
it('stays on the operator while typing a partial operator', () => {
const ctx = getCaretContext('env NOT', 7);
expect(ctx.stage).toBe('operator');
expect(ctx.partial).toBe('NOT');
expect(ctx.replaceStart).toBe(4);
});
it('moves to the value stage after the operator + space', () => {
const ctx = getCaretContext('env = ', 6);
expect(ctx.stage).toBe('value');
expect(ctx.fieldKey).toBe('env');
expect(ctx.operator).toBe('=');
});
it('recognises a multi-word operator and moves to value', () => {
const ctx = getCaretContext('env NOT IN ', 11);
expect(ctx.stage).toBe('value');
expect(ctx.operator).toBe('NOT IN');
});
it('reports value partial inside an open quote', () => {
const ctx = getCaretContext("env = 'pr", 9);
expect(ctx.stage).toBe('value');
expect(ctx.partial).toBe("'pr");
expect(ctx.replaceStart).toBe(6);
});
it('chains a connector after EXISTS (no value stage)', () => {
expect(stageAt('env EXISTS ', 11)).toBe('connector');
});
it('moves to the connector stage after a complete value + space', () => {
const ctx = getCaretContext("env = 'prod' ", 13);
expect(ctx.stage).toBe('connector');
expect(ctx.partial).toBe('');
});
it('reports a partial connector keyword', () => {
const ctx = getCaretContext("env = 'prod' AN", 15);
expect(ctx.stage).toBe('connector');
expect(ctx.partial).toBe('AN');
expect(ctx.replaceStart).toBe(13);
});
it('handles a key operator with no whitespace', () => {
const ctx = getCaretContext("name='x", 7);
expect(ctx.stage).toBe('value');
expect(ctx.fieldKey).toBe('name');
expect(ctx.operator).toBe('=');
});
it('starts a fresh key after a top-level AND', () => {
const q = "created_by = 'a@x' AND ";
const ctx = getCaretContext(q, q.length);
expect(ctx.stage).toBe('key');
expect(ctx.partial).toBe('');
});
it('detects the stage of the term under a mid-string caret', () => {
const q = "env = AND team = 'core'";
// caret right after the first `env ` (index 4) is the operator stage
expect(stageAt(q, 4)).toBe('operator');
});
});
describe('spliceAtCaret', () => {
it('splices a key suggestion and returns the caret', () => {
const ctx = getCaretContext('env', 3);
const { next, caret } = spliceAtCaret('env', ctx, 'name ');
expect(next).toBe('name ');
expect(caret).toBe(5);
});
it('splices an operator into the gap', () => {
const ctx = getCaretContext('env ', 4);
const { next } = spliceAtCaret('env ', ctx, '= ');
expect(next).toBe('env = ');
});
it('splices a value over an open-quote partial', () => {
const q = "env = 'pr";
const ctx = getCaretContext(q, q.length);
const { next } = spliceAtCaret(q, ctx, "'prod'");
expect(next).toBe("env = 'prod'");
});
it('preserves text after the caret', () => {
const q = "env AND team = 'core'";
const ctx = getCaretContext(q, 4); // operator gap after `env`
const { next } = spliceAtCaret(q, ctx, '= ');
expect(next).toBe("env = AND team = 'core'");
});
});

View File

@@ -0,0 +1,351 @@
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
// the autocomplete engine (dslSuggestions) and the clause splicer (filterQuery).
// The DSL is a boolean composition of `key OP value` terms joined by AND/OR.
import {
canonicalOperator,
isOperatorSymbolStart,
OPERATOR_PATTERN,
VALUELESS_OPERATORS,
} from './dslGrammar';
export interface Term {
// Raw substring of the term (may carry surrounding whitespace); callers trim.
text: string;
start: number; // absolute start index in the query (inclusive)
end: number; // absolute end index in the query (exclusive)
precedingJoiner: 'AND' | 'OR' | null;
}
// A top-level AND/OR keyword starting at index `i` (word-boundary aware), or null.
const joinerAt = (
query: string,
i: number,
): { value: 'AND' | 'OR'; length: number } | null => {
const c = query[i];
if (c !== 'A' && c !== 'a' && c !== 'O' && c !== 'o') {
return null;
}
const prev = i === 0 ? '' : query[i - 1];
if (!(i === 0 || /[^A-Za-z0-9_]/.test(prev))) {
return null;
}
const m = /^(AND|OR)\b/i.exec(query.slice(i));
return m
? { value: m[1].toUpperCase() as 'AND' | 'OR', length: m[0].length }
: null;
};
// Index just past the string literal that opens at `start` (handles `\` escapes;
// runs to end-of-input if unterminated).
const skipString = (query: string, start: number): number => {
const quote = query[start];
let i = start + 1;
while (i < query.length) {
if (query[i] === '\\') {
i += 2;
} else if (query[i] === quote) {
return i + 1;
} else {
i += 1;
}
}
return i;
};
// Split a query into its top-level (depth-0, outside quotes) AND/OR-separated
// terms. `AND`/`OR` inside quotes or brackets/parens are ignored.
export const splitTopLevelTerms = (query: string): Term[] => {
const terms: Term[] = [];
let depth = 0;
let termStart = 0;
let precedingJoiner: 'AND' | 'OR' | null = null;
let i = 0;
const push = (end: number, next: 'AND' | 'OR' | null): void => {
terms.push({
text: query.slice(termStart, end),
start: termStart,
end,
precedingJoiner,
});
precedingJoiner = next;
};
while (i < query.length) {
const c = query[i];
if (c === "'" || c === '"') {
i = skipString(query, i);
continue;
}
if (c === '[' || c === '(') {
depth += 1;
} else if (c === ']' || c === ')') {
depth = Math.max(0, depth - 1);
} else if (depth === 0) {
const joiner = joinerAt(query, i);
if (joiner) {
push(i, joiner.value);
i += joiner.length;
termStart = i;
continue;
}
}
i += 1;
}
push(query.length, null);
return terms;
};
export interface TermScan {
key?: { start: number; end: number; text: string };
// The recognised operator (canonical form), if the second token parses as one.
operator?: { start: number; end: number; canonical: string };
// The second token when it is NOT (yet) a recognised operator — i.e. an
// operator the user is still typing.
operatorPartial?: { start: number; end: number; text: string };
value?: { start: number; end: number; text: string };
}
// Scan a single term (relative coordinates) into key / operator / value tokens.
export const scanTerm = (text: string): TermScan => {
const n = text.length;
let i = 0;
const skipWs = (): void => {
while (i < n && /\s/.test(text[i])) {
i += 1;
}
};
skipWs();
const scan: TermScan = {};
// Key: a bare token up to whitespace or an operator symbol.
const ks = i;
while (i < n && !/\s/.test(text[i]) && !isOperatorSymbolStart(text[i])) {
i += 1;
}
if (i > ks) {
scan.key = { start: ks, end: i, text: text.slice(ks, i) };
}
skipWs();
if (i >= n) {
return scan;
}
// Operator: try to match a known operator at the current position.
const rest = text.slice(i);
const m = OPERATOR_PATTERN.exec(rest);
// A word operator must end on a boundary so `INDIA` isn't read as `IN`.
const wordBoundaryOk =
m &&
(/[^A-Za-z]$/.test(m[0]) ||
i + m[0].length >= n ||
/[^A-Za-z]/.test(text[i + m[0].length]));
if (m && wordBoundaryOk) {
scan.operator = {
start: i,
end: i + m[0].length,
canonical: canonicalOperator(m[0]),
};
i += m[0].length;
skipWs();
if (i < n) {
scan.value = { start: i, end: n, text: text.slice(i, n) };
}
return scan;
}
// Not a recognised operator yet — treat the second token as a partial op.
const os = i;
while (i < n && !/\s/.test(text[i])) {
i += 1;
}
scan.operatorPartial = { start: os, end: i, text: text.slice(os, i) };
return scan;
};
export type CaretStage = 'key' | 'operator' | 'value' | 'connector' | 'none';
export interface CaretContext {
stage: CaretStage;
fieldKey: string; // key of the active term ('' when still typing the key)
operator: string; // canonical operator ('' when not yet parsed)
partial: string; // text typed so far in the active slot (for matching)
replaceStart: number; // absolute index to start replacing at
replaceEnd: number; // absolute index to stop replacing (the caret)
}
interface Slot {
stage: CaretStage;
operator: string;
partial: string;
replaceStartRel: number;
}
// Index where the value CONTENT ends (the value token's `end` is the whole term
// tail, incl. trailing space). A quoted/bracketed value ends after its close; an
// unterminated one runs to the end (still being typed); a bare one ends at space.
const valueContentEnd = (text: string, start: number): number => {
const n = text.length;
const ch = text[start];
if (ch === '"' || ch === "'") {
for (let i = start + 1; i < n; i += 1) {
if (text[i] === ch) {
return i + 1;
}
}
return n;
}
if (ch === '[' || ch === '(') {
const close = ch === '[' ? ']' : ')';
for (let i = start + 1; i < n; i += 1) {
if (text[i] === close) {
return i + 1;
}
}
return n;
}
let i = start;
while (i < n && !/\s/.test(text[i])) {
i += 1;
}
return i;
};
// The connector slot: after a complete term, a (partial) AND/OR keyword sits in
// the trailing whitespace, ready to chain the next term.
const connectorSlot = (
text: string,
from: number,
rel: number,
operator: string,
): Slot => {
const ws = /^\s*/.exec(text.slice(from, rel));
const start = from + (ws ? ws[0].length : 0);
return {
stage: 'connector',
operator,
partial: text.slice(start, rel),
replaceStartRel: start,
};
};
// Slot resolution once the caret is past the key: operator vs value.
const afterKeySlot = (text: string, scan: TermScan, rel: number): Slot => {
const { operator, operatorPartial, value } = scan;
// Editing the operator (a partial second token, or within a recognised op).
if (operatorPartial && rel <= operatorPartial.end) {
return {
stage: 'operator',
operator: '',
partial: text.slice(operatorPartial.start, rel),
replaceStartRel: operatorPartial.start,
};
}
if (operator && rel <= operator.end) {
return {
stage: 'operator',
operator: '',
partial: text.slice(operator.start, rel),
replaceStartRel: operator.start,
};
}
// Whitespace right after the key, before any operator — starting the operator.
if (!operator && !operatorPartial) {
return { stage: 'operator', operator: '', partial: '', replaceStartRel: rel };
}
// Past a recognised operator — the value slot, or (once complete) the connector.
if (operator) {
if (VALUELESS_OPERATORS.has(operator.canonical)) {
// After a valueless op (e.g. EXISTS), trailing space → chain AND/OR.
return rel > operator.end
? connectorSlot(text, operator.end, rel, operator.canonical)
: {
stage: 'none',
operator: operator.canonical,
partial: '',
replaceStartRel: rel,
};
}
if (value) {
// Caret past a complete value (into trailing space) → chain AND/OR.
const contentEnd = valueContentEnd(text, value.start);
if (rel > contentEnd) {
return connectorSlot(text, contentEnd, rel, operator.canonical);
}
return {
stage: 'value',
operator: operator.canonical,
partial: text.slice(value.start, rel),
replaceStartRel: value.start,
};
}
// No value typed yet — the (empty) value slot.
return {
stage: 'value',
operator: operator.canonical,
partial: '',
replaceStartRel: rel,
};
}
// A partial operator the caret has moved past — ambiguous, offer nothing.
return { stage: 'none', operator: '', partial: '', replaceStartRel: rel };
};
// Resolve which slot (key/operator/value) the caret is editing within a scanned
// term, in term-relative coordinates.
const resolveSlot = (text: string, scan: TermScan, rel: number): Slot => {
const { key } = scan;
// Still in/at the key token (or before it).
if (!key || rel <= key.end) {
const start = key ? key.start : rel;
return {
stage: 'key',
operator: '',
partial: key ? text.slice(key.start, rel) : '',
replaceStartRel: Math.min(start, rel),
};
}
return afterKeySlot(text, scan, rel);
};
// Determine what the caret is currently editing (key / operator / value) within
// the top-level term it sits in, plus the range a suggestion should replace.
export const getCaretContext = (query: string, caret: number): CaretContext => {
const pos = Math.max(0, Math.min(caret, query.length));
const terms = splitTopLevelTerms(query);
// The term the caret sits in: the first whose end >= caret (a joiner boundary
// counts as the end of the preceding term).
const term =
terms.find((t) => pos >= t.start && pos <= t.end) ?? terms[terms.length - 1];
const scan = scanTerm(term.text);
const slot = resolveSlot(term.text, scan, pos - term.start);
return {
stage: slot.stage,
fieldKey: scan.key ? scan.key.text : '',
operator: slot.operator,
partial: slot.partial,
replaceStart: term.start + slot.replaceStartRel,
replaceEnd: pos,
};
};
export interface SpliceResult {
next: string;
caret: number;
}
// Replace the active slot (ctx.replaceStart..replaceEnd) with `insertText`,
// preserving everything after the caret, and return the new caret position.
export const spliceAtCaret = (
query: string,
ctx: CaretContext,
insertText: string,
): SpliceResult => {
const next =
query.slice(0, ctx.replaceStart) + insertText + query.slice(ctx.replaceEnd);
return { next, caret: ctx.replaceStart + insertText.length };
};

View File

@@ -1,109 +1,124 @@
import dayjs from 'dayjs';
import {
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
isFilterStateEmpty,
areQueriesEqual,
createdByClause,
isQueryEmpty,
parseReflectedClauses,
spliceClause,
updatedWindowFromTimestamp,
} from './filterQuery';
import type { DashboardFilterState } from '../types';
const state = (patch: Partial<DashboardFilterState>): DashboardFilterState => ({
...DEFAULT_FILTER_STATE,
...patch,
describe('createdByClause', () => {
it('is null for no emails, = for one, IN for many', () => {
expect(createdByClause([])).toBeNull();
expect(createdByClause(['a@x.io'])).toBe("created_by = 'a@x.io'");
expect(createdByClause(['a@x.io', 'b@x.io'])).toBe(
"created_by IN ['a@x.io', 'b@x.io']",
);
});
});
describe('filterStateToQuery', () => {
it('passes the raw search through, wrapped in parentheses', () => {
expect(filterStateToQuery(state({ search: 'name contains "prod"' }))).toBe(
'(name contains "prod")',
);
});
it('emits an equality clause for a single creator', () => {
expect(filterStateToQuery(state({ createdBy: ['a@b.com'] }))).toBe(
"created_by = 'a@b.com'",
);
});
it('emits an IN clause for multiple creators', () => {
expect(filterStateToQuery(state({ createdBy: ['a@b.com', 'c@d.com'] }))).toBe(
"created_by IN ['a@b.com', 'c@d.com']",
);
});
it('emits an exact equality clause per selected tag', () => {
describe('parseReflectedClauses', () => {
it('reflects a top-level created_by equality', () => {
expect(
filterStateToQuery(
state({
tags: [
{ key: 'env', value: 'prod' },
{ key: 'team', value: 'core' },
],
}),
parseReflectedClauses("created_by = 'a@x.io'").createdBy,
).toStrictEqual(['a@x.io']);
});
it('reflects a created_by IN list', () => {
expect(
parseReflectedClauses("created_by IN ['a@x.io','b@x.io']").createdBy,
).toStrictEqual(['a@x.io', 'b@x.io']);
});
it('does not reflect a non =/IN created_by operator', () => {
expect(
parseReflectedClauses("created_by != 'a@x.io'").createdBy,
).toStrictEqual([]);
});
it('does not reflect a nested (parenthesised) clause', () => {
expect(
parseReflectedClauses("(created_by = 'a@x.io') AND name = 'x'").createdBy,
).toStrictEqual([]);
});
it('does not reflect a duplicated key', () => {
expect(
parseReflectedClauses("created_by = 'a@x.io' AND created_by = 'b@x.io'")
.createdBy,
).toStrictEqual([]);
});
it('reflects a recent updated_at >= as the nearest window', () => {
const iso = dayjs().subtract(7, 'day').toISOString();
expect(parseReflectedClauses(`updated_at >= '${iso}'`).updated).toBe('7d');
});
it('falls back to any for an unrecognised updated_at cutoff', () => {
const iso = dayjs().subtract(400, 'day').toISOString();
expect(parseReflectedClauses(`updated_at >= '${iso}'`).updated).toBe('any');
});
});
describe('spliceClause', () => {
it('appends a clause when the key is absent', () => {
expect(
spliceClause("name = 'x'", 'created_by', "created_by = 'a@x.io'"),
).toBe("name = 'x' AND created_by = 'a@x.io'");
});
it('replaces an existing top-level clause for the key', () => {
expect(
spliceClause(
"created_by = 'old' AND name = 'x'",
'created_by',
"created_by = 'new'",
),
).toBe("env = 'prod' AND team = 'core'");
).toBe("created_by = 'new' AND name = 'x'");
});
it('ANDs raw search with the structured chips', () => {
it('removes the clause when passed null', () => {
expect(
filterStateToQuery(
state({
search: 'name contains "x"',
createdBy: ['a@b.com'],
tags: [{ key: 'env', value: 'prod' }],
}),
spliceClause("created_by = 'a@x.io' AND name = 'x'", 'created_by', null),
).toBe("name = 'x'");
});
it('leaves a nested clause untouched and appends instead', () => {
expect(
spliceClause(
"(created_by = 'a') AND name = 'x'",
'created_by',
"created_by = 'b'",
),
).toBe("(name contains \"x\") AND created_by = 'a@b.com' AND env = 'prod'");
).toBe("(created_by = 'a') AND name = 'x' AND created_by = 'b'");
});
it('returns an empty string for the default state', () => {
expect(filterStateToQuery(DEFAULT_FILTER_STATE)).toBe('');
it('is a no-op removing an absent key', () => {
expect(spliceClause("name = 'x'", 'updated_at', null)).toBe("name = 'x'");
});
});
describe('isFilterStateEmpty', () => {
it('is true for the default state', () => {
expect(isFilterStateEmpty(DEFAULT_FILTER_STATE)).toBe(true);
});
it('is false when any tag is selected', () => {
describe('updatedWindowFromTimestamp', () => {
it('maps a ~1 day cutoff to today', () => {
expect(
isFilterStateEmpty(state({ tags: [{ key: 'env', value: 'prod' }] })),
).toBe(false);
updatedWindowFromTimestamp(dayjs().subtract(1, 'day').toISOString()),
).toBe('today');
});
it('returns null for an unquoted invalid value', () => {
expect(updatedWindowFromTimestamp('not-a-date')).toBeNull();
});
});
describe('areFilterStatesEqual', () => {
it('ignores tag ordering', () => {
const a = state({
tags: [
{ key: 'env', value: 'prod' },
{ key: 'team', value: 'core' },
],
});
const b = state({
tags: [
{ key: 'team', value: 'core' },
{ key: 'env', value: 'prod' },
],
});
expect(areFilterStatesEqual(a, b)).toBe(true);
describe('query helpers', () => {
it('isQueryEmpty ignores whitespace', () => {
expect(isQueryEmpty(' ')).toBe(true);
expect(isQueryEmpty("name = 'x'")).toBe(false);
});
it('distinguishes differing tag selections', () => {
expect(
areFilterStatesEqual(
state({ tags: [{ key: 'env', value: 'prod' }] }),
state({ tags: [{ key: 'env', value: 'dev' }] }),
),
).toBe(false);
});
});
describe('combineQueries', () => {
it('drops empty fragments and ANDs the rest', () => {
expect(combineQueries('locked = true', '', undefined, 'name = "x"')).toBe(
'locked = true AND name = "x"',
);
it('areQueriesEqual trims before comparing', () => {
expect(areQueriesEqual("name = 'x' ", " name = 'x'")).toBe(true);
});
});

View File

@@ -5,9 +5,9 @@
// - client: constrains by a client-side id set (Favorites, Recently viewed)
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
import { DEFAULT_FILTER_STATE } from './filterQuery';
import { createdByClause } from './filterQuery';
import { BuiltinViewId } from '../types';
import type { DashboardFilterState, ViewSection } from '../types';
import type { ViewSection } from '../types';
import type { DashboardListItem } from './helpers';
// All @signozhq icons share this component type.
@@ -49,28 +49,25 @@ export const BUILTIN_VIEWS: BuiltinView[] = [
export const isClientView = (id: string): boolean =>
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
// DSL the Locked view seeds into the search box, so the constraint is visible
// (and editable) rather than applied invisibly behind the scenes.
// DSL the Locked view applies — visible and editable in the query box rather
// than applied invisibly behind the scenes.
export const LOCKED_QUERY = 'locked = true';
// The canonical filter snapshot a built-in view applies when selected. `null`
// for ids that aren't built-in (custom views carry their own snapshot).
export const builtinViewSnapshot = (
// The canonical query string a built-in view applies when selected. `null` for
// ids that aren't built-in (custom views carry their own query).
export const builtinViewQuery = (
id: string,
userEmail: string,
): DashboardFilterState | null => {
): string | null => {
switch (id) {
case BuiltinViewId.Mine:
return {
...DEFAULT_FILTER_STATE,
createdBy: userEmail ? [userEmail] : [],
};
return userEmail ? (createdByClause([userEmail]) ?? '') : '';
case BuiltinViewId.Locked:
return { ...DEFAULT_FILTER_STATE, search: LOCKED_QUERY };
return LOCKED_QUERY;
case BuiltinViewId.All:
case BuiltinViewId.Pinned:
case BuiltinViewId.Recent:
return { ...DEFAULT_FILTER_STATE };
return '';
default:
return null;
}

View File

@@ -10,7 +10,6 @@ import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
@@ -116,9 +115,6 @@ function TimeSeriesViewContainer({
return (
<div className="trace-explorer-time-series-view-container">
<div className="trace-explorer-time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isFilterApplied={isFilterApplied}
isError={isError}
@@ -126,8 +122,10 @@ function TimeSeriesViewContainer({
isLoading={isLoading || isFetching}
data={responseData}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
dataSource={dataSource}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -107,6 +107,9 @@ export function ContextMenu({
}
}}
trigger="click"
// Anchor to body (like the backdrop), not the host container: a modal's
// transformed dialog would break `position: fixed` and trap the menu below it.
getPopupContainer={(): HTMLElement => document.body}
overlayStyle={{
position: 'fixed',
left: position.left,

View File

@@ -77,6 +77,11 @@
color: var(--muted-foreground);
}
// Body-portaled overlay: stay clickable when a modal sets `body { pointer-events: none }`.
.context-menu {
pointer-events: auto;
}
.context-menu .ant-popover-inner {
padding: 0;
border-radius: 6px;

View File

@@ -28,8 +28,6 @@ export interface PostableAlertRuleV2 {
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
algorithm?: string;
seasonality?: string;
};
evaluation?: {
kind?: 'rolling' | 'cumulative';

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