Compare commits

...

42 Commits

Author SHA1 Message Date
Abhi Kumar
ff1cc79c00 feat(dashboard-v2): pin the query-type tabs to the top of the editor scroll area
Make the query-type tab nav + Run Query button sticky so they stay visible
while the query body scrolls underneath; move the top padding onto the nav so
it keeps its spacing at rest and when pinned.
2026-07-09 00:27:45 +05:30
Abhi Kumar
83dc3ef430 feat(dashboard-v2): support per-series legend colors for bar and histogram panels
Route BarChartPanel and HistogramPanel through the time-series legend path in
useLegendSeries and enable the Legend `colors` control in both kinds' sections,
extending the per-series color overrides to these kinds.
2026-07-09 00:27:31 +05:30
Abhi Kumar
03114ed645 refactor(dashboard-v2): read panel time preference via shared helper
Panel.tsx read visualization.timePreference through an inline cast; replace it
with getPanelTimePreference so the header pill and NoData's extend gate share
one reader.
2026-07-09 00:26:04 +05:30
Abhi Kumar
890234ac6c fix(dashboard-v2): hide "Extend time range" for panels with a fixed time preference
A panel locked to a non-global timePreference queries a fixed relative span
anchored to the dashboard end, so widening the ambient window can't surface
data for it — the "Extend time range" CTA was a no-op. NoData now takes the
panel and, via panelHasFixedTimePreference, drops the global extender for such
panels (only Retry remains). The View modal's local extender still wins.
2026-07-09 00:25:43 +05:30
Abhi Kumar
ec04a885f4 fix(dashboard-v2): lazy-load panels per-panel instead of per-section
Move the viewport intersection observer down from Section to a new
SectionGridItem wrapper so each panel gates its own fetch. A board with
many panels now only queries the ones actually on screen, instead of
loading every panel in any section that scrolls into view.
2026-07-08 23:49:28 +05:30
Abhi Kumar
a71aacc8fd fix(dashboard-v2): hide the empty panel header in editor preview
When a panel has no title, description, or status and the actions menu is
suppressed (editor preview), render nothing instead of an empty header bar.
Add a min-height so the header keeps a stable size when it does render.
2026-07-08 23:49:28 +05:30
Abhi Kumar
05f3862e23 feat(dashboard-v2): offer dashboard root and every section in the move menu
Rework the "Move to section" submenu to list the dashboard root plus every
titled section (minus the panel's own), instead of only titled sections with
a separate "move out of section" entry. Hide it entirely when there is
nowhere to move.
2026-07-08 23:49:28 +05:30
Abhi Kumar
d26a904d94 feat(dashboard-v2): support per-slice color overrides for pie charts
Enable the legend colors control for pie charts. The control is fed by
useLegendSeries, which only handled time-series data, so pie returned no
series. Resolve pie legend series from the same scalar slices the renderer
draws (without overrides, for default colors) so the color keys match what
the chart looks up.

Extract the series resolution into utils/legendSeries.ts (a shared dedupe
primitive plus a pie and a time-series resolver) and reduce the hook to a
kind switch that returns none for kinds without a colors control.
2026-07-08 23:49:28 +05:30
Abhi Kumar
85895d6c4f fix(dashboard-v2): measure chart dimensions before first paint
useResizeObserver seeded its size to 0 and only reported real dimensions
after paint via its debounced observer, so charts rendered once at 0 then
jumped — most visibly the pie legend, which rendered left-aligned then
re-centered once it learned it fit on one row. Add a useLayoutEffect that
measures the element synchronously before paint. In jsdom clientWidth is 0,
so tests are unaffected.
2026-07-08 23:49:28 +05:30
Abhi Kumar
2efbd04ae1 feat(dashboard-v2): add the list columns editor and pagination loader to the view modal
Surface the List panel's columns editor in the View modal (as in the full
editor) by exposing setSpec from useViewPanelMode and wiring it into the
query-builder footer. Its add-column combobox portals to body, so inside
the modal it opened behind the dialog — lift its z-index above the
query-builder popups so it stacks on top.

Also thread isPreviousData into PreviewPane so the List panel's page-change
loader shows during pagination, matching the full editor and dashboard.
2026-07-08 23:49:27 +05:30
Abhi Kumar
bb9acc2f65 fix(dashboard-v2): make antd popups usable inside the view panel modal
The editor's antd Selects/pickers portal to document.body, outside the
modal Radix dialog, where Radix disables pointer events and traps focus, so
their menus opened but couldn't be clicked. Wrap the modal body in an antd
ConfigProvider whose getPopupContainer points at the dialog content, so all
antd popups render inside the interactive, focus-trapped layer.
2026-07-08 21:26:26 +05:30
Abhi Kumar
67608b1d8c fix(dashboard-v2): tint chart-legend checkbox with its series color
The @signozhq/ui Checkbox declares --checkbox-checked-background on the
element itself via its data-color rule, so a value set on an ancestor was
shadowed and the per-series tint never applied. Feed the color through a
private --series-color var and override the vars on the element with a
selector that out-specifies the library rule.
2026-07-08 21:26:26 +05:30
Aditya Singh
f26c8adb3b feat(trace-details): Span Details overhaul — width-driven layout, percentile & shared metadata (#11914)
* feat(trace-details): full-width span percentile panel with visible borders

* refactor(trace-details): give TraceIdField its own CSS module

* fix(trace-details): single borders for docked span details

* feat(trace-details): rework span details panel

Extract the summary into a SpanSummary component, place it by panel width
(above the tabs when narrow, inside Overview when wide), single-scroll sticky
tabs, and fix service/status-message badge overflow + truncation.

* feat(trace-details): share metadata row across header and span summary

* test: update test cases
2026-07-08 15:11:34 +00:00
Vinicius Lourenço
f5a395f229 fix(infra-monitoring-details): remove process/containers tab on hosts & better color for http status on traces (#12019)
* refactor(infra-monitoring): remove tabs for containers/processes from hosts

* fix(traces): parse http code to int instead of just render sakura color

* test(entity-details): add tests for entity traces

* fix(pr): address comments
2026-07-08 14:40:26 +00:00
Gaurav Tewari
b1dcbe87fe feat(llm-attribute-mapping): foundation — route, permission, page shell [1/5] (#11778)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(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

* feat(llm-attribute-mapping): add attribute mapping foundation (route, permission, page shell)

* fix: css styling

* refactor: css module

* 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

* 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

* 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

* chore: remove worktree

* chore: revert env.ts

* chore: add attribute mapping foundation

* refactor: basic components

* chore: update env.ts

* chore: update tests

* chore: self review changes

* chore: update test cases

* chore: remove extra comments

* refactor(llm-pricing): share toast copy via constants

* chore: use constants

* chore: redclared constants

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-08 14:12:49 +00:00
Vinicius Lourenço
a36081b00c fix(query-builder): ensure contrast/border/colors are consistent (#11992)
* refactor(query-builder-v2): ensure query builder has consistent colors and allow customization

* fix(query-builder-v2-usages): ensure each place using query builder v2 has consistent colors

* fix(alert): revert l3 for v2 ui
2026-07-08 13:57:05 +00:00
Aditya Singh
48d2460c8e feat(trace-details): waterfall timeline crosshair and VQA styling fixes (#11926)
* fix(trace): remove double border at flamegraph/waterfall juncture

* fix(trace): square accordion header corners

* fix(trace-waterfall): scope span hover-card to the span-name column

* feat(trace): move missing-spans banner to header using Callout

* feat(trace): use Lucide chevrons for accordion expand icons

* fix(trace-waterfall): keep hovered rows at full opacity when dimmed

* fix(trace-waterfall): strengthen selected/hover highlight into one continuous band

* fix(trace-waterfall): soften row-action button gradient

* fix(trace-waterfall): extend span-name divider to the top of the container

* fix(trace): use neutral info icon for flamegraph sampling notice

* fix(trace): tighten timeline ruler ticks and labels

* feat(trace-waterfall): enable timeline crosshair aligned to padded ruler

* fix(trace): match crosshair badge unit to ruler ticks
2026-07-08 12:57:52 +00:00
Tushar Vats
14d9bbfce9 chore: refactor querier integration tests (#12006)
* test(querier): move querier tests into per-signal suites

Pure git renames into querier{logs,traces,metrics,scalar,common} + CI matrix;
monoliths are split in the next commit. Preserves history (git log --follow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt2Xi6TdBDBAc3mhYZAb9q

* test(querier): split querier monoliths into theme files

Split 01_logs/04_traces/03_metrics/06_order_by_table into theme files
(functions moved verbatim, 249 tests unchanged); scalar helper -> fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt2Xi6TdBDBAc3mhYZAb9q

* test(querier): add coverage for filter/function/aggregation gaps

hasToken + has/hasToken misuse errors, ILIKE/NOT LIKE/NOT CONTAINS,
key:number, key-not-found (logs+traces), min/max, HAVING, metric reduceTo.
hasAny/hasAll over body arrays are marked xfail: they 500 in legacy body
mode (ClickHouse type error) and work only with use_json_body on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt2Xi6TdBDBAc3mhYZAb9q

* test(querier_json_body): add hasAny/hasAll positive coverage

Body-JSON array functions hasAny/hasAll (string + numeric arrays) succeed in
JSON-body mode (body_v2 -> ClickHouse Array via dynamicElement), complementing
the legacy-mode xfail in querierlogs/09. Verified on ClickHouse 25.12.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rt2Xi6TdBDBAc3mhYZAb9q

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:09:37 +00:00
Ashwin Bhatkal
9c48d17fed fix(dashboards): render logs/traces list panels on public dashboards (#11937)
* fix(dashboards): render logs/traces list panels on public dashboards

Public list panels rendered nothing even though the query returned data:
ListPanelWrapper bails with an empty fragment when setRequestData is
undefined, and the public Panel never forwarded one.

Back requestData with state and forward the setter to WidgetGraphComponent,
mirroring the authenticated GridCard. The per-panel request builder is
extracted to utils.ts.

Fixes SigNoz/engineering-pod#3646

* fix(dashboards): hide pagination controls on public list panels

Public widget data is fetched by index and the payload redacts each
widget's limit, so LogsPanelComponent/TracesTableComponent always showed a
pager that can't page (the endpoint takes no pagination params). Thread an
optional hidePagination flag from the public Panel through
WidgetGraphComponent -> PanelWrapper -> ListPanelWrapper to both list
components; default off, so authenticated dashboards are unchanged.

* fix(dashboards): guard against missing orderBy in useLogsData

Public dashboards redact the widget query (orderBy/filter/limit are
stripped), so a LIST query can reach useLogsData with no orderBy. The
optional chain guarded listQuery but not orderBy, so listQuery?.orderBy.find
threw once the public logs panel started rendering. Guard orderBy with ?..
2026-07-08 09:23:00 +00:00
Ashwin Bhatkal
33c6cbecda feat(dashboard-v2): variables table redesign + apply dynamic variables to panels (#12009)
* feat(dashboard-v2): patch builders to apply/sync a dynamic variable's filter across panels

* feat(dashboard-v2): two-column variables table with dynamic apply-to-panels

* fix(dashboard-v2): normalize dynamic variable signal so the source field always shows

* fix(dashboard-v2): keep field-key options during search refetch (smooth typing)

* fix(dashboard-v2): center and show the default-value clear icon

* fix(dashboard-v2): keep the add-variable + on one line when the bar is collapsed

* fix(dashboard-v2): variable selection flow correctness (#12013)

* fix(dashboard-v2): correct "ALL" variable selection end-to-end

- Materialize a query/custom ALL selection to the full option array so it reaches
  the query payload (was stored as value:null → dropped); tracks option growth.
- Default a multi-select allow-ALL variable (and the __ALL__ sentinel) to ALL,
  matching V1.
- ValueSelector hands CustomMultiSelect the expanded option set when all-selected,
  so it no longer renders a literal __ALL__ row.
- Keep a still-valid multi-select subset when options re-scope (was collapsing to
  the first option).
- Fix the dead configuredDefault fallback (read defaultValue as string|string[]).

* fix(dashboard-v2): variable selector robustness

- Surface option-fetch errors with a retry in the variable bar (Query/Dynamic) —
  a first-fetch failure is no longer a silent, stuck-empty selector.
- Custom variables now auto-select their default/first option (via a CustomSelector
  that runs useAutoSelect) instead of rendering blank.

* fix(dashboard-v2): variable fetch-engine correctness

- Ignore a stale/late fetch settle for a variable no longer actively fetching
  (restores V1's active-state guard).
- Unblock a waiting query child only once ALL its parents are settled (diamond
  dependencies no longer fetch against a stale parent).
- A value change no longer refetches the changed variable's own options, and only
  a dynamic change refreshes the other dynamics (query/custom/text changes don't
  touch dynamics — they don't depend on those values).
- Enqueue cycle-dropped query variables as best-effort roots instead of leaving
  them (and any waiting dynamics) silently stuck.

* fix(dashboard-v2): variable selection edge cases

- Read the __ALL__ URL sentinel as "ALL" only for variables that support it, so a
  literal "__ALL__" value (e.g. a text variable) isn't misread.
- Prune URL selections for variables that no longer exist (rename/remove), so a
  shared link can't leak a stale value into a later variable.
- Bound option-query cache churn with a cacheTime on the query/dynamic fetches.
2026-07-08 09:14:55 +00:00
Ashwin Bhatkal
9429c9e632 feat(dashboard-v2): show empty sections & clone a section with its panels (#12023)
* fix(dashboard-v2): render a newly-created empty titled section instead of the dashboard empty state

* feat(dashboard-v2): clone a section with all its panels
2026-07-08 07:21:40 +00:00
Ashwin Bhatkal
7821961a6f feat(dashboard-v2): detect external dashboard changes on tab focus and prompt to reload (#12022) 2026-07-08 07:21:08 +00:00
Abhi kumar
08de39cdbd fix(dashboard-v2): panel drilldown fixes + New Panel modal one-click create (#12026)
* fix(dashboard-v2): disable panel drilldown for non-builder queries

Drilldown was armed whenever the panel had at least one builder query,
even for PromQL / ClickHouse panels (a mixed CompositeQuery, or the
QUERY_BUILDER fallback of deriveQueryType). Those modes carry no builder
context to refine, so the click produced an empty/invalid drilldown.

Gate enableDrillDown on getPanelQueryType(panel) === QUERY_BUILDER in
addition to the existing capability + builder-query-count checks.

* fix(dashboard-v2): hide "Click to drilldown" hint when drilldown is disarmed

TooltipFooter's canDrilldown defaults to true, and the time-series / bar
renderers never passed it — so the hint showed even in the panel editor
preview (where drilldown is off) and for non-builder query types. Passing
the prop as-is wasn't enough: enableDrillDown is undefined in edit mode, and
canDrilldown={undefined} falls back to the true default.

Pass canDrilldown={!!enableDrillDown} (coerced) and add enableDrillDown to
the useCallback deps (also fixes a stale-closure in the bar renderer). The
hint now shows only when a click will actually open the drilldown menu.

* fix(dashboard-v2): make pie panel drilldown fire

preparePieData built slices with only label/value/color, never the
queryName and labels that enrichPieClick needs. Every slice therefore had
queryName === undefined, so enrichPieClick hit isValidQueryName('') and
returned null — the slice click was a silent no-op and pie drilldown never
worked.

Carry the value column's queryName and the row's group-by key→value labels
(keyed by field name) onto each slice, mirroring how enrichTableClick reads
them off the shared PanelTable shape. Adds regression tests for both.

* fix(dashboard-v2): one-click panel create when the dashboard has one section

The New Panel modal always used a select-then-confirm flow with a footer
section picker. When the dashboard has a single section there is nothing to
pick, so the footer degraded to a lone confirm button and the extra click
was pointless.

Hide the footer entirely for the single-section case and create the panel
directly on tile click; keep select-then-confirm when multiple sections
exist. The section-picker guard moves up to the modal so the footer renders
only when a picker is actually needed.
2026-07-08 07:19:32 +00:00
dependabot[bot]
98178795ec chore(deps): bump golang.org/x/crypto from 0.49.0 to 0.52.0 (#12012)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.49.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.49.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.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-08 06:42:13 +00:00
Ashwin Bhatkal
0797b26cb2 feat(dashboard-v2): variable editor form, add-variable UX & styling (#11942)
* fix(dashboard-v2): cap the variable preview-values box height

The 'Preview of Values' box only had a min-height, so a field with many values
grew the box tall. Add a max-height so it scrolls instead.

* feat(dashboard-v2): restore the V1 combined field/source row for dynamic variables

Replace the separate Source and Attribute rows with the single V1-style row:
the telemetry field on the left, then "from" and the source select on the right.

* feat(dashboard-v2): add-variable button in the variables bar

Add a dashed outlined 'Add variable' button on the left of the variables bar
(shown for editors even with no variables). It deep-links into the settings
drawer at the Variables tab with the add-variable form open, via a transient
settingsRequest store slice; the drawer destroys on close so it re-initializes
cleanly each open.

* feat(dashboard-v2): collapse the add-variable button once variables exist

Full labelled button when there are no variables; once some exist it shrinks to
a + icon (label on hover) placed after the collapsed +N. Dashed l3 border.

* style(dashboard-v2): use the l3 border on toolbar buttons and the empty state

Give Actions/Configure/Edit-as-JSON and the dashboard empty-state dashed border
the more visible --l3-border.

* fix(dashboard-v2): keep add-variable button out of collapsed bar + flat toolbar borders

- Move the add-variable + icon inside the strip and only show it when the bar
  isn't collapsed (no overflow) or is expanded, so it never breaks the layout
  mid-wrap; expand to reveal it.
- Make the Actions/Configure/Edit-as-JSON border a flat l3 outline with no
  box-shadow/depth.

* feat(dashboard-v2): raise dynamic-variable attribute search debounce to 500ms

* feat(dashboard-v2): add a Configure step to the dashboard empty state

* fix(dashboard-v2): flow the variables bar under the time selector + reposition add button

Revert the bar wrapper to block so the strip wraps around the floated time
selector (one line beside it collapsed; full-width below it when expanded) — a
flex wrapper had made it a BFC that trapped the rows on the left. The add-variable
"+" now sits after the +N/Less trigger, kept inline and always mounted so the
overflow measurement no longer toggles it (which caused a layout shift), with the
collapse math reserving space for it.

* feat(dashboard-v2): hide dashboard chrome in full screen

Show only the sections and panels in full screen — the header and toolbar
(actions, variables bar, time selector) are hidden for a clean presentation
view; exit with Esc. Also drops a stray blank line that failed the format check.

* refactor(dashboard-v2): extract add-variable buttons into components
2026-07-08 06:37:40 +00:00
Vinicius Lourenço
f8846026fb fix(authz-devtools): drop global listener for modal keyboard (#12018)
Co-authored-by: Yunus M <myounis.ar@live.com>
2026-07-08 06:31:25 +00:00
dependabot[bot]
5ec6560613 chore(deps): bump starlette from 1.0.0 to 1.3.1 in /tests (#11737)
Bumps [starlette](https://github.com/Kludex/starlette) from 1.0.0 to 1.3.1.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/1.0.0...1.3.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 05:30:13 +00:00
dependabot[bot]
46e092b2c9 chore(deps): bump cryptography from 46.0.7 to 48.0.1 in /tests (#11736)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (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
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.7 to 48.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 05:24:02 +00:00
Ashwin Bhatkal
fe660e27a2 feat(dashboard-v2): gate list-page edit actions on edit permission (#12021) 2026-07-08 05:13:03 +00:00
Ashwin Bhatkal
465620fbbb feat(dashboard-v2): enforce read-only behavior when a dashboard is locked (#12017)
* feat(dashboard-v2): lock-aware edit context, mutation guard, tooltip primitives & locked indicator, skip chart refetch on lock toggle

* feat(dashboard-v2): hide toolbar edit actions (incl. clone) in view mode, disable with a hover reason when locked

* feat(dashboard-v2): hide panel & section edit actions in view mode, disable when locked

* feat(dashboard-v2): open the JSON editor read-only when not editable

* feat(dashboard-v2): disable panel editor save when not editable
2026-07-08 05:12:31 +00:00
Aditya Singh
db2e29dde9 feat(trace-details): Pretty View interaction polish (hover, search, actions, z-index) (#11940)
* feat(trace-details): restyle Pretty View attributes search bar

* feat(trace-details): full-width row hover in Pretty View

* feat(trace-details): keep Pretty View row active and brighten value on hover/menu-open

* feat(trace-details): move parent-row ellipsis to the right edge

* fix(cmdk): raise command palette above the floating span-details panel

* feat: use toggle group for data viewer instead of dropdown

* fix(trace-details): disable Monaco sticky scroll in JSON view to stop overlap
2026-07-08 04:55:24 +00:00
Ashwin Bhatkal
cec72e25fd feat(dashboard-v2): link variables to panels (#12003)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboard-v2): scope panel refetch to referenced variables

A panel now refetches only when a variable its queries reference changes (the cache key is scoped to referenced variables; the full payload is still substituted), and stays in its loading state on first load until those variables resolve.

* feat(dashboard-v2): surface dashboard variables in query builder suggestions

Publish the dashboard's variables into the shared store the query builder autocomplete reads, so $variable suggestions appear in the dashboards-page builder and the panel editor.
2026-07-07 20:01:15 +00:00
Abhi kumar
82c833044d feat(dashboard-v2): extend time window from empty panel state (#11999)
* feat(dashboard-v2): add extend-time-window logic for empty panels

Add the empty-state "extend the time window" building block: useExtendTimeWindow
widens the dashboard's global time via the shared zoom-out ladder (getNextZoomOutRange
/ useZoomOut), and extendWindow.ts exposes the ExtendTimeWindow type plus a thin
buildExtendWindow adapter over the ladder result.

* feat(dashboard-v2): add view-session store slice for the modal extender

The View modal owns a local time window; add a transient dashboard-store
slice so it can publish its extend-window behaviour for the deeply-nested
NoData empty state to read, instead of threading it through every renderer.

* feat(dashboard-v2): offer an extend-window action in the panel no-data state

A panel with no data in the selected window showed only a Retry, which can't
help when the query succeeded and there's just nothing in range. Add an
"Extend time range" action that widens the window (global time on the
grid/editor; the View modal's local window in place, read from the store),
shown alongside the retained Retry.

Also fix the stale empty state: keepPreviousData retained the empty response so
NoData lingered through a refetch then snapped to the chart — a refetch over
empty data now shows the shared PanelLoader instead.

PanelMessage gains a secondaryAction so the empty state can render both buttons;
refetch stays on the renderer contract to drive Retry (standalone call sites
like the editor preview may still omit it, showing Extend only).

* chore: pr review fixes

* chore: pr review changes

* chore: pr review changes
2026-07-07 19:06:38 +00:00
Ashwin Bhatkal
f98b8bb1fd feat(dashboard-v2): runtime variable fetch engine & selectors (#12002)
* feat(dashboard-v2): add runtime variable fetch-state engine

Port V1's variable fetch orchestration natively onto the dashboard store: a fetch-state slice (idle/loading/revalidating/waiting/error + per-variable cycle ids) plus the dependency context derivation (query/dynamic order) needed to schedule fetches. Query variables load in dependency order; dynamics wait for query values. enqueueFetchAll drives first load/time change, enqueueDescendants drives a single value change.

* feat(dashboard-v2): drive variable selectors off the fetch engine

Gate the Query/Dynamic option fetches on the engine's per-variable state and key them by cycle id (so a parent change refetches only its dependents, in order, with no duplicate calls), and report completion/failure back. Wire the selection lifecycle: full fetch cycle on load/time change, descendant cascade on value change.

* fix(dashboard-v2): read a variable's default value as string, not { value }

defaultValue is a string | string[] on the wire, but the editor and runtime read
it as { value }, so a saved default showed blank in the form (and couldn't be
cleared) and never auto-applied. Read it directly in all three sites.

* feat(dashboard-v2): show hidden variable values on the collapsed +N hover

When the variables bar collapses the overflow behind +N, hovering it now lists
the hidden variables and their current selected values.

* feat(dashboard-v2): batch initial variable auto-selections into one fetch

Auto-selection now flows through a dedicated onAutoSelect path, separate from
user changes. The initial load burst of fills (each selector picks a value as
its options resolve, at different times) is coalesced across one animation frame
into a single store write plus one enqueueDescendantsBatch, so dependent
variables re-fetch once with the settled parent values instead of once per fill.

* refactor(dashboard-v2): use enum for VariableFetchState
2026-07-07 19:01:32 +00:00
Ashwin Bhatkal
2114fbddfe feat(dashboard-v2): enforce name-length limits on dashboard inputs (#12015)
* feat(dashboard-v2): cap dashboard, section, panel and variable name inputs at 128 chars

* feat(dashboard-v2): cap saved-view names at 64 chars
2026-07-07 18:51:20 +00:00
Yunus M
b2214cf5a3 feat: implement resizable quick filters across multiple components with persistent width settings (#12014) 2026-07-07 18:10:39 +00:00
Abhi kumar
db4219e0f1 feat(dashboards-v2): download panel as PNG/SVG/CSV (#11803)
* chore(dashboards-v2): add html-to-image for panel image export

Dependency used to capture a panel's rendered DOM node as PNG/SVG.

* feat(dashboards-v2): declare per-kind download capability

Adds the DownloadFormat enum and a per-kind `actions.download` map. Tables allow CSV/PNG/SVG, charts PNG/SVG; CSV is table-only (V1 parity).

* feat(dashboards-v2): capture a panel as a PNG/SVG image

downloadElementAsImage captures the panel's rendered node via html-to-image (dropping the hover chrome); useDownloadPanelImage locates it by its data-panel-root marker and surfaces failures as a toast. toSafeFileName sanitizes the filename.

* feat(dashboards-v2): export table panel data as CSV

getTableCsvRows flattens the prepared scalar table (reusing the on-screen cell formatting) into rows, and downloadCsv serializes them via papaparse.

* feat(dashboards-v2): add the Download action to the panel menu

useDownloadPanelMenuItem composes the submenu, dispatching CSV to the query response (useDownloadPanelCsv) and PNG/SVG to the DOM capture; the response is threaded to the menu via props. buildDownloadMenuItem renders the format options; usePanelActionItems consumes the ready item.

* chore: pr review fixes
2026-07-07 16:23:22 +00:00
Gaurav Tewari
165c945511 feat(frontend): LLM pricing UI update and tests [5/6] (#11910)
* 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

* 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

* 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

* chore: remove worktree

* chore: revert env.ts

* chore: update tests

* chore: self review changes

* chore: update test cases

* chore: remove extra comments

* chore: use constants

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-07 15:05:23 +00:00
Aditya Singh
ff19aedaa8 feat(trace-details): UI polish + reusable CopyButton (#11958)
* refactor(trace-details): use ArrowRightFromLine for filter collapse icon

* refactor(trace-details): square header back button with l1 border, tabular-nums trace ID

* refactor(trace-details): show events count as an l3 badge

* feat(periscope): add reusable CopyButton with copy-to-check animation

* refactor(trace-details): use CopyButton in DataViewer and filter query popover

* fix(traces): address PR review on CopyButton and events badge

- Remove unused onCopy prop from CopyButton; it was wired to fire
  unconditionally (even on failed clipboard writes), contradicting its
  documented "on successful copy" contract, and no caller used it. Will
  be re-added gated on success when actually needed.
- Only render the Events tab count badge when eventsCount > 0 so spans
  with no events don't show a "0" badge.

* style(traces): tokenize events badge margin per review

Use var(--spacing-3) for the events badge margin-left instead of a raw
6px. Leave the 18px badge box and 5px padding off-grid (no matching
token; nearest values would change the single-digit circle).
2026-07-07 15:01:58 +00:00
Abhi kumar
ef8319585b feat(dashboard-v2): View-modal drill-down + editor "Switch to View Mode" (#12004)
* fix(dashboard-v2): only mark a new panel savable once it has a query

A new panel was always treated as dirty, so Save was enabled even with no
query to run. Track dirtiness off spec/query edits and require a seeded query
(List auto-seeds one; other kinds open query-less) before a new panel is
savable.

* feat(dashboard-v2): drill down from the panel View modal

Wire the shared drill-down orchestration into the View modal so filter-by-value
and breakout refine the expanded view in place (URL-persisted, re-runs the
preview) instead of opening a nested View modal. Adds an OpenDrilldownView
handoff type, an optional openDrilldownView override on useDrilldown, and the
PreviewPane onClick/enableDrillDown pass-through the modal uses. Cells and log
rows get a pointer/hover affordance for the click.

* fix(dashboard-v2): keep the dashboard spec cache patch-driven

The spec cache is kept fresh by optimistic patches, so auto-refetching flashed
the grid back to server state as observers mounted across the panel tree. Set
staleTime: Infinity + refetchOnMount: false; explicit refetch() still works.

* feat(dashboard-v2): add "Switch to View Mode" to the panel editor

Add the V1 "Switch to View Mode" button to the panel editor header. It leaves
the full-page editor for the dashboard with this panel expanded in the View
modal, seeded with the live (un-saved) query via the same expandedWidgetId +
graphType + compositeQuery URL contract the modal hydrates from. Shown for
existing panels only — a new panel isn't saved to the dashboard spec yet.
2026-07-07 14:20:00 +00:00
Abhi kumar
14ba332935 fix: ui fixes for panel selection modal (#11944)
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
* fix: ui fixes for panel selection modal

* feat(dashboards-v2): choose target section when adding a panel

Add a section picker to the New Panel modal so a panel can be placed in
any section (or the untitled root), not just the one the "Add panel"
action was triggered from. Sections are read from the cached dashboard
query via useDashboardSections (no refetch, no prop-drilling).

Picking a panel type now selects it (highlighted card) and reveals a
footer holding the section dropdown and an "Add Panel" button split
50/50; confirming shows a brief loader before navigating to the editor.
The chosen section's layoutIndex is threaded through
useCreatePanel.createPanel.

* fix(dashboards-v2): refine New Panel modal footer + section labels

- Keep the footer visible at all times; the "Add Panel" confirm is disabled
  until a panel type is selected (instead of hiding the whole footer).
- Footer layout: the section selector fills the available width and the
  confirm button takes its natural width (no more 50/50 split).
- Add a "Select panel type" label above the panel-type grid, matching the
  existing "Add panel to" label.

* chore: pr review fixes
2026-07-07 12:28:15 +00:00
Vinicius Lourenço
fda7b66d49 feat(authz): add devtools for authz (#11933)
* feat(authz): add devtools for authz

* fix(authz): add missing allowed/deniedPermissions to test mocks

* fix(pnpm-lock): keep updated

* fix(pr): address comments

* fix(pr): use our button component

* fix(pr): split modal in smaller components
2026-07-07 12:11:05 +00:00
395 changed files with 20091 additions and 8832 deletions

View File

@@ -48,7 +48,11 @@ jobs:
- logspipelines
- passwordauthn
- preference
- querier
- querierlogs
- queriertraces
- queriermetrics
- querierscalar
- queriercommon
- rawexportdata
- role
- rootuser

View File

@@ -0,0 +1,3 @@
export const IS_DEV = false;
export const IS_PROD = true;
export const MODE = 'test';

View File

@@ -29,6 +29,7 @@ const config: Config.InitialOptions = {
'^constants/env$': '<rootDir>/__mocks__/env.ts',
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
'^react-syntax-highlighter/dist/esm/(.*)$':
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',

View File

@@ -79,6 +79,7 @@
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
"html-to-image": "1.11.13",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",

View File

@@ -164,6 +164,9 @@ importers:
history:
specifier: 4.10.1
version: 4.10.1
html-to-image:
specifier: 1.11.13
version: 1.11.13
http-status-codes:
specifier: 2.3.0
version: 2.3.0
@@ -5451,6 +5454,9 @@ packages:
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
engines: {node: '>=20.10'}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -14495,6 +14501,8 @@ snapshots:
html-tags@5.1.0: {}
html-to-image@1.11.13: {}
html-void-elements@3.0.0: {}
http-proxy-agent@5.0.0:

View File

@@ -62,6 +62,6 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
}

View File

@@ -87,6 +87,6 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
}

View File

@@ -329,10 +329,3 @@ export const LLMObservabilityPage = Loadable(
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -24,7 +24,6 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -515,17 +514,24 @@ const routes: AppRoutes[] = [
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_BASE,
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_BASE',
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
},
];

View File

@@ -269,3 +269,170 @@ export function mockQueryRangeV5WithEventsResponse({
),
);
}
export type MockTracesOptions = {
offset?: number;
pageSize?: number;
hasMore?: boolean;
delay?: number;
customTraces?: Array<{
serviceName?: string;
name?: string;
durationNano?: number;
httpMethod?: string;
responseStatusCode?: string;
}>;
onReceiveRequest?: (
req: RestRequest,
) =>
| undefined
| void
| Omit<MockTracesOptions, 'onReceiveRequest'>
| Promise<Omit<MockTracesOptions, 'onReceiveRequest'>>
| Promise<void>;
};
const createTracesResponse = ({
offset = 0,
pageSize = 10,
hasMore = true,
customTraces,
}: MockTracesOptions): MetricRangePayloadV5 => {
const serviceNames = ['frontend', 'backend', 'database', 'api-gateway'];
const spanNames = [
'GET /api/users',
'POST /api/orders',
'SELECT * FROM users',
];
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE'];
const statusCodes = ['200', '201', '400', '404', '500'];
const rows = customTraces
? customTraces.map((trace, index) => {
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
const currentTimestamp = new Date(baseTimestamp - index * 60000);
return {
timestamp: currentTimestamp.toISOString(),
data: {
serviceName:
trace.serviceName ?? serviceNames[index % serviceNames.length],
name: trace.name ?? spanNames[index % spanNames.length],
durationNano: trace.durationNano ?? 1000000 + index * 100000,
httpMethod: trace.httpMethod ?? httpMethods[index % httpMethods.length],
responseStatusCode:
trace.responseStatusCode ?? statusCodes[index % statusCodes.length],
traceID: `trace-id-${index}`,
spanID: `span-id-${index}`,
},
};
})
: Array.from(
{ length: hasMore ? pageSize : Math.ceil(pageSize / 2) },
(_, index) => {
const cumulativeIndex = offset + index;
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
const currentTimestamp = new Date(baseTimestamp - cumulativeIndex * 60000);
return {
timestamp: currentTimestamp.toISOString(),
data: {
serviceName: serviceNames[cumulativeIndex % serviceNames.length],
name: spanNames[cumulativeIndex % spanNames.length],
durationNano: 1000000 + cumulativeIndex * 100000,
httpMethod: httpMethods[cumulativeIndex % httpMethods.length],
responseStatusCode: statusCodes[cumulativeIndex % statusCodes.length],
traceID: `trace-id-${cumulativeIndex}`,
spanID: `span-id-${cumulativeIndex}`,
},
};
},
);
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
nextCursor: hasMore ? 'next-cursor-token' : '',
rows,
},
],
},
meta: {
bytesScanned: 9682976,
durationMs: 295,
rowsScanned: 34198,
stepIntervals: { A: 170 },
},
},
};
};
export function mockQueryRangeV5WithTracesResponse({
hasMore = true,
offset = 0,
pageSize = 10,
delay = 0,
customTraces,
onReceiveRequest,
}: MockTracesOptions = {}): void {
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
res(
...(delay ? [ctx.delay(delay)] : []),
ctx.status(200),
ctx.json(
createTracesResponse(
(await onReceiveRequest?.(req)) ?? {
hasMore,
pageSize,
offset,
customTraces,
},
),
),
),
),
);
}
export function mockQueryRangeV5WithEmptyTraces(): void {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
type: 'raw',
data: {
results: [{ queryName: 'A', nextCursor: '', rows: [] }],
},
meta: {
bytesScanned: 0,
durationMs: 10,
rowsScanned: 0,
stepIntervals: {},
},
},
}),
),
),
);
}
export function mockQueryRangeV5WithKeyNotFoundError(): void {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: {
code: 'invalid_input',
errors: [{ message: 'key not found' }],
},
}),
),
),
);
}

View File

@@ -12,6 +12,8 @@ type BadgeColor =
interface HttpStatusBadgeProps {
statusCode: string | number;
testId?: string;
className?: string;
}
function getStatusCodeColor(statusCode: number): BadgeColor {
@@ -35,6 +37,8 @@ function getStatusCodeColor(statusCode: number): BadgeColor {
function HttpStatusBadge({
statusCode,
testId,
className,
}: HttpStatusBadgeProps): JSX.Element | null {
const numericStatusCode = Number(statusCode);
@@ -45,7 +49,12 @@ function HttpStatusBadge({
const color = getStatusCodeColor(numericStatusCode);
return (
<Badge color={color} variant="outline">
<Badge
color={color}
variant="outline"
data-testid={testId}
className={className}
>
{statusCode}
</Badge>
);

View File

@@ -20,9 +20,9 @@
padding: 0px 8px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--l2-foreground);
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
display: flex;
justify-content: flex-start;
@@ -35,21 +35,54 @@
min-width: 150px;
font-family: 'Space Mono', monospace !important;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
--input-border-radius: 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
border-right: none;
border-left: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
position: relative;
&:hover,
&:focus {
z-index: 1;
}
.ant-select-selector {
position: relative;
border-radius: inherit;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
z-index: 1;
}
&.input__has-label-after {
margin-left: -1px;
.ant-select-selector {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.input__has-close-button {
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
~ .close-btn {
margin-left: -1px;
}
}
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--input-with-label-color, var(--l3-foreground)) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
@@ -63,25 +96,35 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
height: 100%;
width: 38px;
position: relative;
&:hover,
&:focus {
z-index: 2;
}
}
&.labelAfter {
.input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.label {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-radius: 0px 2px 2px 0px;
}
}
}

View File

@@ -45,7 +45,10 @@ function InputWithLabel({
>
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
<Input
className="input"
className={cx('input', {
'input__has-label-after': !labelAfter,
'input__has-close-button': !!onClose,
})}
placeholder={placeholder}
type={type}
value={inputValue}

View File

@@ -80,8 +80,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -101,7 +101,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -114,8 +115,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -241,7 +242,8 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted var(--l1-border);
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
}
/* Horizontal line pointing from vertical to the item */
@@ -254,8 +256,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -273,6 +275,16 @@
line-height: 16px; /* 128.571% */
resize: none;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
.formula-legend {
@@ -282,15 +294,42 @@
.ant-input-group-addon {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: var(--l2-background);
color: var(--l2-foreground);
background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
color: var(--query-builder-v2-color, var(--l2-foreground));
font-size: 12px;
font-weight: 300;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
}
.ant-input {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
height: 36px;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
margin-left: -1px;
&:hover,
&:focus {
z-index: 1;
border-color: var(--internal-ant-border-color-hover);
}
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
}
}
@@ -323,8 +362,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -395,8 +434,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--l1-border),
var(--l1-border) 4px,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
transparent 4px,
transparent 8px
);
@@ -412,7 +451,7 @@
min-width: 120px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
@@ -457,13 +496,16 @@
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background) !important;
height: 34px !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
height: 36px !important;
box-sizing: border-box !important;
.ant-select-selection-item {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l1-foreground));
}
}

View File

@@ -92,6 +92,11 @@
.ant-select {
width: 100%;
.ant-select-selector {
min-height: 36px;
}
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
@@ -100,9 +105,12 @@
.ant-select-selector {
border-radius: 2px;
border: 1.005px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -123,6 +131,7 @@
.input {
flex: initial;
width: 100px !important;
min-height: 36px;
}
}
}

View File

@@ -8,9 +8,9 @@
.ant-select-selection-search-input {
font-size: 12px !important;
line-height: 27px;
line-height: 25px;
&::placeholder {
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-size: 12px !important;
}
}
@@ -22,9 +22,12 @@
.ant-select-selector {
width: 100%;
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background);
color: var(--l1-foreground);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -33,36 +36,49 @@
min-height: 36px;
.ant-select-selection-placeholder {
color: var(--l2-foreground) !important;
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
font-size: 12px !important;
}
}
}
.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.qb-select-popover.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select-item {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.ant-select-item {
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
&:hover,
&.ant-select-item-option-active {
background: var(--l3-background) !important;
}
&:not(:last-of-type) {
margin-bottom: 4px;
}
&.ant-select-item-option-selected {
background: var(--l3-background) !important;
border: 1px solid var(--l1-border);
font-weight: 600;
}
&:hover,
&.ant-select-item-option-active {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
}
&.ant-select-item-option-selected {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
font-weight: 600;
}
}
}

View File

@@ -142,6 +142,7 @@ export const MetricsSelect = memo(function MetricsSelect({
{signalSourceChangeEnabled && (
<Select
className="source-selector"
popupClassName="qb-select-popover"
placeholder="Source"
options={SOURCE_OPTIONS}
value={source}

View File

@@ -1,8 +1,23 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
.query-add-ons {
width: 100%;
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
.add-on-tab-title {
display: flex;
align-items: center;
@@ -29,32 +44,33 @@
font-style: normal;
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
> button {
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left: none;
min-width: 120px;
height: 36px;
line-height: 36px;
&:first-child {
border-left: 1px solid var(--l1-border);
border-left: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
}
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
&[data-state='on'] {
color: var(--text-robin-500);
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
display: none;
&::before {
background: var(--l1-border);
background: var(--query-builder-v2-border-color, var(--l2-border));
}
}
}
@@ -65,7 +81,7 @@
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
@@ -78,10 +94,13 @@
align-items: center;
.having-filter-select-container {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
background: var(--query-builder-v2-background-color, var(--l2-background));
padding-right: 38px;
.having-filter-select-editor {
border-radius: 2px;
@@ -106,15 +125,17 @@
}
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left-width: 0px;
border-right-width: 0px;
padding: 0px !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -218,17 +239,32 @@
}
.cm-line {
line-height: 36px !important;
min-height: 34px;
line-height: 32px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -237,8 +273,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -246,34 +285,38 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-activeLine > span {
font-size: 12px !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
}
.close-btn {
position: absolute;
top: 0;
right: 0;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {
border-color: var(--l2-border);
border-left-color: transparent;
outline: none;
box-shadow: none;
}
}
}
}
@@ -300,20 +343,8 @@
font-size: 12px !important;
}
$add-on-row-height: 38px;
.periscope-input-with-label {
.input {
.ant-select {
height: $add-on-row-height;
}
}
}
.input-with-label {
.input {
height: $add-on-row-height;
}
input {
min-height: 36px;
}
}
}

View File

@@ -23,7 +23,7 @@
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&.error {
.cm-editor {
@@ -51,14 +51,15 @@
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
@@ -74,7 +75,7 @@
right: 0px !important;
border-radius: 4px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
@@ -118,7 +119,7 @@
box-sizing: border-box;
overflow: hidden;
color: var(--l2-foreground) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
@@ -127,7 +128,10 @@
&:hover,
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
color: var(--l1-foreground) !important;
font-weight: 600 !important;
}
@@ -142,15 +146,24 @@
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
@@ -159,8 +172,11 @@
}
.chip-decorator {
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l1-foreground)) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -168,7 +184,10 @@
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
@@ -201,12 +220,11 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
height: 38px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
@@ -217,13 +235,13 @@
height: 36px;
line-height: 36px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
font-family: 'Space Mono', monospace !important;
&::placeholder {
color: var(--l1-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
opacity: 0.5;
}
}
@@ -238,9 +256,10 @@
.query-aggregation-interval-input-container {
.query-aggregation-interval-input {
input {
min-height: 36px;
max-width: 120px;
&::placeholder {
color: var(--l2-foreground);
color: var(--query-builder-v2-color, var(--l2-foreground));
}
}
}
@@ -251,8 +270,8 @@
.query-aggregation-error-popover {
.ant-popover-inner {
background-color: var(--l1-border);
border: 1px solid var(--l1-border);
background-color: var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 4px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,7 +1,7 @@
.add-trace-operator-button,
.add-new-query-button,
.add-formula-button {
border: 1px solid var(--l1-border);
background: var(--l2-background);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -40,11 +40,14 @@ $max-recents-shown: 5;
.query-status-container {
width: 32px;
background-color: var(--l1-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: 2px;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
@@ -83,16 +86,16 @@ $max-recents-shown: 5;
.cm-content {
border-radius: 2px;
border: 1px solid var(--l1-border);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
padding: 0px !important;
&:focus-within {
border-color: var(--l1-border);
border-color: var(--query-builder-v2-border-color, var(--l2-border));
}
}
&.cm-focused {
outline: 1px solid var(--l1-border);
outline: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
}
.cm-tooltip-autocomplete {
@@ -183,11 +186,17 @@ $max-recents-shown: 5;
font-family: 'Space Mono', monospace !important;
background-color: var(--l1-background) !important;
color: var(--l2-foreground) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
&:hover {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
}
.cm-completionIcon {
@@ -205,7 +214,10 @@ $max-recents-shown: 5;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
font-weight: 600 !important;
}
}
@@ -274,25 +286,49 @@ $max-recents-shown: 5;
}
.cm-line {
line-height: 34px !important;
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(--l2-background) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
}
.cm-selectionBackground {
background: var(--l3-background) !important;
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
opacity: 0.5 !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
.cursor-position {

View File

@@ -65,6 +65,14 @@
display: flex;
flex-direction: column;
gap: 8px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
}
&-aggregation-container {

View File

@@ -47,7 +47,7 @@ function CheckboxValueRow({
{customRendererForValue ? (
customRendererForValue(value)
) : (
<TooltipSimple title={value} side="top" align="center" arrow>
<TooltipSimple title={String(value)} side="top" align="start">
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>

View File

@@ -57,10 +57,13 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
}
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
const unit = getIntervalUnit(spread, offsetTimestamp);
// Use the same width-derived interval spread the ticks use, so the badge
// unit always matches the tick unit (narrow rulers pick fewer intervals).
const intervalSpread = spread / getMinimumIntervalsBasedOnWidth(width);
const unit = getIntervalUnit(intervalSpread, offsetTimestamp);
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
return `${formatted}${unit.name}`;
}, [cursorXPercent, spread, offsetTimestamp]);
}, [cursorXPercent, spread, offsetTimestamp, width]);
if (endTimestamp < startTimestamp) {
console.error(
@@ -94,12 +97,17 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
y={timelineHeight * 2 - 3}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
<line
y1={0}
y2={timelineHeight - 3}
stroke={strokeColor}
strokeWidth="1"
/>
</g>
))}
</svg>

View File

@@ -0,0 +1,63 @@
import {
getIntervals,
getIntervalUnit,
getMinimumIntervalsBasedOnWidth,
} from '../utils';
// A tick label looks like "200.00ms" / "1.10s" — grab the trailing unit name.
function unitOfLabel(label: string): string {
const match = label.match(/[a-z]+$/i);
return match ? match[0] : '';
}
describe('getMinimumIntervalsBasedOnWidth', () => {
it('returns fewer intervals for narrower rulers', () => {
expect(getMinimumIntervalsBasedOnWidth(500)).toBe(3);
expect(getMinimumIntervalsBasedOnWidth(700)).toBe(4);
expect(getMinimumIntervalsBasedOnWidth(900)).toBe(5);
expect(getMinimumIntervalsBasedOnWidth(1200)).toBe(6);
});
});
describe('getIntervalUnit', () => {
it('selects the unit from the interval spread', () => {
expect(getIntervalUnit(130, 0).name).toBe('ms');
expect(getIntervalUnit(1100, 0).name).toBe('s');
expect(getIntervalUnit(70_000, 0).name).toBe('m');
});
it('accounts for a large offset (deep-zoom labels stay readable)', () => {
// Cursor spread is tiny but the window starts 5,000,000ms into the trace.
expect(getIntervalUnit(100, 5_000_000).name).toBe('hr');
});
// Regression: the interval COUNT changes the chosen unit, so the crosshair
// badge must use the same width-derived count as the ticks. On a 5.5s trace a
// narrow ruler (5 intervals → 1100ms → "s") and a wide one (6 intervals →
// 916ms → "ms") pick different units.
it('can resolve to different units for the same spread at different counts', () => {
const spread = 5500;
expect(getIntervalUnit(spread / 5, 0).name).toBe('s');
expect(getIntervalUnit(spread / 6, 0).name).toBe('ms');
});
});
describe('badge/tick unit consistency', () => {
// The invariant the fix guarantees: when the badge and the ticks are fed the
// same width-derived intervalSpread, every tick label uses the badge's unit.
it.each([
{ spread: 1287, width: 900 },
{ spread: 5500, width: 900 }, // narrow → 5 intervals, the mismatch case
{ spread: 5500, width: 1200 }, // wide → 6 intervals
{ spread: 120_000, width: 700 },
])('spread=$spread width=$width', ({ spread, width }) => {
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalSpread = spread / minIntervals;
const badgeUnit = getIntervalUnit(intervalSpread, 0).name;
const intervals = getIntervals(intervalSpread, spread, 0);
intervals.forEach((interval) => {
expect(unitOfLabel(interval.label)).toBe(badgeUnit);
});
});
});

View File

@@ -10,14 +10,18 @@ export type { Interval };
/**
* Select the interval unit matching the timeline's logic.
* Exported so crosshair labels use the same unit as timeline ticks.
* Exported so crosshair labels use the same unit as the timeline ticks.
*
* Takes the already-computed `intervalSpread` (spread / minIntervals) rather
* than deriving it from a hardcoded interval count — the tick count is
* width-dependent (`getMinimumIntervalsBasedOnWidth`), so callers must pass the
* same `intervalSpread` the ticks use or the badge unit can diverge from the
* ticks (e.g. ms vs s) on narrower rulers like the waterfall.
*/
export function getIntervalUnit(
spread: number,
intervalSpread: number,
offsetTimestamp: number,
): IIntervalUnit {
const minIntervals = 6;
const intervalSpread = spread / minIntervals;
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let unit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {

View File

@@ -1,12 +1,12 @@
/* Overlay stays below content */
[data-slot='dialog-overlay'] {
z-index: 50;
z-index: 1000 !important;
}
/* Dialog content always above overlay */
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
z-index: 1001 !important;
background: var(--l1-background);
color: var(--l1-foreground);

View File

@@ -22,6 +22,7 @@ import {
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useThemeMode } from 'hooks/useDarkMode';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { IS_DEV } from 'lib/env';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -30,6 +31,33 @@ import { useCmdK } from '../../providers/cmdKProvider';
import './cmdKPalette.scss';
const AuthZDevModal = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
default: m.AuthZDevModal,
})),
)
: null;
const AuthZDevFloatingIndicator = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
(m) => ({
default: m.AuthZDevFloatingIndicator,
}),
),
)
: null;
const openAuthZDevModal = IS_DEV
? (): void => {
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
m.openAuthZDevModal();
return m;
});
}
: undefined;
type CmdAction = {
id: string;
name: string;
@@ -110,6 +138,7 @@ export function CmdKPalette({
aiAssistant: isAIAssistantEnabled
? { open: handleOpenAIAssistant }
: undefined,
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
});
// RBAC filter: show action if no roles set OR current user role is included
@@ -146,37 +175,57 @@ export function CmdKPalette({
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
<>
<CommandDialog
open={open}
onOpenChange={setOpen}
position="top"
offset={110}
>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
<span
className={cx(
'cmd-item-icon',
it.id === 'ai-assistant' && 'noz-icon',
)}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
{IS_DEV && AuthZDevModal && (
<React.Suspense fallback={null}>
<AuthZDevModal />
</React.Suspense>
)}
{IS_DEV && AuthZDevFloatingIndicator && (
<React.Suspense fallback={null}>
<AuthZDevFloatingIndicator />
</React.Suspense>
)}
</>
);
}

View File

@@ -32,6 +32,11 @@ export enum LOCALSTORAGE {
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -89,8 +89,10 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
} as const;
export default ROUTES;

View File

@@ -43,10 +43,17 @@ type ActionDeps = {
aiAssistant?: {
open: () => void;
};
/**
* Provided only in development mode. Opens the AuthZ DevTools modal
* for testing permission overrides.
*/
authzDevTools?: {
open: () => void;
};
};
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
const { navigate, handleThemeChange, aiAssistant } = deps;
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
const actions: CmdAction[] = [
{
@@ -302,5 +309,17 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
});
}
if (authzDevTools) {
actions.push({
id: 'authz-devtools',
name: 'AuthZ DevTools',
keywords: 'authz permissions rbac debug devtools override testing',
section: 'Dev',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: authzDevTools.open,
});
}
return actions;
}

View File

@@ -163,12 +163,23 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.api-quick-filter-left-section {
width: 260px;
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.api-module-right-section {
width: calc(100% - 260px);
flex: 1;
min-width: 0;
}
}
}

View File

@@ -4,21 +4,49 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import DomainList from './Domains/DomainList';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className="api-quick-filter-left-section">
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="api-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
@@ -27,7 +55,7 @@ function Explorer(): JSX.Element {
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
/>
</section>
</ResizableBox>
<DomainList />
</div>
</Sentry.ErrorBoundary>

View File

@@ -41,6 +41,28 @@
.steps-container {
width: 80%;
.alert-query-section-container {
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
}
}
.qb-chart-preview-container {

View File

@@ -0,0 +1,4 @@
.wrapper :global(button[data-color]) {
--checkbox-checked-background: var(--series-color);
--checkbox-border-color: var(--series-color);
}

View File

@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
import { CSSProperties } from 'react';
import { CheckBoxProps } from '../types';
import styles from './CustomCheckBox.module.scss';
function CustomCheckBox({
data,
@@ -15,12 +16,11 @@ function CustomCheckBox({
const isChecked = graphVisibilityState[index] || false;
const colorStyle = {
'--checkbox-checked-background': color,
'--checkbox-border-color': color,
'--series-color': color,
} as CSSProperties;
return (
<span style={colorStyle}>
<span className={styles.wrapper} style={colorStyle}>
<Checkbox
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
value={isChecked}

View File

@@ -3,6 +3,30 @@
overflow-x: auto;
overflow-y: hidden;
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
--input-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
.full-view-header-container {
display: flex;
flex-direction: column;

View File

@@ -66,6 +66,7 @@ function WidgetGraphComponent({
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
hidePagination,
}: WidgetGraphComponentProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [deleteModal, setDeleteModal] = useState(false);
@@ -430,6 +431,7 @@ function WidgetGraphComponent({
customSeries={customSeries}
customOnRowClick={customOnRowClick}
enableDrillDown={enableDrillDown}
hidePagination={hidePagination}
onColumnWidthsChange={onColumnWidthsChange}
/>
</div>

View File

@@ -42,6 +42,8 @@ export interface WidgetGraphComponentProps {
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
/** Hide list-panel pagination controls (e.g. public dashboards, where paging isn't supported). */
hidePagination?: boolean;
}
export interface GridCardGraphProps {

View File

@@ -6,6 +6,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
@@ -16,6 +17,8 @@ import {
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -40,8 +43,21 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -139,7 +155,18 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -156,7 +183,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</div>
</ResizableBox>
)}
<div
className={`${styles.listContainer}${
@@ -189,8 +216,6 @@ function Hosts(): JSX.Element {
queryKeyPrefix="hosts"
tabsConfig={{
showEvents: false,
showContainers: true,
showProcesses: true,
}}
/>
</>

View File

@@ -44,26 +44,20 @@
}
.quickFiltersContainer {
width: 280px;
min-width: 280px;
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
:global(.ant-collapse-header) {
@@ -142,7 +136,9 @@
}
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
}
.quickFiltersToggleContainer {

View File

@@ -35,7 +35,6 @@ import {
ChevronsLeftRight,
Compass,
DraftingCompass,
Package2,
ScrollText,
X,
} from '@signozhq/icons';
@@ -54,12 +53,10 @@ import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
import EntityContainers from '../EntityDetailsUtils/EntityContainers';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityProcesses from '../EntityDetailsUtils/EntityProcesses';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
@@ -120,8 +117,6 @@ export interface K8sBaseDetailsProps<T> {
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
showContainers?: boolean;
showProcesses?: boolean;
};
customTabs?: Array<{
key: string;
@@ -259,8 +254,6 @@ export default function K8sBaseDetails<T>({
showLogs: true,
showTraces: true,
showEvents: true,
showContainers: false,
showProcesses: false,
...tabsConfig,
}),
[tabsConfig],
@@ -575,32 +568,6 @@ export default function K8sBaseDetails<T>({
},
]
: []),
...(tabVisibility.showContainers
? [
{
value: VIEW_TYPES.CONTAINERS,
label: (
<div className="view-title">
<Package2 size={14} />
Containers
</div>
),
},
]
: []),
...(tabVisibility.showProcesses
? [
{
value: VIEW_TYPES.PROCESSES,
label: (
<div className="view-title">
<ChevronsLeftRight size={14} />
Processes
</div>
),
},
]
: []),
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
@@ -680,11 +647,6 @@ export default function K8sBaseDetails<T>({
initialExpression={eventsInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.CONTAINERS &&
tabVisibility.showContainers && <EntityContainers />}
{effectiveView === VIEW_TYPES.PROCESSES && tabVisibility.showProcesses && (
<EntityProcesses />
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
<React.Fragment key={tab.key}>

View File

@@ -1,47 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
import broomUrl from '@/assets/Icons/broom.svg';
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
import 'components/HostMetricsDetail/Containers/Containers.styles.scss';
const { Text } = Typography;
function EntityContainers(): JSX.Element {
const { t } = useTranslation(['infraMonitoring']);
return (
<Space direction="vertical" className="host-containers" size={24}>
<div className="infra-container-card-container">
<div className="dev-status-container">
<div className="infra-container-card">
<img
src={infraContainersUrl}
alt="infra-container"
width={32}
height={32}
/>
<Text className="infra-container-card-text">
{t('containers_visualization_message')}
</Text>
</div>
<div className="infra-container-working-msg">
<Space>
<img src={broomUrl} alt="broom" width={24} height={24} />
<Text className="infra-container-card-text">{t('working_message')}</Text>
</Space>
</div>
</div>
<WaitlistFragment entityType="containers" />
</div>
</Space>
);
}
export default EntityContainers;

View File

@@ -1,46 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
import broomUrl from '@/assets/Icons/broom.svg';
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
import 'components/HostMetricsDetail/Processes/Processes.styles.scss';
const { Text } = Typography;
function EntityProcesses(): JSX.Element {
const { t } = useTranslation(['infraMonitoring']);
return (
<Space direction="vertical" className="host-processes" size={24}>
<div className="infra-container-card-container">
<div className="dev-status-container">
<div className="infra-container-card">
<img
src={infraContainersUrl}
alt="infra-container"
width={32}
height={32}
/>
<Text className="infra-container-card-text">
{t('processes_visualization_message')}
</Text>
</div>
<div className="infra-container-working-msg">
<Space>
<img src={broomUrl} alt="broom" width={24} height={24} />
<Text className="infra-container-card-text">{t('working_message')}</Text>
</Space>
</div>
</div>
<WaitlistFragment entityType="processes" />
</div>
</Space>
);
}
export default EntityProcesses;

View File

@@ -0,0 +1,98 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Default Behavior', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should fetch traces using V5 API on initial render', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 0,
});
});
it('should pass time range to API (converted to milliseconds)', async () => {
const timeRange = { startTime: 1000, endTime: 2000 };
act(() => {
renderEntityTraces({ timeRange });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedTimeRange: {
start: timeRange.startTime * 1000,
end: timeRange.endTime * 1000,
},
});
});
it('should order results by timestamp desc', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
order?: Array<{ key: { name: string }; direction: string }>;
};
const timestampOrder = spec.order?.find((o) => o.key.name === 'timestamp');
expect(timestampOrder?.direction).toBe('desc');
});
it('should use TRACES signal type', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const query = capturedPayloads[0].compositeQuery.queries[0];
const spec = query?.spec as { signal?: string };
expect(spec?.signal).toBe('traces');
});
it('should use raw request type for traces list view', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].requestType).toBe('raw');
});
});

View File

@@ -0,0 +1,38 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithEmptyTraces } from '__tests__/query_range_v5.util';
describe('EntityTraces - Empty State', () => {
it('should show empty state when no traces returned', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('should not show pagination controls when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByText(/1 - 10/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,44 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import {
mockQueryRangeV5WithError,
mockQueryRangeV5WithKeyNotFoundError,
} from '__tests__/query_range_v5.util';
describe('EntityTraces - Error State', () => {
it('should show error state on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
});
it('should show empty state for key not found error', async () => {
mockQueryRangeV5WithKeyNotFoundError();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,55 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Expression', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should include expression in query filter', async () => {
const expression = 'k8s.pod.name = "my-pod"';
act(() => {
renderEntityTraces({ expression });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
filter?: { expression: string };
};
expect(spec.filter?.expression).toContain('k8s.pod.name');
});
it('should include complex expression with multiple conditions', async () => {
const expression = 'k8s.pod.name = "my-pod" AND service.name = "api"';
act(() => {
renderEntityTraces({ expression });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
filter?: { expression: string };
};
expect(spec.filter?.expression).toContain('k8s.pod.name');
expect(spec.filter?.expression).toContain('service.name');
});
});

View File

@@ -0,0 +1,39 @@
import { act, screen, waitFor } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Loading State', () => {
it('should show loading state while fetching', async () => {
mockQueryRangeV5WithTracesResponse({ delay: 1000 });
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/pending_data_placeholder/i),
).resolves.toBeInTheDocument();
});
it('should hide loading state after data loads', async () => {
mockQueryRangeV5WithTracesResponse({ delay: 100 });
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/pending_data_placeholder/i),
).resolves.toBeInTheDocument();
await waitFor(
() => {
expect(
screen.queryByText(/pending_data_placeholder/i),
).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
});
});

View File

@@ -0,0 +1,96 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Pagination', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should use default limit when no pagination provided', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
limit?: number;
};
expect(spec.limit).toBe(10);
});
it('should use custom offset from pagination param', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 20, limit: 10 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 20,
});
});
it('should use custom limit from pagination param', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 0, limit: 50 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedLimit: 50,
});
});
it('should default offset to 0', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 0,
});
});
it('should combine offset and limit correctly', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 30, limit: 20 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 30,
expectedLimit: 20,
});
});
});

View File

@@ -0,0 +1,185 @@
import { act, screen, within } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
// Trace list columns are hidden below the antd `md` breakpoint. The global
// matchMedia mock reports `matches: false`, which drops every column, so make
// all breakpoints match for these rendering tests.
jest.spyOn(window, 'matchMedia').mockImplementation(
(query: string) =>
({
matches: true,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}) as unknown as MediaQueryList,
);
describe('EntityTraces - Table Rendering', () => {
it('should render table with all column headers', async () => {
mockQueryRangeV5WithTracesResponse({ pageSize: 3 });
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('Service Name')).resolves.toBeInTheDocument();
const headerTexts = within(screen.getByRole('table'))
.getAllByRole('columnheader')
.map((header) => header.textContent);
expect(headerTexts).toStrictEqual(
expect.arrayContaining([
'Timestamp',
'Service Name',
'Name',
'Duration',
'HTTP Method',
'Status Code',
]),
);
});
it('should render trace values in table cells', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{
serviceName: 'checkout-service',
name: 'GET /api/checkout',
durationNano: 5000000,
httpMethod: 'GET',
responseStatusCode: '200',
},
],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByTestId('serviceName')).resolves.toHaveTextContent(
'checkout-service',
);
expect(screen.getByTestId('name')).toHaveTextContent('GET /api/checkout');
expect(screen.getByTestId('durationNano')).toHaveTextContent('5.00ms');
expect(screen.getByTestId('httpMethod')).toHaveTextContent('GET');
expect(screen.getByTestId('responseStatusCode')).toHaveTextContent('200');
});
it('should render http method as robin colored outline badge', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ httpMethod: 'POST', responseStatusCode: '200' }],
});
act(() => {
renderEntityTraces();
});
const badge = await screen.findByTestId('httpMethod');
expect(badge).toHaveTextContent('POST');
expect(badge).toHaveAttribute('data-color', 'robin');
expect(badge).toHaveAttribute('data-variant', 'outline');
});
it('should render N/A when http method is empty', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ httpMethod: '', responseStatusCode: '200' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('N/A')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('httpMethod')).not.toBeInTheDocument();
});
it('should render status code badge color per status range', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{ responseStatusCode: '200' },
{ responseStatusCode: '302' },
{ responseStatusCode: '404' },
{ responseStatusCode: '500' },
{ responseStatusCode: '100' },
],
});
act(() => {
renderEntityTraces();
});
const badges = await screen.findAllByTestId('responseStatusCode');
const badgeColors = badges.map((badge) => badge.getAttribute('data-color'));
expect(badgeColors).toStrictEqual([
'forest', // 2xx -> success
'robin', // 3xx -> redirect
'amber', // 4xx -> client error
'cherry', // 5xx -> server error
'vanilla', // 1xx -> informational
]);
});
it('should render non-numeric status code as plain text without badge', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ responseStatusCode: 'unknown' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('unknown')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('responseStatusCode')).not.toBeInTheDocument();
});
it('should link cells to trace details page', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ serviceName: 'frontend' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByTestId('serviceName')).resolves.toBeInTheDocument();
const links = within(screen.getByRole('table')).getAllByRole('link');
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute(
'href',
expect.stringContaining('/trace/trace-id-0'),
);
});
});
it('should render one row per trace', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{ serviceName: 'frontend' },
{ serviceName: 'backend' },
{ serviceName: 'database' },
],
});
act(() => {
renderEntityTraces();
});
const serviceCells = await screen.findAllByTestId('serviceName');
expect(serviceCells.map((cell) => cell.textContent)).toStrictEqual([
'frontend',
'backend',
'database',
]);
});
});

View File

@@ -1,100 +0,0 @@
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, screen, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
function verifyEntityTracesV5Request({
payload,
expectedOffset,
initialTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset: number;
initialTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
expect(spec.offset).toBe(expectedOffset);
if (initialTimeRange) {
expect(payload.start).toBe(initialTimeRange.start);
expect(payload.end).toBe(initialTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));
describe('EntityTraces', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedQueryRangePayloads = [];
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
return {};
},
});
});
it('should use V5 API for fetching traces', async () => {
act(() => {
render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityTraces
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'
/>
</NuqsTestingAdapter>,
);
});
await waitFor(() => {
expect(
screen.queryByText('pending_data_placeholder'),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(1);
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityTracesV5Request({
payload: firstPayload,
expectedOffset: 0,
});
});
});

View File

@@ -0,0 +1,121 @@
import { ENVIRONMENT } from 'constants/env';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, RenderResult } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
// QuerySearch fires autocomplete requests on mount; without handlers MSW
// passes them through to the real network and the resulting AxiosError fails
// whichever test happens to be running.
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
);
});
export interface RenderEntityTracesOptions {
expression?: string;
timeRange?: { startTime: number; endTime: number };
category?: InfraMonitoringEntity;
selectedInterval?: '5m' | '15m' | '30m' | '1h';
pagination?: { offset: number; limit: number };
}
export function renderEntityTraces({
expression = 'k8s.pod.name = "test-pod"',
timeRange = { startTime: 1, endTime: 2 },
category = InfraMonitoringEntity.PODS,
selectedInterval = '5m',
pagination,
}: RenderEntityTracesOptions = {}): RenderResult {
const encodedExpression = encodeURIComponent(expression);
let searchParams = `${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodedExpression}`;
if (pagination) {
const paginationStr = encodeURIComponent(JSON.stringify(pagination));
searchParams += `&pagination=${paginationStr}`;
}
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<EntityTraces
timeRange={timeRange}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval={selectedInterval}
queryKey="test"
category={category}
initialExpression={expression}
/>
</NuqsTestingAdapter>,
);
}
export function verifyQueryPayload({
payload,
expectedOffset,
expectedLimit,
expectedTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset?: number;
expectedLimit?: number;
expectedTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
limit?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
if (expectedOffset !== undefined) {
expect(spec.offset).toBe(expectedOffset);
}
if (expectedLimit !== undefined) {
expect(spec.limit).toBe(expectedLimit);
}
if (expectedTimeRange) {
expect(payload.start).toBe(expectedTimeRange.start);
expect(payload.end).toBe(expectedTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));

View File

@@ -0,0 +1,7 @@
.pointer {
cursor: pointer;
}
.cellText {
color: var(--l2-foreground);
}

View File

@@ -1,6 +1,7 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
@@ -9,6 +10,7 @@ import {
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import styles from './traceListColumns.module.scss';
const keyToLabelMap: Record<string, string> = {
timestamp: 'Timestamp',
@@ -48,8 +50,9 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
const aliases = keyAliases[primaryKey];
if (aliases) {
for (const alias of aliases) {
if (data[alias] !== undefined) {
return data[alias];
const aliasedValue = data[alias] || data.resource?.[alias];
if (aliasedValue !== undefined) {
return aliasedValue;
}
}
}
@@ -78,7 +81,7 @@ export const getTraceListColumns = (
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography.Text>{date}</Typography.Text>
<Typography.Text className={styles.cellText}>{date}</Typography.Text>
</BlockLink>
);
}
@@ -86,34 +89,81 @@ export const getTraceListColumns = (
if (value === '') {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key}>N/A</Typography>
<Typography data-testid={key} className={styles.cellText}>
N/A
</Typography>
</BlockLink>
);
}
if (primaryKey === 'httpMethod' || primaryKey === 'responseStatusCode') {
if (primaryKey === 'httpMethod') {
const httpMethod = getValueForKey(itemData, key);
if (!httpMethod) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>N/A</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Badge data-testid={key} color="sakura">
{getValueForKey(itemData, key)}
<Badge
data-testid={key}
color="robin"
variant="outline"
className={styles.pointer}
>
{httpMethod}
</Badge>
</BlockLink>
);
}
if (primaryKey === 'responseStatusCode') {
const statusCode = getValueForKey(itemData, key);
const numericCode = Number(statusCode);
const isValidCode = !Number.isNaN(numericCode) && numericCode > 0;
if (!isValidCode) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>
{numericCode === 0 || !statusCode ? 'N/A' : statusCode}
</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<HttpStatusBadge
statusCode={statusCode}
testId={key}
className={styles.pointer}
/>
</BlockLink>
);
}
if (primaryKey === 'durationNano') {
const durationNano = getValueForKey(itemData, key);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key}>{getMs(durationNano)}ms</Typography>
<Typography data-testid={key} className={styles.cellText}>
{getMs(durationNano)}ms
</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key}>{getValueForKey(itemData, key)}</Typography>
<Typography data-testid={key} className={styles.cellText}>
{getValueForKey(itemData, key)}
</Typography>
</BlockLink>
);
},

View File

@@ -44,26 +44,15 @@
}
.quickFiltersContainer {
width: 280px;
min-width: 280px;
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
}
:global(.quick-filters) {
@@ -128,7 +117,9 @@
}
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
}
.categorySelectorSection {
@@ -219,8 +210,16 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -22,6 +23,8 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -56,9 +59,23 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -212,7 +229,18 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -265,7 +293,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</div>
</ResizableBox>
)}
<div

View File

@@ -34,8 +34,6 @@ export const VIEW_TYPES = {
METRICS: VIEWS.METRICS,
LOGS: VIEWS.LOGS,
TRACES: VIEWS.TRACES,
CONTAINERS: VIEWS.CONTAINERS,
PROCESSES: VIEWS.PROCESSES,
EVENTS: VIEWS.EVENTS,
};
@@ -394,41 +392,6 @@ export function GetClustersQuickFiltersConfig(
];
}
export function GetContainersQuickFiltersConfig(
dotMetricsEnabled: boolean,
): IQuickFiltersConfig[] {
const containerKey = dotMetricsEnabled
? 'k8s.container.name'
: 'k8s_container_name';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
type: FiltersType.CHECKBOX,
title: 'Container',
attributeKey: {
key: containerKey,
dataType: DataTypes.String,
type: 'resource',
id: `${containerKey}--string--resource`,
},
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: true,
},
];
}
export function GetVolumesQuickFiltersConfig(
dotMetricsEnabled: boolean,
): IQuickFiltersConfig[] {

View File

@@ -0,0 +1,13 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,26 @@
import AttributeMappingHeader from './components/AttributeMappingHeader';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={noop}
onSave={noop}
/>
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
No mapping groups configured yet.
</div>
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -0,0 +1,34 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: var(--periscope-font-size-large);
font-weight: var(--font-weight-semibold);
}
.description {
margin: var(--spacing-2) 0 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@signozhq/ui/button';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Attribute Mapping</h1>
<p className={styles.description}>
Configure source-to-target attribute remapping for LLM traces
</p>
</div>
<div className={styles.pageHeaderActions}>
{isDirty && (
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

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

View File

@@ -1,27 +1,7 @@
.llmObservability {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
height: 100%;
margin-top: var(--spacing-2);
margin-left: var(--spacing-2);
}

View File

@@ -1,16 +1,22 @@
import { Tabs } from '@signozhq/ui/tabs';
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
import styles from './LLMObservability.module.scss';
// Shell for the LLM Observability page: renders the top-level tab bar
// (Overview / Configuration) using the SigNoz design-system Tabs, with
// route-driven active state from useLLMObservabilityTabs.
function LLMObservability(): JSX.Element {
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
return (
<div className={styles.llmObservability} data-testid="llm-observability-page">
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
<Tabs
items={items}
value={activeTab}
onChange={onTabChange}
testId="llm-observability-tabs"
/>
</div>
);
}

View File

@@ -0,0 +1,27 @@
.overview {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -0,0 +1,20 @@
import styles from './Overview.module.scss';
// Overview tab content for LLM Observability. Currently the feature's landing
// surface; usage/cost/performance widgets land in later PRs.
function Overview(): JSX.Element {
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
</div>
);
}
export default Overview;

View File

@@ -2,29 +2,4 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -1,5 +1,4 @@
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import ModelCostTabPanel from './ModelCostTabPanel';
import styles from './LLMObservabilityModelPricing.module.scss';
@@ -10,20 +9,9 @@ function LLMObservabilityModelPricing(): JSX.Element {
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
Model pricing and cost estimation settings
</Typography.Text>
</div>
</header>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands, this can become a URL-backed param.
// the unpriced-models tab lands in a later PR.
defaultValue="model-costs"
items={[
{

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Search, X } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
@@ -161,12 +160,6 @@ function ModelCostTabPanel(): JSX.Element {
onDelete={deletion.requestDelete}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}

View File

@@ -0,0 +1,326 @@
import { LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO } from 'api/generated/services/sigNoz.schemas';
import {
TOAST_MODEL_COST_DELETED,
TOAST_MODEL_COST_UPDATED,
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import {
LLM_PRICING_ENDPOINT,
LLM_PRICING_RULE_ENDPOINT,
makeListResponse,
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 ModelCostTabPanel from '../ModelCostTabPanel';
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),
},
}));
function setupList(items = mockRules, total = items.length): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
),
);
}
function resetUrl(): void {
window.history.pushState(null, '', '/');
}
async function openRowMenu(
user: ReturnType<typeof userEvent.setup>,
ruleId: string,
): Promise<void> {
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
await user.click(within(row as HTMLElement).getByRole('button'));
}
describe('ModelCostTabPanel (integration)', () => {
beforeEach(() => {
resetUrl();
});
afterEach(() => {
server.resetHandlers();
});
it('renders pricing rules returned by the list API', async () => {
setupList();
render(<ModelCostTabPanel />);
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
expect(openaiCell).toHaveTextContent('gpt-4o');
expect(
screen.getByTestId('model-cell-name-rule-anthropic'),
).toHaveTextContent('claude-3-5-sonnet');
// Canonical id under the model name + provider column.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
// Source badges reflect the override flag.
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
'User override',
);
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
'Auto',
);
});
it('shows the empty state when there are no rules', async () => {
setupList([], 0);
render(<ModelCostTabPanel />);
const empty = await screen.findByTestId('model-costs-empty');
expect(empty).toHaveTextContent('No model costs yet.');
});
it('shows an error message when the list request fails', async () => {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<ModelCostTabPanel />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(
'Failed to load pricing rules. Please try again.',
);
});
it('sends the debounced search term as the q param', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let lastParams: URLSearchParams | null = null;
server.use(
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
lastParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.type(
screen.getByPlaceholderText('Search by model or provider'),
'claude',
);
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
});
it('clears the search via the clear button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
const input = screen.getByPlaceholderText(
'Search by model or provider',
) as HTMLInputElement;
await user.type(input, 'gpt');
expect(input.value).toBe('gpt');
await user.click(screen.getByTestId('model-cost-search-clear'));
await waitFor(() => expect(input.value).toBe(''));
});
it('sends isOverride=true when the source filter is set to User override', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let lastParams: URLSearchParams | null = null;
server.use(
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
lastParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.click(screen.getByTestId('source-filter'));
// Scope to the listbox option — "User override" also appears as a row badge.
await user.click(
await screen.findByRole('option', { name: 'User override' }),
);
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
});
it('opens the add drawer for a manager (ADMIN)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.click(screen.getByTestId('add-model-cost-btn'));
const modelInput = await screen.findByTestId('drawer-model-id-input');
expect(modelInput).toBeInTheDocument();
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
});
it('hides the add button and row actions for a viewer', async () => {
setupList();
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
const row = (
await screen.findByTestId('model-cell-name-rule-openai')
).closest('tr') as HTMLElement;
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
// View-only rows render no action menu (no buttons in the row).
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
});
it('opens the edit drawer prefilled from the row action menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupList();
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await openRowMenu(user, 'rule-openai');
await user.click(await screen.findByText('Edit'));
const drawerTitle = await screen.findByText('Edit model cost');
expect(drawerTitle).toBeInTheDocument();
const modelInput = screen.getByTestId(
'drawer-model-id-input',
) as HTMLInputElement;
expect(modelInput.value).toBe('gpt-4o');
expect(modelInput).toBeDisabled();
});
it('deletes a rule through the confirm dialog', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let deletedId: string | null = null;
setupList();
server.use(
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await openRowMenu(user, 'rule-openai');
await user.click(await screen.findByText('Delete'));
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
await waitFor(() => expect(deletedId).toBe('rule-openai'));
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_DELETED),
);
});
it('renders cache buckets for rules that have cache pricing', async () => {
setupList();
render(<ModelCostTabPanel />);
const anthropicRow = (
await screen.findByTestId('model-cell-name-rule-anthropic')
).closest('tr') as HTMLElement;
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
});
it('formats per-million prices in the row', async () => {
setupList();
render(<ModelCostTabPanel />);
const openaiRow = (
await screen.findByTestId('model-cell-name-rule-openai')
).closest('tr') as HTMLElement;
// mockRules gpt-4o has input cost 3 → rendered as $3.00.
expect(within(openaiRow).getByText('$3.00')).toBeInTheDocument();
});
it('sends a normalized create payload when adding a rule', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let body: Record<string, unknown> | null = null;
setupList();
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await user.click(screen.getByTestId('add-model-cost-btn'));
// Leading/trailing whitespace should be trimmed off the model id.
await user.type(
await screen.findByTestId('drawer-model-id-input'),
' gpt-4o-mini ',
);
await user.type(screen.getByTestId('drawer-input-cost'), '3');
await user.type(screen.getByTestId('drawer-output-cost'), '9');
await user.click(screen.getByTestId('drawer-save-btn'));
await waitFor(() => expect(body).not.toBeNull());
// The create call submits a bulk `rules` array of normalized payloads.
const [payload] = (
body as unknown as {
rules: Record<string, unknown>[];
}
).rules;
expect(payload).toMatchObject({
modelName: 'gpt-4o-mini',
provider: 'OpenAI',
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 3, output: 9 },
});
});
it('sends an updated payload when editing a rule', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let body: Record<string, unknown> | null = null;
setupList();
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
render(<ModelCostTabPanel />);
await screen.findByTestId('model-cell-name-rule-openai');
await openRowMenu(user, 'rule-openai');
await user.click(await screen.findByText('Edit'));
// Model id + provider are locked in edit mode; change the prefilled input cost.
const inputCost = await screen.findByTestId('drawer-input-cost');
await user.clear(inputCost);
await user.type(inputCost, '5');
await user.click(screen.getByTestId('drawer-save-btn'));
await waitFor(() => expect(body).not.toBeNull());
const [payload] = (
body as unknown as {
rules: Record<string, unknown>[];
}
).rules;
// Edit carries the rule id; disabled model/provider are still submitted and
// the edited price flows through, while output keeps its prefilled value.
expect(payload).toMatchObject({
id: 'rule-openai',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 5, output: 9 },
});
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_UPDATED),
);
});
});

View File

@@ -0,0 +1,295 @@
import { makePricingRule } from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
import { EMPTY_DRAFT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import { draftFromRule } from 'container/LLMObservability/Settings/ModelPricing/utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ModelCostDrawer from '../ModelCostDrawer';
const editDraft = draftFromRule(
makePricingRule({
id: 'rule-openai',
modelName: 'gpt-4o',
provider: 'OpenAI',
}),
);
describe('ModelCostDrawer (integration)', () => {
it('renders the add title and a save button for a manager', () => {
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByText('Add model cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
});
it('disables save until the form is dirty', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
await waitFor(() =>
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
);
});
it('shows the model id required error and does not call onSave when the name is empty', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={onSave}
isSaving={false}
saveError={null}
canManage
/>,
);
// Make the form dirty without touching the model id: add a pattern, which
// mutates the `patterns` form field while leaving the name empty.
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
await waitFor(() =>
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
);
await user.click(screen.getByTestId('drawer-save-btn'));
const error = await screen.findByText('Billing model ID is required.');
expect(error).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('calls onSave once on the happy path with valid model id and pricing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={onSave}
isSaving={false}
saveError={null}
canManage
/>,
);
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
await user.type(screen.getByTestId('drawer-input-cost'), '3');
await user.type(screen.getByTestId('drawer-output-cost'), '9');
await user.click(screen.getByTestId('drawer-save-btn'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
});
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
render(
<ModelCostDrawer
isOpen
mode="edit"
initialDraft={editDraft}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
const modelInput = screen.getByTestId(
'drawer-model-id-input',
) as HTMLInputElement;
expect(modelInput.value).toBe('gpt-4o');
expect(modelInput).toBeDisabled();
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
});
it('renders a read-only view with a Close button and no save for a viewer', () => {
render(
<ModelCostDrawer
isOpen
mode="edit"
initialDraft={editDraft}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage={false}
/>,
);
expect(screen.getByText('View model cost')).toBeInTheDocument();
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
});
it('renders the save error text', () => {
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError="boom"
canManage
/>,
);
expect(screen.getByText('boom')).toBeInTheDocument();
});
it('adds and removes a model pattern from the editor', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
// The added pattern renders as a removable chip.
const removeChip = screen.getByRole('button', {
name: 'Remove pattern gpt-5',
});
expect(removeChip).toBeInTheDocument();
await user.click(removeChip);
expect(
screen.queryByRole('button', { name: 'Remove pattern gpt-5' }),
).not.toBeInTheDocument();
});
it('adds a cache pricing bucket via the picker and removes it', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
// Adding the bucket reveals its cost input and the shared cache-mode select.
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
await user.click(screen.getByTestId('drawer-remove-cache-read'));
expect(
screen.queryByTestId('drawer-cache-read-cost'),
).not.toBeInTheDocument();
});
it('blocks save with a pricing error when an override rule has no input cost', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(
<ModelCostDrawer
isOpen
mode="add"
initialDraft={EMPTY_DRAFT}
onClose={jest.fn()}
onSave={onSave}
isSaving={false}
saveError={null}
canManage
/>,
);
// EMPTY_DRAFT defaults to an override with empty pricing. Fill only the
// model id + output cost so the form is dirty but the input cost is missing.
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
await user.type(screen.getByTestId('drawer-output-cost'), '9');
await user.click(screen.getByTestId('drawer-save-btn'));
await expect(
screen.findByText('Input cost must be greater than 0.'),
).resolves.toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('requires confirmation to reset an override rule back to auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ModelCostDrawer
isOpen
mode="edit"
initialDraft={editDraft}
onClose={jest.fn()}
onSave={jest.fn()}
isSaving={false}
saveError={null}
canManage
/>,
);
// Pricing is editable while the rule is an override.
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
// Picking "auto" surfaces a confirm step instead of applying immediately.
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
// Keep backs out of the reset.
await user.click(screen.getByTestId('drawer-reset-keep-btn'));
expect(
screen.queryByTestId('drawer-reset-confirm-btn'),
).not.toBeInTheDocument();
// Confirming the reset flips the rule to auto and locks pricing.
await user.click(screen.getByTestId('drawer-source-auto'));
await user.click(screen.getByTestId('drawer-reset-confirm-btn'));
await waitFor(() =>
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled(),
);
});
});

View File

@@ -6,7 +6,11 @@ import {
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from '../../../../constants';
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';
@@ -76,7 +80,9 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
toast.success(
mode === 'edit' ? TOAST_MODEL_COST_UPDATED : TOAST_MODEL_COST_ADDED,
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);

View File

@@ -1,7 +1,7 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 250px);
height: calc(100vh - 170px);
overflow-y: auto;
:global(table) tbody tr {

View File

@@ -6,6 +6,7 @@ import {
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import { TOAST_MODEL_COST_DELETED } from '../../constants';
import type { PricingRule } from '../../types';
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
@@ -49,7 +50,7 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
queryKey: getListLLMPricingRulesQueryKey(),
});
setPendingDelete(null);
toast.success('Model cost deleted');
toast.success(TOAST_MODEL_COST_DELETED);
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
toast.error(message);

View File

@@ -0,0 +1,80 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type ListLLMPricingRules200,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } 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';
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
// replaced wholesale when provided so callers can shape cache buckets freely.
export function makePricingRule(
overrides: Partial<PricingRule> = {},
): PricingRule {
const { pricing, ...rest } = overrides;
return {
id: 'rule-1',
enabled: true,
isOverride: true,
modelName: 'gpt-4o',
modelPattern: ['gpt-4o'],
orgId: 'org-1',
provider: 'OpenAI',
sourceId: 'source-1',
unit: UnitDTO.per_million_tokens,
createdAt: '2023-10-01T00:00:00.000Z',
updatedAt: '2023-10-10T00:00:00.000Z',
syncedAt: '2023-10-10T00:00:00.000Z',
pricing: {
input: 3,
output: 9,
...pricing,
},
...rest,
};
}
export const mockRules: PricingRule[] = [
makePricingRule({
id: 'rule-openai',
modelName: 'gpt-4o',
provider: 'OpenAI',
isOverride: true,
pricing: { input: 3, output: 9 },
}),
makePricingRule({
id: 'rule-anthropic',
modelName: 'claude-3-5-sonnet',
provider: 'Anthropic',
isOverride: false,
pricing: {
input: 2,
output: 30,
cache: { mode: CacheModeDTO.additive, read: 3, write: 6 },
},
}),
];
// Wraps items in the list response envelope the list query reads
// (`data.data.items` / `data.data.total`).
export function makeListResponse(
items: PricingRule[],
total = items.length,
offset = 0,
limit = 20,
): ListLLMPricingRules200 {
return {
status: 'success',
data: {
items,
total,
offset,
limit,
},
};
}

View File

@@ -4,6 +4,10 @@ import type { CacheBucketDef, DrawerDraft } from './types';
export const PAGE_SIZE = 20;
export const TOAST_MODEL_COST_ADDED = 'Model cost added';
export const TOAST_MODEL_COST_UPDATED = 'Model cost updated';
export const TOAST_MODEL_COST_DELETED = 'Model cost deleted';
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SEARCH_KEY = 'search';

View File

@@ -0,0 +1,95 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import ROUTES from 'constants/routes';
import {
LLM_PRICING_ENDPOINT,
makeListResponse,
mockRules,
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import LLMObservability from '../LLMObservability';
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(items))),
),
);
}
describe('LLMObservability (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders the overview panel and the tab bar on the overview route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Model pricing' }),
).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Attribute Mapping' }),
).toBeInTheDocument();
});
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
);
});
it('navigates to the attribute mapping route when that tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
);
});
it('renders the attribute mapping page on the attribute mapping route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
});
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('renders the model-pricing page on the configuration route', async () => {
setupList();
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
});
await waitFor(() =>
expect(
screen.getByTestId('llm-observability-model-pricing-page'),
).toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,62 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { type TabItemProps } from '@signozhq/ui/tabs';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
import Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
interface UseLLMObservabilityTabsResult {
items: TabItemProps[];
activeTab: string;
onTabChange: (key: string) => void;
}
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
// design-system Tabs.
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
let activeTab: string = OVERVIEW_KEY;
if (pathname.startsWith(CONFIGURATION_KEY)) {
activeTab = CONFIGURATION_KEY;
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
activeTab = ATTRIBUTE_MAPPING_KEY;
}
const onTabChange = useCallback(
(key: string): void => {
safeNavigate(key);
},
[safeNavigate],
);
const items: TabItemProps[] = [
{
key: OVERVIEW_KEY,
label: 'Overview',
children: <Overview />,
},
{
key: CONFIGURATION_KEY,
label: 'Model pricing',
children: <LLMObservabilityModelPricing />,
},
{
key: ATTRIBUTE_MAPPING_KEY,
label: 'Attribute Mapping',
children: <LLMObservabilityAttributeMapping />,
},
];
return { items, activeTab, onTabChange };
}

View File

@@ -34,6 +34,7 @@ function LogsPanelComponent({
setRequestData,
queryResponse,
onColumnWidthsChange,
hidePagination,
}: LogsPanelComponentProps): JSX.Element {
const [pageSize, setPageSize] = useState<number>(10);
const [offset, setOffset] = useState<number>(0);
@@ -158,7 +159,7 @@ function LogsPanelComponent({
/>
</OverlayScrollbar>
</div>
{!widget.query.builder.queryData[0].limit && (
{!hidePagination && !widget.query.builder.queryData[0].limit && (
<div className="controller">
<Controls
totalCount={totalCount}
@@ -198,6 +199,7 @@ export type LogsPanelComponentProps = {
>;
widget: Widgets;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
hidePagination?: boolean;
};
export default LogsPanelComponent;

View File

@@ -3,17 +3,36 @@
flex-direction: row;
.meter-explorer-quick-filters-section {
width: 280px;
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.meter-explorer-content-section {
width: 100%;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;
@@ -78,7 +97,9 @@
&.quick-filters-open {
.meter-explorer-content-section {
width: calc(100% - 280px);
flex: 1;
width: auto;
min-width: 0;
}
}

View File

@@ -7,6 +7,7 @@ import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
@@ -18,6 +19,8 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -30,6 +33,10 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
const {
handleRunQuery,
@@ -55,6 +62,16 @@ function Explorer(): JSX.Element {
const [showQuickFilters, setShowQuickFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
@@ -127,10 +144,19 @@ function Explorer(): JSX.Element {
'quick-filters-open': showQuickFilters,
})}
>
<div
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-meter-explorer"
@@ -142,7 +168,7 @@ function Explorer(): JSX.Element {
setShowQuickFilters(!showQuickFilters);
}}
/>
</div>
</ResizableBox>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">

View File

@@ -1,6 +1,14 @@
.metrics-explorer-explore-container {
padding-bottom: 80px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;

View File

@@ -1,4 +1,12 @@
.dashboard-navigation {
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.run-query-dashboard-btn {
min-width: 180px;
}

View File

@@ -9,6 +9,7 @@ function ListPanelWrapper({
queryResponse,
setRequestData,
onColumnWidthsChange,
hidePagination,
}: PanelWrapperProps): JSX.Element {
const dataSource = widget.query.builder?.queryData[0]?.dataSource;
@@ -23,6 +24,7 @@ function ListPanelWrapper({
queryResponse={queryResponse}
setRequestData={setRequestData}
onColumnWidthsChange={onColumnWidthsChange}
hidePagination={hidePagination}
/>
);
}
@@ -32,6 +34,7 @@ function ListPanelWrapper({
queryResponse={queryResponse}
setRequestData={setRequestData}
onColumnWidthsChange={onColumnWidthsChange}
hidePagination={hidePagination}
/>
);
}

View File

@@ -26,6 +26,7 @@ function PanelWrapper({
panelMode,
enableDrillDown = false,
onColumnWidthsChange,
hidePagination,
}: PanelWrapperProps): JSX.Element {
const Component = PanelTypeVsPanelWrapper[
selectedGraph || widget.panelTypes
@@ -76,6 +77,7 @@ function PanelWrapper({
enableDrillDown={enableDrillDown}
onColumnWidthsChange={onColumnWidthsChange}
groupByPerQuery={groupByPerQuery}
hidePagination={hidePagination}
/>
);
}

View File

@@ -32,6 +32,7 @@ export type PanelWrapperProps = {
panelMode: PanelMode;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
hidePagination?: boolean;
};
export type TooltipData = {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useRef } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyWidget from 'container/GridCardLayout/EmptyWidget';
@@ -6,11 +6,12 @@ import WidgetGraphComponent from 'container/GridCardLayout/GridCard/WidgetGraphC
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { isEqual } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
import { DataSource } from 'types/common/queryBuilder';
import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getPublicPanelRequestData } from './utils';
function Panel({
widget,
index,
@@ -27,54 +28,27 @@ function Panel({
const graphRef = useRef<HTMLDivElement>(null);
const updatedQuery = widget?.query;
const requestData: GetQueryResultsProps = useMemo(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
return {
selectedTime: widget?.timePreferance,
graphType: getGraphType(widget.panelTypes),
// State (not memo) so LIST panels get a setRequestData — ListPanelWrapper
// renders nothing without one.
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() =>
getPublicPanelRequestData({ widget, startTime, endTime }),
);
useEffect(() => {
if (!isEqual(updatedQuery, requestData.query)) {
setRequestData((prev) => ({
...prev,
query: updatedQuery,
variables: {}, // we are not supporting variables in public dashboards
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: startTime,
end: endTime,
originalGraphType: widget.panelTypes,
};
}));
}
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
const updatedQueryForList = {
...updatedQuery,
builder: {
...updatedQuery.builder,
queryData: updatedQuery.builder.queryData.map((qd, i) =>
i === 0 ? { ...qd, pageSize: 10 } : qd,
),
},
};
return {
query: updatedQueryForList,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {
pagination: {
offset: 0,
limit: updatedQuery.builder.queryData[0].limit || 0,
},
// we do not need select columns in case of logs
selectColumns:
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
},
fillGaps: widget.fillSpans,
start: startTime,
end: endTime,
};
}, [widget, updatedQuery, startTime, endTime]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updatedQuery]);
const queryResponse = useGetQueryRange(
{
...requestData,
start: startTime,
end: endTime,
originalGraphType: widget?.panelTypes,
},
ENTITY_VERSION_V5,
@@ -140,6 +114,8 @@ function Panel({
headerMenuList={[]}
isWarning={false}
isFetchingResponse={queryResponse.isFetching || queryResponse.isLoading}
setRequestData={setRequestData}
hidePagination
onDragSelect={onDragSelect}
/>
)}

View File

@@ -19,9 +19,14 @@ jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
},
}));
const widgetGraphProps = jest.fn();
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="widget-graph" />,
default: (props: { setRequestData?: unknown }): JSX.Element => {
widgetGraphProps(props);
return <div data-testid="widget-graph" />;
},
}));
const buildWidget = (id: string): Widgets =>
@@ -39,6 +44,22 @@ const buildWidget = (id: string): Widgets =>
describe('Public dashboard Panel', () => {
beforeEach(() => {
useGetQueryRangeMock.mockClear();
widgetGraphProps.mockClear();
});
it('forwards a setRequestData setter so LIST panels render (bug 3646)', () => {
render(
<Panel
widget={buildWidget('widget-a')}
index={0}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>,
);
const props = widgetGraphProps.mock.calls[0][0];
expect(typeof props.setRequestData).toBe('function');
});
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {

View File

@@ -0,0 +1,53 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { Widgets } from 'types/api/dashboard/getAll';
import { DataSource } from 'types/common/queryBuilder';
import { getGraphType } from 'utils/getGraphType';
// Builds the useGetQueryRange payload for a public-dashboard panel, mirroring the
// authenticated GridCard.
export const getPublicPanelRequestData = ({
widget,
startTime,
endTime,
}: {
widget: Widgets;
startTime: number;
endTime: number;
}): GetQueryResultsProps => {
const updatedQuery = widget?.query;
if (widget.panelTypes !== PANEL_TYPES.LIST) {
return {
selectedTime: widget?.timePreferance,
graphType: getGraphType(widget.panelTypes),
query: updatedQuery,
variables: {}, // we are not supporting variables in public dashboards
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: startTime,
end: endTime,
originalGraphType: widget.panelTypes,
};
}
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
return {
query: updatedQuery,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {
pagination: {
offset: 0,
limit: updatedQuery.builder.queryData[0].limit || 0,
},
// we do not need select columns in case of logs
selectColumns:
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
},
fillGaps: widget.fillSpans,
start: startTime,
end: endTime,
};
};

View File

@@ -31,8 +31,14 @@
min-width: 32px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
--periscope-btn-border-color: var(
--query-builder-v2-border-color,
var(--l2-border)
);
--periscope-btn-background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -27,8 +27,11 @@
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
background-color: var(--l1-border) !important;
opacity: 0.8;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
margin-left: -1px;
&:disabled {
opacity: 0.4;
@@ -86,8 +89,8 @@
border-bottom-left-radius: 3px;
.ant-select-selector {
border: none;
background: var(--l3-background);
border: 1px solid var(--query-builder-v2-border-color, none);
background: var(--query-builder-v2-background-color, var(--l3-background));
}
&.showInput {
@@ -101,12 +104,20 @@
.query-function-value {
width: 70px;
border-left: 0;
background: var(--l2-background);
background: var(--query-builder-v2-background-color, var(--l2-background));
border-radius: 0;
border: 1px solid transparent;
border-top: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-bottom: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
height: 32px;
&:focus {
border-color: transparent !important;
border-color: var(--internal-ant-border-color-focus);
}
&:hover {
border-color: var(--internal-ant-border-color-hover);
}
}
@@ -114,7 +125,7 @@
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-left: 1px solid var(--l2-border);
border-left: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;

View File

@@ -48,6 +48,8 @@ describe('CreateRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -77,6 +77,8 @@ describe('EditRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -409,6 +409,8 @@ describe('ViewRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -203,8 +203,9 @@ export const routesToSkip = [
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_BASE,
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -37,6 +37,7 @@ function TracesTableComponent({
queryResponse,
setRequestData,
onColumnWidthsChange,
hidePagination,
}: TracesTableComponentProps): JSX.Element {
const [pagination, setPagination] = useState<Pagination>({
offset: 0,
@@ -139,34 +140,36 @@ function TracesTableComponent({
/>
</OverlayScrollbar>
</div>
<div className="controller">
<Controls
totalCount={totalCount}
perPageOptions={PER_PAGE_OPTIONS}
isLoading={queryResponse.isFetching}
offset={pagination.offset}
countPerPage={pagination.limit}
handleNavigatePrevious={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset - pagination.limit,
});
}}
handleNavigateNext={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset + pagination.limit,
});
}}
handleCountItemsPerPageChange={(value): void => {
handlePaginationChange({
...pagination,
limit: value,
offset: 0,
});
}}
/>
</div>
{!hidePagination && (
<div className="controller">
<Controls
totalCount={totalCount}
perPageOptions={PER_PAGE_OPTIONS}
isLoading={queryResponse.isFetching}
offset={pagination.offset}
countPerPage={pagination.limit}
handleNavigatePrevious={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset - pagination.limit,
});
}}
handleNavigateNext={(): void => {
handlePaginationChange({
...pagination,
offset: pagination.offset + pagination.limit,
});
}}
handleCountItemsPerPageChange={(value): void => {
handlePaginationChange({
...pagination,
limit: value,
offset: 0,
});
}}
/>
</div>
)}
</div>
);
}
@@ -178,6 +181,7 @@ export type TracesTableComponentProps = {
>;
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
hidePagination?: boolean;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
};

View File

@@ -0,0 +1,30 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useLogsData } from '../useLogsData';
describe('useLogsData', () => {
// Public dashboards redact the widget query (orderBy/filter/limit stripped),
// so a LIST query can arrive with no orderBy — the hook must not crash on it.
it('does not crash when the query has no orderBy', () => {
const stagedQuery = {
builder: {
queryData: [{ dataSource: 'logs', queryName: 'A', disabled: false }],
},
} as unknown as Query;
const { result } = renderHook(
() =>
useLogsData({
result: undefined,
panelType: PANEL_TYPES.LIST,
stagedQuery,
}),
{ wrapper: AllTheProviders },
);
expect(result.current.logs).toStrictEqual([]);
});
});

View File

@@ -30,4 +30,6 @@ export const useGetFieldKeys = ({
queryKey: ['fieldKeys', signal, name],
queryFn: () => getFieldKeys(signal, name),
enabled,
// Keep prior keys during a search refetch so the dropdown doesn't blank out.
keepPreviousData: true,
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import debounce from 'lodash-es/debounce';
export type Dimensions = {
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
height: ref.current?.clientHeight || 0,
});
// Measure before paint so the first frame has real dimensions, not 0 (the
// debounced observer below only catches up after paint → layout jump).
useLayoutEffect(() => {
const node = ref.current;
if (node) {
setSize({ width: node.clientWidth, height: node.clientHeight });
}
}, [ref]);
useEffect(() => {
const handleResize = debounce(
(entries: ResizeObserverEntry[]) => {

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