Compare commits

..

23 Commits

Author SHA1 Message Date
Ashwin Bhatkal
90e0cb5bb5 Merge branch 'main' into feat/dashboard-v2-variable-orchestration 2026-07-08 01:41:42 +05:30
Ashwin Bhatkal
cec72e25fd feat(dashboard-v2): link variables to panels (#12003)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* 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
Ashwin Bhatkal
256289614b refactor(dashboard-v2): extract add-variable buttons into components 2026-07-08 01:23:04 +05:30
Ashwin Bhatkal
d1b99c203f 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.
2026-07-08 01:23:04 +05:30
Ashwin Bhatkal
4bbef56d00 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.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
bee2b01d91 feat(dashboard-v2): add a Configure step to the dashboard empty state 2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
fcd7196f37 feat(dashboard-v2): raise dynamic-variable attribute search debounce to 500ms 2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
b66b8167d6 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.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
6e74eaee74 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.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
cc69ada22c 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.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
3ca7b6da89 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.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
bf82650c9d 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.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
78720ff018 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.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
49e5b61362 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-08 01:15:16 +05:30
Ashwin Bhatkal
695d50c270 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.
2026-07-08 01:02:09 +05:30
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
176 changed files with 4592 additions and 1492 deletions

View File

@@ -102,44 +102,3 @@ jobs:
run: |
cd frontend && pnpm generate:config:web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)
code-quality:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: install-frontend
run: cd frontend && pnpm install
- name: get-new-files
id: new-files
run: |
cd frontend
NEW_TS=$(git diff --name-only --diff-filter=A origin/main...HEAD -- src/ | grep -E '\.(ts|tsx)$' | tr '\n' ' ' || true)
NEW_SCSS=$(git diff --name-only --diff-filter=A origin/main...HEAD -- src/ | grep -E '\.scss$' | tr '\n' ' ' || true)
echo "ts=$NEW_TS" >> $GITHUB_OUTPUT
echo "scss=$NEW_SCSS" >> $GITHUB_OUTPUT
echo "New TS files: $NEW_TS"
echo "New SCSS files: $NEW_SCSS"
- name: lint-new-ts-files
if: steps.new-files.outputs.ts != ''
run: |
cd frontend
pnpm oxlint -c .oxlintrc.strict.json ${{ steps.new-files.outputs.ts }}
- name: lint-new-scss-files
if: steps.new-files.outputs.scss != ''
run: |
cd frontend
pnpm stylelint --config .stylelintrc.strict.cjs ${{ steps.new-files.outputs.scss }}

View File

@@ -295,18 +295,6 @@
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
"signoz/no-dashboard-fetch-outside-root": "error",
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
"signoz/no-raw-html-text-elements": "warn",
// Bans raw span/p/h1-6 elements - use Typography component instead
"signoz/no-multiple-components": "warn",
// Bans multiple component exports per file - split into separate files
"signoz/no-inline-helpers": "warn",
// Bans too many helper functions in component files - move to utils.ts
"signoz/max-component-lines": ["warn", { "max": 300 }],
// Warns when component files exceed max lines - split into smaller components
"signoz/no-getByText": "warn",
// Bans getByText in tests - use getByTestId or getByRole instead
"signoz/no-fireEvent": "warn",
// Bans fireEvent in tests - use userEvent instead
"no-restricted-globals": [
"error",
{

View File

@@ -1,18 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["./.oxlintrc.json"],
"env": {
"builtin": true,
"es2021": true,
"browser": true,
"jest": true,
"node": true
},
"rules": {
"signoz/no-raw-html-text-elements": "error",
"signoz/no-multiple-components": "error",
"signoz/no-inline-helpers": "error",
"signoz/no-getByText": "error",
"signoz/no-fireEvent": "error"
}
}

View File

@@ -12,17 +12,11 @@ module.exports = {
),
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
path.join(__dirname, 'stylelint-rules/no-font-properties.js'),
path.join(__dirname, 'stylelint-rules/no-primitive-color-tokens.js'),
path.join(__dirname, 'stylelint-rules/no-styles-scss.js'),
],
customSyntax: 'postcss-scss',
rules: {
// Applies to all SCSS files
'local/no-unsupported-asset-url': true,
'local/no-font-properties': [true, { severity: 'warning' }],
'local/no-primitive-color-tokens': [true, { severity: 'warning' }],
'local/no-styles-scss': [true, { severity: 'warning' }],
},
overrides: [
{

View File

@@ -1,14 +0,0 @@
// Strict config for new files - rules that are warnings in base config become errors here
// oxlint-disable-next-line typescript/no-require-imports
const baseConfig = require('./.stylelintrc.cjs');
module.exports = {
...baseConfig,
rules: {
...baseConfig.rules,
// Override warnings to errors for new files
'local/no-font-properties': true, // error (no severity = error)
'local/no-primitive-color-tokens': true,
'local/no-styles-scss': true,
},
};

View File

@@ -16,8 +16,6 @@
"lint:generated": "oxlint ./src/api/generated --fix",
"lint:fix": "oxlint ./src --fix",
"lint:styles": "stylelint \"src/**/*.scss\"",
"lint:strict": "oxlint -c .oxlintrc.strict.json",
"lint:styles:strict": "stylelint --config .stylelintrc.strict.cjs",
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
@@ -81,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

@@ -1,71 +0,0 @@
/**
* Rule: max-component-lines
*
* Warns when a component file exceeds configured line limit.
* Large components should be split into smaller ones.
*
* Config: ["warn", { "max": 300 }]
* Default: 300 lines
*
* This rule should always be "warn" (never "error").
*/
const DEFAULT_MAX_LINES = 300;
export default {
meta: {
docs: {
description: 'Warn when component files exceed a line limit',
category: 'Code Quality',
},
schema: [
{
type: 'object',
properties: {
max: {
type: 'number',
minimum: 1,
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options?.[0] || {};
const maxLines = options.max || DEFAULT_MAX_LINES;
const filename = context.getFilename();
// Only check .tsx files (component files)
if (!/\.tsx$/.test(filename)) {
return {};
}
// Skip test files
if (/\.(test|spec)\.tsx$/.test(filename)) {
return {};
}
// Skip utility/type files
if (
/\/(utils|helpers|constants|types|hooks)\.(ts|tsx)$/.test(filename) ||
/\/(utils|helpers|constants|types)\//.test(filename)
) {
return {};
}
return {
Program(node) {
const sourceCode = context.getSourceCode();
const lines = sourceCode.lines?.length || sourceCode.getText().split('\n').length;
if (lines > maxLines) {
context.report({
node,
message: `Component file has ${lines} lines (max ${maxLines}). Consider splitting into smaller components or extracting logic into hooks/utils.`,
});
}
},
};
},
};

View File

@@ -1,48 +0,0 @@
/**
* Rule: no-fireEvent
*
* Bans fireEvent in test files. Use userEvent instead for more realistic
* user interaction simulation.
*
* BAD:
* fireEvent.click(button)
* fireEvent.change(input, { target: { value: 'test' } })
*
* GOOD:
* await userEvent.click(button)
* await userEvent.type(input, 'test')
*/
export default {
create(context) {
return {
// fireEvent.click(...), fireEvent.change(...)
MemberExpression(node) {
if (
node.object.type === 'Identifier' &&
node.object.name === 'fireEvent'
) {
context.report({
node,
message:
"Avoid 'fireEvent'. Use userEvent from @testing-library/user-event for more realistic user interactions.",
});
}
},
// import { fireEvent } from '@testing-library/react'
ImportSpecifier(node) {
if (
node.imported.type === 'Identifier' &&
node.imported.name === 'fireEvent'
) {
context.report({
node,
message:
"Avoid importing 'fireEvent'. Use userEvent from @testing-library/user-event instead.",
});
}
},
};
},
};

View File

@@ -1,53 +0,0 @@
/**
* Rule: no-getByText
*
* Bans getByText, queryByText, findByText, getAllByText, queryAllByText, findAllByText
* in test files. Use getByTestId or getByRole instead.
*
* BAD:
* screen.getByText('Submit')
* getByText('Submit')
*
* GOOD:
* screen.getByTestId('submit-button')
* screen.getByRole('button', { name: 'Submit' })
*/
const BANNED_METHODS = new Set([
'getByText',
'queryByText',
'findByText',
'getAllByText',
'queryAllByText',
'findAllByText',
]);
export default {
create(context) {
return {
CallExpression(node) {
let methodName = null;
// screen.getByText(...) or result.getByText(...)
if (
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier'
) {
methodName = node.callee.property.name;
}
// Direct getByText(...) from destructured import
if (node.callee.type === 'Identifier') {
methodName = node.callee.name;
}
if (methodName && BANNED_METHODS.has(methodName)) {
context.report({
node,
message: `Avoid '${methodName}'. Use getByTestId or getByRole instead for more stable selectors.`,
});
}
},
};
},
};

View File

@@ -1,161 +0,0 @@
/**
* Rule: no-inline-helpers
*
* Bans too many helper functions in component files.
* Helper functions should be moved to utils.ts or specialized files.
*
* Exempt:
* - Hooks (use* functions)
* - Exported functions (they're intentionally in the file)
* - Files named utils.ts, helpers.ts, constants.ts
*
* Threshold: More than 3 private helper functions triggers warning.
*/
const MAX_HELPERS = 3;
function isHookName(name) {
return /^use[A-Z]/.test(name);
}
function isComponentName(name) {
return /^[A-Z]/.test(name);
}
export default {
create(context) {
const filename = context.getFilename();
// Skip utility files
if (
/\/(utils|helpers|constants|types|hooks)\.(ts|tsx|js|jsx)$/.test(filename) ||
/\/(utils|helpers|constants|types)\//.test(filename)
) {
return {};
}
// Skip test files
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(filename)) {
return {};
}
const privateHelpers = [];
const exportedNames = new Set();
let hasComponent = false;
return {
// Track exported names
ExportNamedDeclaration(node) {
if (node.declaration) {
if (node.declaration.type === 'FunctionDeclaration' && node.declaration.id) {
exportedNames.add(node.declaration.id.name);
if (isComponentName(node.declaration.id.name)) {
hasComponent = true;
}
}
if (node.declaration.type === 'VariableDeclaration') {
for (const decl of node.declaration.declarations) {
if (decl.id.type === 'Identifier') {
exportedNames.add(decl.id.name);
if (isComponentName(decl.id.name)) {
hasComponent = true;
}
}
}
}
}
},
ExportDefaultDeclaration(node) {
if (
node.declaration.type === 'FunctionDeclaration' &&
node.declaration.id &&
isComponentName(node.declaration.id.name)
) {
hasComponent = true;
}
if (node.declaration.type === 'Identifier') {
exportedNames.add(node.declaration.name);
}
},
// Track private function declarations
FunctionDeclaration(node) {
if (!node.id) {
return;
}
const name = node.id.name;
// Will check if exported later
if (
!isHookName(name) &&
!isComponentName(name) &&
node.parent.type !== 'ExportNamedDeclaration' &&
node.parent.type !== 'ExportDefaultDeclaration'
) {
privateHelpers.push({ name, node });
}
},
// Track private arrow/function expressions
VariableDeclaration(node) {
// Skip if this is an export
if (
node.parent.type === 'ExportNamedDeclaration' ||
node.parent.type === 'Program'
) {
for (const decl of node.declarations) {
if (decl.id.type !== 'Identifier') {
continue;
}
const name = decl.id.name;
const init = decl.init;
// Only track functions
if (
!init ||
(init.type !== 'ArrowFunctionExpression' &&
init.type !== 'FunctionExpression')
) {
continue;
}
// Skip hooks, components, and exports
if (
!isHookName(name) &&
!isComponentName(name) &&
node.parent.type !== 'ExportNamedDeclaration'
) {
privateHelpers.push({ name, node: decl });
}
}
}
},
'Program:exit'() {
// Only check files with components
if (!hasComponent) {
return;
}
// Filter out any that ended up exported
const actualPrivate = privateHelpers.filter(
(h) => !exportedNames.has(h.name),
);
if (actualPrivate.length > MAX_HELPERS) {
// Report on helpers beyond the threshold
for (let i = MAX_HELPERS; i < actualPrivate.length; i++) {
const { name, node } = actualPrivate[i];
context.report({
node,
message: `Too many helper functions (${actualPrivate.length}) in component file. Move '${name}' and other helpers to utils.ts. Helpers: ${actualPrivate.map((h) => h.name).join(', ')}.`,
});
}
}
},
};
},
};

View File

@@ -1,142 +0,0 @@
/**
* Rule: no-multiple-components
*
* Bans multiple React component exports in a single file.
* Each component should have its own file.
*
* BAD:
* export function ComponentA() { return <div /> }
* export function ComponentB() { return <div /> }
*
* GOOD:
* // ComponentA.tsx
* export function ComponentA() { return <div /> }
*
* // ComponentB.tsx (separate file)
* export function ComponentB() { return <div /> }
*
* Note: index.tsx files are exempt (re-export barrels).
*/
function isPascalCase(name) {
return /^[A-Z][a-zA-Z0-9]*$/.test(name);
}
function hasJSXReturn(node) {
if (!node.body) {
return false;
}
// Arrow function with direct JSX return: () => <div />
if (node.body.type === 'JSXElement' || node.body.type === 'JSXFragment') {
return true;
}
// Function body - check return statements
if (node.body.type === 'BlockStatement') {
for (const stmt of node.body.body) {
if (
stmt.type === 'ReturnStatement' &&
stmt.argument &&
(stmt.argument.type === 'JSXElement' ||
stmt.argument.type === 'JSXFragment' ||
// Conditional: condition ? <A /> : <B />
(stmt.argument.type === 'ConditionalExpression' &&
(stmt.argument.consequent.type === 'JSXElement' ||
stmt.argument.alternate.type === 'JSXElement')) ||
// Logical: condition && <A />
(stmt.argument.type === 'LogicalExpression' &&
(stmt.argument.left.type === 'JSXElement' ||
stmt.argument.right.type === 'JSXElement')))
) {
return true;
}
}
}
return false;
}
export default {
create(context) {
const exportedComponents = [];
return {
// export function ComponentName() { }
ExportNamedDeclaration(node) {
if (!node.declaration) {
return;
}
// function ComponentName() {}
if (node.declaration.type === 'FunctionDeclaration') {
const name = node.declaration.id?.name;
if (name && isPascalCase(name) && hasJSXReturn(node.declaration)) {
exportedComponents.push({ name, node });
}
}
// const ComponentName = () => {} or const ComponentName = function() {}
if (node.declaration.type === 'VariableDeclaration') {
for (const declarator of node.declaration.declarations) {
const name = declarator.id?.name;
if (!name || !isPascalCase(name)) {
continue;
}
const init = declarator.init;
if (!init) {
continue;
}
// Arrow function or function expression
if (
(init.type === 'ArrowFunctionExpression' ||
init.type === 'FunctionExpression') &&
hasJSXReturn(init)
) {
exportedComponents.push({ name, node });
}
// React.memo(Component) or memo(Component)
if (
init.type === 'CallExpression' &&
((init.callee.type === 'Identifier' &&
(init.callee.name === 'memo' ||
init.callee.name === 'forwardRef')) ||
(init.callee.type === 'MemberExpression' &&
init.callee.object.name === 'React' &&
(init.callee.property.name === 'memo' ||
init.callee.property.name === 'forwardRef')))
) {
exportedComponents.push({ name, node });
}
}
}
},
// export default function ComponentName() { }
ExportDefaultDeclaration(node) {
if (node.declaration.type === 'FunctionDeclaration') {
const name = node.declaration.id?.name;
if (name && isPascalCase(name) && hasJSXReturn(node.declaration)) {
exportedComponents.push({ name, node });
}
}
},
'Program:exit'() {
if (exportedComponents.length > 1) {
// Report on all but the first component
for (let i = 1; i < exportedComponents.length; i++) {
const { name, node } = exportedComponents[i];
context.report({
node,
message: `Multiple component exports in same file. Move '${name}' to its own file. Found ${exportedComponents.length} components: ${exportedComponents.map((c) => c.name).join(', ')}.`,
});
}
}
},
};
},
};

View File

@@ -1,39 +0,0 @@
/**
* Rule: no-raw-html-text-elements
*
* Bans raw HTML text elements (span, p, h1-h6) in JSX.
* Use Typography component from @signozhq/ui instead.
*
* BAD:
* <span>Hello</span>
* <p>Paragraph</p>
* <h1>Title</h1>
*
* GOOD:
* <Typography>Hello</Typography>
* <Typography.Text>Hello</Typography.Text>
* <Typography.Title level={1}>Title</Typography.Title>
*/
const BANNED_ELEMENTS = new Set(['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
export default {
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier') {
return;
}
const elementName = node.name.name;
if (BANNED_ELEMENTS.has(elementName)) {
context.report({
node,
message: `Avoid raw '<${elementName}>' element. Use Typography component from @signozhq/ui instead.`,
});
}
},
};
},
};

View File

@@ -13,12 +13,6 @@ import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
import noGetByText from './rules/no-getByText.mjs';
import noFireEvent from './rules/no-fireEvent.mjs';
import noRawHtmlTextElements from './rules/no-raw-html-text-elements.mjs';
import noMultipleComponents from './rules/no-multiple-components.mjs';
import noInlineHelpers from './rules/no-inline-helpers.mjs';
import maxComponentLines from './rules/max-component-lines.mjs';
export default {
meta: {
@@ -33,11 +27,5 @@ export default {
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
'no-getByText': noGetByText,
'no-fireEvent': noFireEvent,
'no-raw-html-text-elements': noRawHtmlTextElements,
'no-multiple-components': noMultipleComponents,
'no-inline-helpers': noInlineHelpers,
'max-component-lines': maxComponentLines,
},
};

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,17 @@ const routes: AppRoutes[] = [
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_BASE,
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_BASE',
key: 'LLM_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
},
];

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

@@ -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

@@ -90,7 +90,8 @@ const ROUTES = {
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
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

@@ -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

@@ -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}${

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

@@ -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

@@ -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,69 @@
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();
});
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('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,52 @@
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 Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
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();
const activeTab = pathname.startsWith(CONFIGURATION_KEY)
? CONFIGURATION_KEY
: OVERVIEW_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 />,
},
];
return { items, activeTab, onTabChange };
}

View File

@@ -3,12 +3,23 @@
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 {
@@ -78,7 +89,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

@@ -203,8 +203,8 @@ 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,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -20,12 +20,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.
.all-errors-quick-filter-section {
width: 260px;
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.all-errors-right-section {
width: calc(100% - 260px);
flex: 1;
min-width: 0;
}
}
}

View File

@@ -18,12 +18,18 @@ import Toolbar from 'container/Toolbar/Toolbar';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { routes } from './config';
import { useAllErrorsQueryState } from './QueryStateContext';
import './AllErrors.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function AllErrors(): JSX.Element {
const { pathname } = useLocation();
const { handleRunQuery } = useQueryBuilder();
@@ -55,17 +61,38 @@ function AllErrors(): JSX.Element {
setShowFilters((prev) => !prev);
};
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<section className={cx('all-errors-quick-filter-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="all-errors-quick-filter-section"
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-exceptions"
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</section>
</ResizableBox>
)}
<section
className={cx(

View File

@@ -5,3 +5,9 @@
justify-content: flex-end;
gap: 12px;
}
/* Actions / Configure / Edit-as-JSON: a flat l3 outline — no depth/shadow. */
.toolbarButton {
border: 1px solid var(--l3-border) !important;
box-shadow: none !important;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { generatePath } from 'react-router-dom';
import {
@@ -59,6 +59,8 @@ function DashboardActions({
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
@@ -72,6 +74,14 @@ function DashboardActions({
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
// Open the settings drawer when something in the tree requests it (e.g. the
// variables bar's "Add variable" button).
useEffect(() => {
if (settingsRequest) {
setIsSettingsDrawerOpen(true);
}
}, [settingsRequest]);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
@@ -201,6 +211,7 @@ function DashboardActions({
variant="solid"
color="secondary"
size="md"
className={styles.toolbarButton}
prefix={<Grid3X3 size="md" />}
testId="options"
>
@@ -212,6 +223,7 @@ function DashboardActions({
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
@@ -222,7 +234,11 @@ function DashboardActions({
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
@@ -231,6 +247,7 @@ function DashboardActions({
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}

View File

@@ -19,6 +19,7 @@ import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
@@ -99,7 +100,7 @@ function DashboardInfo({
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}

View File

@@ -8,6 +8,8 @@ type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
/** Unmount the content on close so it re-initializes on the next open. */
destroyOnClose?: boolean;
}>;
function SettingsDrawer({
@@ -15,6 +17,7 @@ function SettingsDrawer({
drawerTitle,
isOpen,
onClose,
destroyOnClose = false,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
@@ -23,6 +26,7 @@ function SettingsDrawer({
width="50%"
onClose={onClose}
open={isOpen}
destroyOnClose={destroyOnClose}
rootClassName={styles.settingsContainerRoot}
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}

View File

@@ -12,6 +12,7 @@ import { Input as AntdInput } from 'antd';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { Base64Icons } from '../utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
@@ -71,6 +72,7 @@ function DashboardInfoForm({
testId="dashboard-name"
className={styles.dashboardNameInput}
value={title}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>

View File

@@ -40,7 +40,7 @@ function DynamicVariableFields({
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const debouncedSearch = useDebounce(search, 500);
const apiSignal = signalForApi(signal);
const {
@@ -82,38 +82,14 @@ function DynamicVariableFields({
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
<Typography.Text className={styles.label}>Source</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
{/* Combined row retained from V1: the field on the left, `from` + the
telemetry source on the right. */}
<div className={cx(styles.row, styles.dynamicCombinedRow)}>
<CustomSelect
className={styles.searchSelect}
className={styles.dynamicFieldSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
placeholder="Select a field"
loading={isLoading}
options={options}
onSearch={setSearch}
@@ -126,6 +102,25 @@ function DynamicVariableFields({
showRetryButton={error ? isRetryableError(error) : true}
data-testid="variable-field-select"
/>
<Typography.Text className={styles.fromText}>from</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
<Select
className={styles.dynamicSourceSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>

View File

@@ -138,7 +138,7 @@
align-items: center;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
padding: 4px 16px !important;
white-space: nowrap;
border-radius: 0;
color: var(--l2-foreground);
@@ -157,6 +157,7 @@
.betaTag {
margin-left: 4px;
vertical-align: middle;
}
/* Query */
@@ -264,6 +265,7 @@
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
max-height: 120px;
overflow-y: auto;
}
@@ -329,15 +331,41 @@
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
.searchSelect,
.dynamicFieldSelect,
.dynamicSourceSelect {
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
}
/* Dynamic variable: field | "from" | info | source, retained from V1. */
.dynamicCombinedRow {
display: grid;
grid-template-columns: 1fr auto 16px 180px;
gap: 12px;
align-items: center;
}
.dynamicFieldSelect,
.dynamicSourceSelect {
width: 100%;
min-width: 0;
}
.fromText {
font-family: 'Space Mono';
font-size: 13px;
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.actionButtons {
width: 100%;
display: flex;

View File

@@ -46,8 +46,12 @@ export interface UseVariableForm {
handleSave: () => void;
}
const readDefaultValue = (model: VariableFormModel): string =>
((model.defaultValue as { value?: string })?.value ?? '') as string;
// `defaultValue` is a string | string[] on the wire; the editor uses a single
// string, so take the first when it's an array.
const readDefaultValue = (model: VariableFormModel): string => {
const dv = model.defaultValue;
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
};
/** Form state, derivations and handlers for the variable editor. */
export function useVariableForm({

View File

@@ -4,6 +4,7 @@ import { Typography } from '@signozhq/ui/typography';
import { Input as AntdInput } from 'antd';
import styles from './VariableInfoForm.module.scss';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../../constants';
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
interface VariableInfoFormProps {
@@ -30,6 +31,7 @@ function VariableInfoForm({
testId="variable-name"
className={styles.variableNameInput}
value={title}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => onTitleChange(e.target.value)}
placeholder="Unique name of the variable"
/>

View File

@@ -23,6 +23,11 @@ interface VariablesSettingsProps {
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
// The drawer destroys on close, so reading this once on mount is enough to
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
const openAddOnMount = useDashboardStore(
(s) => s.settingsRequest?.addVariable ?? false,
);
const { save, isSaving } = useSaveVariables();
const initialFormModels = useMemo(
@@ -38,7 +43,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [isEditing, setIsEditing] = useState<EditingState>(
openAddOnMount && isEditable ? { type: 'new' } : null,
);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);

View File

@@ -17,6 +17,7 @@ import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './DashboardSettings.module.scss';
interface DashboardSettingsProps {
@@ -38,6 +39,9 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
// Opened once per drawer mount (the drawer destroys on close); a deep-link
// request lands us on the right tab.
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
@@ -69,7 +73,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
);
return (
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
<TabsList variant="primary">
{Object.values(TabKeys).map((key) => (
<TabsTrigger value={key} key={key}>

View File

@@ -14,6 +14,7 @@ import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
@@ -86,6 +87,7 @@ function ConfigPane({
data-testid="panel-editor-v2-title"
value={spec.display.name}
placeholder="Panel title"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => setDisplayField('name', e.target.value)}
/>
</div>

View File

@@ -11,14 +11,18 @@ import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
showSwitchToView?: boolean;
onSave: () => void;
onSwitchToView?: () => void;
onClose: () => void;
}
function Header({
isDirty,
isSaving,
showSwitchToView = false,
onSave,
onSwitchToView,
onClose,
}: HeaderProps): JSX.Element {
const discard = useConfirmableAction(
@@ -49,6 +53,16 @@ function Header({
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
{showSwitchToView && (
<Button
variant="outlined"
color="secondary"
data-testid="panel-editor-v2-switch-to-view"
onClick={onSwitchToView}
>
Switch to View Mode
</Button>
)}
<Button
variant="solid"
color="primary"

View File

@@ -5,6 +5,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
@@ -42,6 +43,10 @@ interface PreviewPaneProps {
dashboardPreference?: DashboardPreference;
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
/** Opens the drill-down context menu; only the View modal wires it (the editor preview omits it). */
onClick?: AnyPanelInteractionProps['onClick'];
/** Arms the drill-down click on interactive renderers — the View modal enables it, the editor doesn't. */
enableDrillDown?: boolean;
}
/**
@@ -64,6 +69,8 @@ function PreviewPane({
hideHeader = false,
dashboardPreference,
onCloseStandaloneView,
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
@@ -96,6 +103,7 @@ function PreviewPane({
<PanelHeader
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
@@ -119,6 +127,8 @@ function PreviewPane({
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
onClick={onClick}
enableDrillDown={enableDrillDown}
/>
</div>
</div>

View File

@@ -48,6 +48,10 @@ jest.mock('../hooks/usePanelEditorSave', () => ({
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
useSwitchColumnsOnSignalChange: jest.fn(),
}));
const mockOnSwitchToView = jest.fn();
jest.mock('../hooks/useSwitchToViewMode', () => ({
useSwitchToViewMode: (): (() => void) => mockOnSwitchToView,
}));
jest.mock('../hooks/useSeedNewListColumns', () => ({
useSeedNewListColumns: jest.fn(),
}));
@@ -138,13 +142,16 @@ jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
default: (): JSX.Element => <div data-testid="list-columns" />,
}));
function makePanel(kind: string): DashboardtypesPanelDTO {
function makePanel(
kind: string,
queries: unknown[] = [],
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind, spec: {} },
queries: [],
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
@@ -159,12 +166,13 @@ const baseProps = {
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
draftOverrides?: { isSpecDirty?: boolean },
): void {
mockUseDraft.mockReturnValue({
draft: panel,
spec: panel.spec,
setSpec: mockSetSpec,
isSpecDirty: false,
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
});
mockUseQuery.mockReturnValue({
data: { response: undefined },
@@ -240,12 +248,38 @@ describe('PanelEditorContainer composition', () => {
);
});
it('marks a new panel dirty and always serializes its query', () => {
it('keeps a query-less new panel unsaveable but still serializes its seed query', () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({ alwaysSerializeQuery: true }),
);
// No query and no edits yet → nothing to save, so Save stays disabled.
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: false }),
);
});
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
const seededQuery = {
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
};
setup(makePanel('signoz/ListPanel', [seededQuery]), { isNew: true });
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: true }),
);
});
it('marks a new panel dirty once the user edits its spec', () => {
setup(
makePanel('signoz/TimeSeriesPanel'),
{ isNew: true },
{
isSpecDirty: true,
},
);
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: true }),
);
@@ -258,10 +292,30 @@ describe('PanelEditorContainer composition', () => {
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('offers Switch to View Mode for an existing panel', () => {
setup(makePanel('signoz/TimeSeriesPanel'));
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({
showSwitchToView: true,
onSwitchToView: expect.any(Function),
}),
);
});
it('hides Switch to View Mode for a new (unsaved) panel', () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ showSwitchToView: false }),
);
});
it('renders the list-columns editor only for list panels', () => {
setup(makePanel('signoz/ListPanel'));
expect(screen.getByTestId('list-columns')).toBeInTheDocument();

View File

@@ -0,0 +1,64 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useSwitchToViewMode } from '../useSwitchToViewMode';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockSearch = '';
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => new URLSearchParams(mockSearch),
}));
const query = { queryType: 'builder' } as unknown as Query;
describe('useSwitchToViewMode', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSearch = '';
});
function invoke(): void {
const { result } = renderHook(() =>
useSwitchToViewMode({
dashboardId: 'dash-1',
panelId: 'panel-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
}),
);
result.current();
}
it('opens the dashboard with the View modal seeded from the live query', () => {
invoke();
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const target = new URL(mockSafeNavigate.mock.calls[0][0], 'http://x');
expect(target.pathname).toBe('/dashboard/dash-1');
expect(target.searchParams.get('expandedWidgetId')).toBe('panel-1');
expect(target.searchParams.get('graphType')).toBe(PANEL_TYPES.TIME_SERIES);
expect(
JSON.parse(
decodeURIComponent(target.searchParams.get('compositeQuery') || ''),
),
).toStrictEqual(query);
});
it('carries dashboard variables through and drops other editor URL state', () => {
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
invoke();
const target = new URL(mockSafeNavigate.mock.calls[0][0], 'http://x');
expect(target.searchParams.get('variables')).toBe('{"a":1}');
// The stale editor query is replaced with the live one, not the URL leftover.
expect(target.searchParams.get('compositeQuery')).not.toBe('stale');
});
});

View File

@@ -0,0 +1,46 @@
import { useCallback } from 'react';
import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
interface UseSwitchToViewModeArgs {
dashboardId: string;
panelId: string;
panelType: PANEL_TYPES;
query: Query;
}
/**
* Callback that leaves the editor for the dashboard with this panel expanded in the
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
*/
export function useSwitchToViewMode({
dashboardId,
panelId,
panelType,
query,
}: UseSwitchToViewModeArgs): () => void {
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
return useCallback((): void => {
const params = new URLSearchParams();
const variables = urlQuery.get(QueryParams.variables);
if (variables) {
params.set(QueryParams.variables, variables);
}
params.set(QueryParams.expandedWidgetId, panelId);
params.set(QueryParams.graphType, panelType);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
);
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
}

View File

@@ -13,6 +13,7 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
@@ -28,6 +29,7 @@ import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
@@ -143,9 +145,13 @@ function PanelEditorContainer({
onSelectUnit: seedFormattingUnit,
});
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
// A new panel is savable once it has a query to run — List auto-seeds one; other
// kinds open query-less, so there's nothing to save until the user builds one.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
@@ -184,6 +190,13 @@ function PanelEditorContainer({
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
});
const onSave = useCallback(async (): Promise<void> => {
try {
// Bake the live query into the spec so unstaged edits are saved too.
@@ -200,7 +213,9 @@ function PanelEditorContainer({
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
onSave={onSave}
onSwitchToView={onSwitchToView}
onClose={onClose}
/>
<ResizablePanelGroup

View File

@@ -1,37 +1,60 @@
import { Clock, RotateCw } from '@signozhq/icons';
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
import PanelMessage from '../PanelMessage/PanelMessage';
import {
selectViewPanelExtendWindow,
useViewPanelStore,
} from '../../../store/useViewPanelStore';
import PanelLoader from '../PanelLoader/PanelLoader';
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
import { useExtendTimeWindow } from './useExtendTimeWindow';
interface NoDataProps {
/** Title override. Defaults to the time-range empty-state copy. */
title?: string;
/** Description override. Defaults to the "widen the range" hint. */
description?: string;
/** In flight over empty data → show the loader, not the empty state. */
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
'data-testid'?: string;
}
/**
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
* kind surfaces the same "no data" affordance when a query returns nothing.
* Shared empty-state for panel renderers. The query succeeded but returned nothing,
* so we offer to widen the time window — global by default, or the View modal's
* local window when it publishes one to the store — alongside a Retry that re-runs
* the query.
*/
function NoData({
title = 'No data in this time range',
description = 'Nothing in the selected window. Try widening the range.',
isFetching = false,
onRetry,
'data-testid': testId = 'panel-no-data',
}: NoDataProps): JSX.Element {
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
const globalExtend = useExtendTimeWindow();
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
if (isFetching) {
return <PanelLoader />;
}
const extendAction: PanelMessageAction | undefined =
canExtend && actionLabel
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
: undefined;
const retryAction: PanelMessageAction | undefined = onRetry
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
: undefined;
return (
<PanelMessage
icon={<Clock size={18} />}
title={title}
description={description}
action={
onRetry
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
: undefined
}
action={extendAction ?? retryAction}
secondaryAction={extendAction ? retryAction : undefined}
data-testid={testId}
/>
);

View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
import { ExtendTimeWindow } from '../extendWindow';
import NoData from '../NoData';
const mockUseExtendTimeWindow = jest.fn();
jest.mock('../useExtendTimeWindow', () => ({
useExtendTimeWindow: (): ExtendTimeWindow => mockUseExtendTimeWindow(),
}));
const inert: ExtendTimeWindow = {
canExtend: false,
actionLabel: null,
extend: jest.fn(),
};
function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
return {
canExtend: true,
actionLabel: 'Extend time range',
extend: jest.fn(),
...over,
};
}
describe('NoData', () => {
beforeEach(() => {
mockUseExtendTimeWindow.mockReturnValue(inert);
useViewPanelStore.setState({ viewPanelExtendWindow: null });
});
it('renders the empty-state title and hint', () => {
render(<NoData />);
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
expect(
screen.getByText('Nothing in the selected window. Try widening the range.'),
).toBeInTheDocument();
});
it('offers to extend the window as the primary action', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Extend time range');
// No retry handler → no secondary Retry button.
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
});
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData onRetry={onRetry} />);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
);
const retry = screen.getByTestId('panel-no-data-secondary-action');
expect(retry).toHaveTextContent('Retry');
fireEvent.click(retry);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('falls back to Retry as the sole action when the window cannot be widened', () => {
const onRetry = jest.fn();
render(<NoData onRetry={onRetry} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
fireEvent.click(action);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('prefers the View modal extender (store) over the global one', () => {
const globalExtend = jest.fn();
const storeExtend = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender({ extend: globalExtend }));
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(<NoData />);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
expect(globalExtend).not.toHaveBeenCalled();
});
it('renders no action when nothing can be widened and no retry handler', () => {
render(<NoData />);
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
});
it('shows the panel loader (not the empty state) while refetching', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData isFetching />);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
});
it('honours the data-testid override for the number panel', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData data-testid="number-panel-no-data" />);
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,96 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { useExtendTimeWindow } from '../useExtendTimeWindow';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
const NS_PER_MS = 1e6;
const MINUTE_MS = 60 * 1000;
const DAY_MS = 24 * 60 * MINUTE_MS;
// A window anchored in the past so the zoom-out ladder is deterministic (real
// Date.now() is far ahead, so it always center-anchors into a custom range).
const PAST_END_MS = 1_600_003_600_000;
const mockStore = configureStore([thunk]);
function globalTimeState(
partial: Pick<GlobalReducer, 'selectedTime' | 'minTime' | 'maxTime'>,
): GlobalReducer {
return {
loading: false,
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '',
...partial,
};
}
function setup(globalTime: GlobalReducer): {
result: { current: ReturnType<typeof useExtendTimeWindow> };
store: ReturnType<typeof mockStore>;
} {
const store = mockStore({ globalTime });
const { result } = renderHook(() => useExtendTimeWindow(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<Provider store={store}>
<MemoryRouter>{children}</MemoryRouter>
</Provider>
),
});
return { result, store };
}
describe('useExtendTimeWindow', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
});
it('reports it can extend and dispatches a wider range', () => {
const { result, store } = setup(
globalTimeState({
selectedTime: '30m',
minTime: (PAST_END_MS - 30 * MINUTE_MS) * NS_PER_MS,
maxTime: PAST_END_MS * NS_PER_MS,
}),
);
expect(result.current.canExtend).toBe(true);
expect(result.current.actionLabel).toBe('Extend time range');
act(() => result.current.extend());
const [action] = store.getActions();
expect(action.type).toBe(UPDATE_TIME_INTERVAL);
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
});
it('cannot extend past the widest window', () => {
const { result, store } = setup(
globalTimeState({
selectedTime: 'custom',
minTime: (PAST_END_MS - 40 * DAY_MS) * NS_PER_MS,
maxTime: PAST_END_MS * NS_PER_MS,
}),
);
expect(result.current.canExtend).toBe(false);
expect(result.current.actionLabel).toBeNull();
act(() => result.current.extend());
expect(store.getActions()).toHaveLength(0);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,26 @@
import type { ZoomOutResult } from 'lib/zoomOutUtils';
export interface ExtendTimeWindow {
canExtend: boolean;
/** Button copy; null when the window can't be widened further. */
actionLabel: string | null;
extend: () => void;
}
const EXTEND_ACTION_LABEL = 'Extend time range';
/**
* Adapts a zoom-out ladder result (`getNextZoomOutRange`) plus its apply callback
* into the panel empty-state extender. A `null` result means the window is already
* at the widest ladder step, so there is nothing to extend to.
*/
export function buildExtendWindow(
result: ZoomOutResult | null,
extend: () => void,
): ExtendTimeWindow {
return {
canExtend: result !== null,
actionLabel: result ? EXTEND_ACTION_LABEL : null,
extend,
};
}

View File

@@ -0,0 +1,26 @@
// eslint-disable-next-line no-restricted-imports -- global time lives in redux; V2 time dispatch is not yet migrated
import { useSelector } from 'react-redux';
import { useZoomOut } from 'hooks/useZoomOut';
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { buildExtendWindow, ExtendTimeWindow } from './extendWindow';
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
/**
* Default empty-state extender: widen the dashboard's global time via the shared
* zoom-out ladder. Dispatch + URL sync live in `useZoomOut`; the View modal
* overrides this with its own local extender via the store.
*/
export function useExtendTimeWindow(): ExtendTimeWindow {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const extend = useZoomOut();
const result = getNextZoomOutRange(
Math.floor(minTime / NANO_SECOND_MULTIPLIER),
Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
);
return buildExtendWindow(result, extend);
}

View File

@@ -0,0 +1,10 @@
// Centred full-body spinner (first-fetch + refetch-over-empty).
.loader {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
min-height: 0;
min-width: 0;
}

View File

@@ -0,0 +1,21 @@
import { Spin } from 'antd';
import { Loader } from '@signozhq/icons';
import styles from './PanelLoader.module.scss';
interface PanelLoaderProps {
'data-testid'?: string;
}
/** Centred full-panel spinner shown while a query is in flight with nothing to display yet. */
function PanelLoader({
'data-testid': testId = 'panel-loading',
}: PanelLoaderProps): JSX.Element {
return (
<div className={styles.loader} data-testid={testId}>
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
}
export default PanelLoader;

View File

@@ -45,6 +45,10 @@
overflow-wrap: anywhere;
}
.action {
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 8px;
}

View File

@@ -20,6 +20,8 @@ interface PanelMessageProps {
description?: string;
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
action?: PanelMessageAction;
/** Second call-to-action rendered beside `action` (e.g. Extend + Retry). */
secondaryAction?: PanelMessageAction;
/** `danger` tints the icon for failure states; `neutral` for empty states. */
tone?: 'neutral' | 'danger';
'data-testid'?: string;
@@ -34,9 +36,18 @@ function PanelMessage({
title,
description,
action,
secondaryAction,
tone = 'neutral',
'data-testid': testId,
}: PanelMessageProps): JSX.Element {
const actions: Array<{ suffix: string; action: PanelMessageAction }> = [];
if (action) {
actions.push({ suffix: 'action', action });
}
if (secondaryAction) {
actions.push({ suffix: 'secondary-action', action: secondaryAction });
}
return (
<div className={styles.message} data-testid={testId}>
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
@@ -48,18 +59,22 @@ function PanelMessage({
{description}
</Typography.Text>
)}
{action && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={action.icon}
onClick={action.onClick}
className={styles.action}
data-testid={testId ? `${testId}-action` : undefined}
>
{action.label}
</Button>
{actions.length > 0 && (
<div className={styles.actions}>
{actions.map(({ suffix, action: item }) => (
<Button
key={suffix}
variant="outlined"
color="secondary"
size="sm"
prefix={item.icon}
onClick={item.onClick}
data-testid={testId ? `${testId}-${suffix}` : undefined}
>
{item.label}
</Button>
))}
</div>
)}
</div>
);

View File

@@ -43,4 +43,28 @@ describe('PanelMessage', () => {
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders both actions side by side and wires each onClick', () => {
const onPrimary = jest.fn();
const onSecondary = jest.fn();
render(
<PanelMessage
icon={null}
title="No data in this time range"
action={{ label: 'Extend time range', onClick: onPrimary }}
secondaryAction={{ label: 'Retry', onClick: onSecondary }}
data-testid="panel-no-data"
/>,
);
const primary = screen.getByTestId('panel-no-data-action');
const secondary = screen.getByTestId('panel-no-data-secondary-action');
expect(primary).toHaveTextContent('Extend time range');
expect(secondary).toHaveTextContent('Retry');
fireEvent.click(primary);
fireEvent.click(secondary);
expect(onPrimary).toHaveBeenCalledTimes(1);
expect(onSecondary).toHaveBeenCalledTimes(1);
});
});

View File

@@ -35,6 +35,7 @@ function BarPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onClick,
onDragSelect,
@@ -186,7 +187,9 @@ function BarPanelRenderer({
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
view: true,
edit: true,
clone: true,
download: false,
download: { csv: false, png: true, svg: true },
createAlert: true,
search: false,
drilldown: true,

View File

@@ -25,6 +25,7 @@ function HistogramPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
panelMode,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
@@ -104,7 +105,9 @@ function HistogramPanelRenderer({
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
view: true,
edit: true,
clone: true,
download: false,
download: { csv: false, png: true, svg: true },
createAlert: true,
search: false,
drilldown: false,

View File

@@ -21,6 +21,11 @@
// Logs: drop the row separators for a denser log view (V1 logs table parity).
.logRows {
// Every row opens the log detail drawer, so flag it as clickable.
:global(.ant-table-tbody) > tr {
cursor: pointer;
}
:global(.ant-table-tbody) > tr > td {
border-bottom: none;
}

View File

@@ -33,6 +33,7 @@ function ListPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
searchTerm = '',
pagination,
@@ -142,7 +143,7 @@ function ListPanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<>
<div

View File

@@ -34,7 +34,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
view: true,
edit: true,
clone: true,
download: false,
download: { csv: false, png: true, svg: true },
createAlert: false,
search: true,
drilldown: false,

View File

@@ -24,6 +24,7 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
isFetching,
refetch,
onClick,
enableDrillDown,
@@ -125,7 +126,11 @@ function NumberPanelRenderer({
: {})}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
<NoData
data-testid="number-panel-no-data"
isFetching={isFetching}
onRetry={refetch}
/>
) : (
<ValueDisplay
value={formattedValue}

View File

@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
view: true,
edit: true,
clone: true,
download: false,
download: { csv: false, png: true, svg: true },
createAlert: true,
search: false,
drilldown: true,

View File

@@ -27,6 +27,7 @@ function PiePanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onClick,
enableDrillDown,
@@ -94,7 +95,7 @@ function PiePanelRenderer({
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
{slices.length === 0 ? (
<NoData onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<Pie
data={slices}

View File

@@ -20,7 +20,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
view: true,
edit: true,
clone: true,
download: false,
download: { csv: false, png: true, svg: true },
createAlert: false,
search: false,
drilldown: true,

View File

@@ -35,6 +35,7 @@ function TablePanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
searchTerm = '',
onClick,
@@ -159,7 +160,7 @@ function TablePanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<div className={styles.container}>
<Table

View File

@@ -18,3 +18,11 @@
@include custom-scrollbar;
}
}
.clickableCell {
cursor: pointer;
&:hover {
color: var(--primary-background);
}
}

View File

@@ -17,7 +17,10 @@ function panelWith(
): PanelOfKind<'signoz/TablePanel'> {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
spec: {
display: { name: 'Table panel' },
plugin: { kind: 'signoz/TablePanel', spec },
},
} as unknown as PanelOfKind<'signoz/TablePanel'>;
}

View File

@@ -0,0 +1,91 @@
import type {
PanelQueryData,
PanelTable,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { PanelOfKind } from '../../../types/rendererProps';
import { prepareScalarTables } from '../../../../queryV5/prepareScalarTables';
import { buildTableCsvRows, getTableCsvRows } from '../tableCsv';
// Stub number/unit formatting so assertions cover only the row-building.
jest.mock('../../../utils/formatPanelValue', () => ({
formatPanelValue: (value: number, unit?: string): string =>
`${value}${unit ?? ''}`,
}));
jest.mock('../../../../queryV5/prepareScalarTables', () => ({
prepareScalarTables: jest.fn(),
}));
jest.mock('../../../../queryV5/v5ResponseData', () => ({
getScalarResults: jest.fn(() => []),
}));
const mockPrepareScalarTables = prepareScalarTables as jest.MockedFunction<
typeof prepareScalarTables
>;
const table: PanelTable = {
queryName: 'A',
legend: '',
columns: [
{ name: 'service', queryName: 'A', isValueColumn: false, id: 'service' },
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
],
rows: [
{ data: { service: 'frontend', A: 1234 } },
{ data: { service: 'cart', A: 56 } },
],
};
describe('buildTableCsvRows', () => {
it('keys rows by column name in display order and formats value columns', () => {
const rows = buildTableCsvRows({
table,
columnUnits: { A: 'ms' },
decimalPrecision: undefined,
});
expect(rows).toStrictEqual([
{ service: 'frontend', p99: '1234ms' },
{ service: 'cart', p99: '56ms' },
]);
expect(Object.keys(rows[0])).toStrictEqual(['service', 'p99']);
});
it('renders group columns and non-numeric value cells as raw text', () => {
const rows = buildTableCsvRows({
table: {
...table,
rows: [{ data: { service: 'api', A: 'n/a' } }],
},
columnUnits: {},
decimalPrecision: undefined,
});
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
});
});
describe('getTableCsvRows', () => {
const panel = {
spec: { plugin: { spec: { formatting: { columnUnits: { A: 'ms' } } } } },
} as unknown as PanelOfKind<'signoz/TablePanel'>;
const data = {} as PanelQueryData;
beforeEach(() => jest.clearAllMocks());
it('prepares the scalar table and flattens the first non-empty one to rows', () => {
mockPrepareScalarTables.mockReturnValue([table]);
expect(getTableCsvRows(panel, data)).toStrictEqual([
{ service: 'frontend', p99: '1234ms' },
{ service: 'cart', p99: '56ms' },
]);
});
it('returns no rows when the response has no table', () => {
mockPrepareScalarTables.mockReturnValue([]);
expect(getTableCsvRows(panel, data)).toStrictEqual([]);
});
});

View File

@@ -21,7 +21,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
view: true,
edit: true,
clone: true,
download: true,
download: { csv: true, png: true, svg: true },
createAlert: false,
// V1 parity: only tables (and lists) expose the header search box.
search: true,

View File

@@ -1,7 +1,10 @@
import type { TableProps } from 'antd';
import type { DashboardtypesTableThresholdDTO } from 'api/generated/services/sigNoz.schemas';
import type { PrecisionOption } from 'components/Graph/types';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type {
PanelTable,
PanelTableColumn,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { coerceToString } from 'utils/stringUtils';
import type { PanelThreshold } from '../../types/threshold';
@@ -10,6 +13,8 @@ import { formatPanelValue } from '../../utils/formatPanelValue';
import { getColumnUnit } from '../../utils/getColumnUnit';
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
import styles from './TablePanel.module.scss';
/** A prepared scalar-table row flattened for the antd Table, with the antd key. */
export type TableRowData = Record<string, unknown> & { key: number };
@@ -28,6 +33,26 @@ export function mapTableThresholds(
return byColumn;
}
/**
* Plain-text value of a table cell (value columns formatted through unit +
* precision, group columns raw). Shared by the renderer and the CSV export.
*/
export function formatTableCellText(
col: PanelTableColumn,
raw: unknown,
unit: string | undefined,
decimalPrecision?: PrecisionOption,
): string {
if (!col.isValueColumn) {
return coerceToString(raw);
}
const num = Number(raw);
if (!Number.isFinite(num)) {
return coerceToString(raw);
}
return formatPanelValue(num, unit, decimalPrecision);
}
// Sort comparator: numeric when both cells parse as numbers (value columns and
// numeric group keys), otherwise a locale string compare. Nullish sorts last.
function compareCells(a: unknown, b: unknown): number {
@@ -87,15 +112,13 @@ export function buildTableColumns({
sorter: (a: TableRowData, b: TableRowData): number =>
compareCells(a[key], b[key]),
render: (raw: unknown): React.ReactNode => {
if (!col.isValueColumn) {
return coerceToString(raw);
}
const text = formatTableCellText(col, raw, unit, decimalPrecision);
const num = Number(raw);
if (!Number.isFinite(num)) {
return coerceToString(raw);
}
const text = formatPanelValue(num, unit, decimalPrecision);
if (colThresholds.length === 0) {
if (
!col.isValueColumn ||
colThresholds.length === 0 ||
!Number.isFinite(num)
) {
return text;
}
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
@@ -120,7 +143,7 @@ export function buildTableColumns({
if (onCellClick) {
cellProps.onClick = (event): void =>
onCellClick({ columnId: key, record, event });
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
cellProps.className = styles.clickableCell;
}
return cellProps;

View File

@@ -0,0 +1,70 @@
import type { PrecisionOption } from 'components/Graph/types';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import type {
PanelQueryData,
PanelTable,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { getColumnUnit } from '../../utils/getColumnUnit';
import type { PanelOfKind } from '../../types/rendererProps';
import { formatTableCellText } from './tableColumns';
interface BuildTableCsvRowsArgs {
table: PanelTable;
/** Per-column display unit (`formatting.columnUnits`), keyed by column key. */
columnUnits: Record<string, string>;
decimalPrecision?: PrecisionOption;
}
/**
* Flattens a prepared table into CSV rows keyed by column name, reusing the
* on-screen cell formatting in display column order. Exports the full result
* set, not the paginated view (V1 parity).
*/
export function buildTableCsvRows({
table,
columnUnits,
decimalPrecision,
}: BuildTableCsvRowsArgs): Record<string, string>[] {
return table.rows.map((row) => {
const csvRow: Record<string, string> = {};
table.columns.forEach((col) => {
const key = col.id || col.name;
const unit = getColumnUnit(key, columnUnits);
csvRow[col.name] = formatTableCellText(
col,
row.data[key],
unit,
decimalPrecision,
);
});
return csvRow;
});
}
/**
* Prepares the scalar table from the query response and flattens it to CSV rows,
* reusing the on-screen formatting. Returns [] when the response has no table.
*/
export function getTableCsvRows(
panel: PanelOfKind<'signoz/TablePanel'>,
data: PanelQueryData,
): Record<string, string>[] {
const spec = panel.spec.plugin.spec;
const table = prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0);
if (!table) {
return [];
}
return buildTableCsvRows({
table,
columnUnits: spec.formatting?.columnUnits ?? {},
decimalPrecision: resolveDecimalPrecision(spec.formatting?.decimalPrecision),
});
}

View File

@@ -35,6 +35,7 @@ function TimeSeriesPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onClick,
onDragSelect,
@@ -187,7 +188,9 @@ function TimeSeriesPanelRenderer({
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
view: true,
edit: true,
clone: true,
download: false,
download: { csv: false, png: true, svg: true },
createAlert: true,
search: false,
drilldown: true,

View File

@@ -1,5 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
// Drilldown is the click-to-context-menu feature ported from V1. Every renderer turns its native
// click into one `DrilldownClickPayload`; the kind-agnostic orchestration layer consumes only that.
@@ -34,3 +36,13 @@ export interface DrilldownClickPayload {
coordinates: { x: number; y: number };
context: DrilldownContext;
}
/**
* Opens the View modal on a refined drilldown query (filter-by-value / breakout). In the grid this
* navigates to the modal seeded with the query; inside the modal it refines the view in place.
*/
export type OpenDrilldownView = (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;

View File

@@ -8,27 +8,29 @@ import type { PanelKind } from './panelKind';
import type { QueryBuilderFieldRule } from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
export enum DownloadFormat {
CSV = 'csv',
PNG = 'png',
SVG = 'svg',
}
/**
* Which panel actions a kind supports. Required field, so registering a new
* kind forces an explicit decision for every action. Chrome actions (move to
* section, clone, delete) are dashboard-layout concerns available to every
* panel and are intentionally not declarable here.
* Which actions a kind supports, declared per-kind in `kinds/<Kind>/definition.ts`.
* Chrome actions (move, clone, delete) are layout concerns and aren't declared here.
*/
export interface PanelActionCapabilities {
/** Kind has a full-screen view — gates the "View" action. */
/** Gates the "View" action. */
view: boolean;
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
/** Gates the "Edit panel" action. */
edit: boolean;
/** Kind can be cloned — gates the "Clone" action. */
/** Gates the "Clone" action. */
clone: boolean;
/** Gates "Download as CSV". V1 parity: only table panels carry exportable data. */
download: boolean;
/** Kind's query can seed a new alert — gates "Create Alerts". */
/** Which formats this kind can be downloaded as (CSV is table-only). */
download: Record<DownloadFormat, boolean>;
/** Gates "Create Alerts". */
createAlert: boolean;
/**
* Header search box that filters rendered rows client-side (V1 parity: only
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
*/
/** Client-side header search box, consumed by the renderer via `searchTerm`. */
search: boolean;
/**
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
@@ -51,13 +53,10 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
actions: PanelActionCapabilities;
}
// Total over PanelKind: every kind must be registered (missing → compile error),
// so getPanelDefinition never returns undefined.
// Every kind must be registered, so getPanelDefinition never returns undefined.
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
// getPanelDefinition resolves to this, concentrating the unavoidable cast in one
// place rather than leaking it to every call site (the kind isn't known statically).
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'

View File

@@ -0,0 +1,19 @@
import { unparse } from 'papaparse';
import { toSafeFileName } from './toSafeFileName';
/** Serializes rows (keyed by column header) to CSV and downloads the file. */
export function downloadCsv(
rows: Record<string, string>[],
fileBaseName: string,
): void {
const csv = unparse(rows);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${toSafeFileName(fileBaseName)}.csv`;
link.click();
link.remove();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,8 @@
/** Makes a download filename safe across OSes; falls back when empty. */
export function toSafeFileName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
return 'panel';
}
return trimmed.replace(/[\\/:*?"<>|]+/g, '-');
}

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