Compare commits

..

144 Commits

Author SHA1 Message Date
Nikhil Mantri
9924fca29d Merge branch 'main' into infraM/v2_onboarding_api 2026-06-23 15:55:15 +05:30
Abhi kumar
949d18f028 feat(dashboards-v2): panel editor foundation + qb/perses adapter (#11769)
* feat(dashboards-v2): pure-V5 perses query adapter and request builder

* feat(dashboards-v2): panel query hook with pagination and time window

* refactor(dashboards-v2): per-kind panel definitions and registry

* feat(dashboards-v2): role and kind-gated panel actions with header chrome

* feat(dashboards-v2): panel editor route with live preview and query builder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): make the editor dirty-check immune to query re-serialization

* fix(dashboards-v2): persist raw/list panels as a bare BuilderQuery

* feat(dashboards-v2): optional footer slot below the query builder

* refactor(dashboards-v2): render the editor preview through the shared panel body

The editor preview duplicated PanelBody's loading/error/renderer state
machine. Delegate to PanelBody so the preview is the production render
path, differing only by panelMode (DASHBOARD_EDIT) and the forwarded
server pager. PanelBody's panelMode and dashboardPreference become
optional (the preview has no dashboard-wide preferences), and its error
state now surfaces the backend message via panelStatusFromError instead
of the raw axios "status code 4xx".

* feat(dashboards-v2): richer panel status popover for errors and warnings

Replace the plain status tooltip with a card: variant-coloured icon, the
error/warning code and message, an optional Open Docs link, a MESSAGES
count pill, and the per-item message list. Hosted in @signozhq/ui
TooltipSimple with its padding/width cap stripped so the card owns its
own layout. Bump the header actions gap so the status icon sits clear of
its neighbours.

* refactor(dashboards-v2): address panel-editor foundation PR review

- fix failing lint: drop the unused Typography import in PanelStatusContent
- drop redundant optional chaining on the required spec/plugin (usePanelQuery,
  PanelEditor index, HistogramPanel sections)
- move ComparisonThresholdShape into types/threshold
- derive PanelActionId from keyof PanelActionCapabilities
- fold the per-kind header search flag into actions (drop PanelHeaderControls)
- default PanelHeader searchTerm to ''
- drive the editor's discard prompt through useConfirmableAction
- trim over-verbose comments; PanelHeader test uses userEvent

* refactor(dashboards-v2): trim comment noise and use JSDoc for function docs

Reduce over-commenting flagged in review across the V2 dashboard code:
remove comments that restate the code, shrink verbose blocks to a line, and
keep only the non-obvious "why". Function/helper docs are written as JSDoc;
inline implementation notes stay as line comments.

* fix(dashboards-v2): harden list pagination against bad inputs

Clamp the page size and offset to finite, positive values before deriving
pageIndex/canNext so the pager can never produce NaN/Infinity/negative state,
and ignore a non-positive setPageSize so the size state stays valid. canNext
now uses `rowCount >= pageSize` to also cover an over-fetch.

Add edge-case tests: empty/non-raw response, full vs partial page, nextCursor,
goNext advancing the page, and a rejected zero page size.

* refactor(dashboards-v2): localize the perses adapter's envelope casts

The adapter bridges the generated query-envelope DTO (enum type, undiscriminated
spec) and the hand-written QueryEnvelope (typed spec) the V1 mappers consume —
nominally distinct types for the same wire shape, so a structural cast is
unavoidable. Confine those casts to two named `*Envelopes` converters and a
builder-query predicate, keep an explicit typed checkpoint for the composite
spec, and correct the stale "Orval erases spec to unknown" comments.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:39:08 +00:00
Aditya Singh
8180436432 chore: update code owner (#11821) 2026-06-23 09:18:00 +00:00
Nikhil Mantri
c83c6054a1 Merge branch 'main' into infraM/v2_onboarding_api 2026-06-23 14:31:42 +05:30
Nikhil Mantri
b0a8e4fb36 Merge branch 'main' into infraM/v2_onboarding_api 2026-06-23 13:13:32 +05:30
nikhilmantri0902
2db3034037 chore: reformatted integration tests 2026-06-21 12:54:50 +05:30
Nikhil Mantri
41e70ef37f Merge branch 'main' into infraM/v2_onboarding_api 2026-06-21 12:25:39 +05:30
nikhilmantri0902
d147b0177a chore: not required parameter removal 2026-06-20 23:41:46 +05:30
nikhilmantri0902
e833952c66 chore: added new metrics existence function + modified return types 2026-06-20 22:31:08 +05:30
nikhilmantri0902
c4a46a5d7d chore: documentation future links added 2026-06-20 15:28:33 +05:30
nikhilmantri0902
8931c593d6 chore: integration tests added 2026-06-20 14:01:21 +05:30
nikhilmantri0902
2d0a6d80f7 chore: onboarding specs updated to match v2 infra-monitoring apis 2026-06-20 13:22:25 +05:30
Nikhil Mantri
4543c0008b Merge branch 'main' into infraM/v2_onboarding_api 2026-06-19 15:02:08 +05:30
Nikhil Mantri
405c7d13ad Merge branch 'main' into infraM/v2_onboarding_api 2026-06-18 16:06:05 +05:30
nikhilmantri0902
3c1a4b4103 chore: merged main to onboarding API 2026-06-18 11:02:21 +05:30
Nikhil Mantri
b414fc30af Merge branch 'main' into infraM/v2_onboarding_api 2026-05-14 14:16:49 +05:30
nikhilmantri0902
7dd64c0d53 chore: merged main, resolved conflicts 2026-05-14 11:57:54 +05:30
nikhilmantri0902
e4a8c581d1 chore: merged main, resolved conflicts 2026-05-14 11:42:27 +05:30
nikhilmantri0902
a949993430 chore: merged main 2026-04-29 12:17:07 +05:30
nikhilmantri0902
f34a33e08b chore: merged base branch 2026-04-28 12:30:16 +05:30
Nikhil Mantri
46e833faba Merge branch 'main' into infraM/v2_pods_list_api 2026-04-28 12:15:56 +05:30
nikhilmantri0902
4bd7492629 chore: updated comment 2026-04-28 12:07:04 +05:30
Nikhil Mantri
24fe9a986d feat(infra-monitoring): v2 pods list apis - phase counts when custom grouping (#11088)
* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types
2026-04-27 16:04:55 +05:30
Nikhil Mantri
56e79be6cd Merge branch 'infraM/v2_pods_list_api' into infraM/v2_onboarding_api 2026-04-27 15:05:47 +05:30
Nikhil Mantri
92d297ac9d Merge branch 'main' into infraM/v2_pods_list_api 2026-04-27 15:05:04 +05:30
Nikhil Mantri
b3c352609c Merge branch 'infraM/v2_pods_list_api' into infraM/v2_onboarding_api 2026-04-27 12:51:27 +05:30
Ashwin Bhatkal
bdbaa32485 Merge branch 'main' into infraM/v2_pods_list_api 2026-04-27 11:44:13 +05:30
nikhilmantri0902
9503cdff36 chore: added a note from otel 2026-04-25 21:42:33 +05:30
nikhilmantri0902
5a18786ab2 chore: added a note from otel 2026-04-25 21:29:41 +05:30
nikhilmantri0902
648154df14 chore: readability improvement 2026-04-25 19:30:30 +05:30
nikhilmantri0902
98eb002e07 chore: simplify 2026-04-24 18:14:48 +05:30
nikhilmantri0902
720379db9f chore: get onboarding spec 2026-04-24 17:47:03 +05:30
nikhilmantri0902
6ad14e7151 chore: renamed method 2026-04-24 17:38:56 +05:30
nikhilmantri0902
181fca064b chore: added onboarding api 2026-04-24 17:33:03 +05:30
nikhilmantri0902
a5e39ca6bd Merge branch 'infraM/v2_pods_list_api' into infraM/v2_onboarding_api 2026-04-24 17:25:27 +05:30
Ashwin Bhatkal
b35c6676f9 fix: rebase fixes 2026-04-24 13:17:02 +05:30
nikhilmantri0902
1095caa123 chore: improved api description to document -1 as no data in numeric fields 2026-04-24 12:11:45 +05:30
nikhilmantri0902
9043b49762 chore: removed pods - order by phase 2026-04-24 12:04:51 +05:30
nikhilmantri0902
d4084a7494 chore: added support for pod phase unknown 2026-04-24 11:46:26 +05:30
nikhilmantri0902
27c564b3bf chore: added required tags 2026-04-24 11:21:20 +05:30
Nikhil Mantri
f02c491828 Merge branch 'main' into infraM/v2_pods_list_api 2026-04-24 10:47:13 +05:30
Nikhil Mantri
3d53b8f77f Merge branch 'main' into infraM/v2_pods_list_api 2026-04-23 18:44:33 +05:30
nikhilmantri0902
dffe94fec4 chore: conflicts resolved 2026-04-23 18:39:39 +05:30
nikhilmantri0902
c9360fcf13 Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-23 11:23:49 +05:30
nikhilmantri0902
b5ab45db20 chore: regen api client for inframonitoring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:51:35 +05:30
Nikhil Mantri
08f76aca78 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-23 09:51:01 +05:30
nikhilmantri0902
d81cec4c29 chore: added onboarding splits 2026-04-22 19:22:37 +05:30
nikhilmantri0902
49744c6104 chore: added attrs presence check function 2026-04-22 19:06:26 +05:30
nikhilmantri0902
2147627baf chore: added specs for all component types 2026-04-22 19:00:46 +05:30
nikhilmantri0902
824f92a88f chore: added types and constants 2026-04-22 18:21:55 +05:30
nikhilmantri0902
983d4fe4f2 Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-22 15:37:21 +05:30
nikhilmantri0902
833af794c3 chore: make sort stable in case of tiebreaker by comparing composite group by keys 2026-04-22 15:26:28 +05:30
nikhilmantri0902
21b51d1fcc chore: cleanup and rename 2026-04-22 15:13:00 +05:30
nikhilmantri0902
56f22682c8 Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-22 14:29:17 +05:30
nikhilmantri0902
9c8359940c chore: remove a defensive nil map check, the function ensure non-nil map when err nil 2026-04-22 11:59:01 +05:30
Nikhil Mantri
4050880275 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-22 11:35:57 +05:30
nikhilmantri0902
5e775f64f2 chore: added status unauthorized 2026-04-21 21:30:44 +05:30
nikhilmantri0902
0189f23f46 chore: removed internal server error 2026-04-21 21:30:01 +05:30
nikhilmantri0902
49a36d4e3d chore: removed pod metric temporalities 2026-04-21 21:24:49 +05:30
nikhilmantri0902
9407d658ab chore: merge base hosts v2 branch 2026-04-21 21:17:28 +05:30
nikhilmantri0902
5035712485 chore: added json tag required: true 2026-04-21 18:50:25 +05:30
nikhilmantri0902
bab17c3615 chore: comments resolve 2026-04-21 18:33:56 +05:30
Nikhil Mantri
37b44f4db9 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-21 17:40:06 +05:30
nikhilmantri0902
99dd6e5f1e chore: pods code restructuring 2026-04-21 17:03:13 +05:30
nikhilmantri0902
9c7131fa6a chore: merge base branch 2026-04-21 16:22:55 +05:30
Nikhil Mantri
ad889a2e1d Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-21 13:48:53 +05:30
nikhilmantri0902
a4f6d0cbf5 chore: removed temporalities 2026-04-21 13:44:06 +05:30
nikhilmantri0902
589bed7c16 chore: comments correction 2026-04-21 12:50:51 +05:30
nikhilmantri0902
93843a1f48 chore: file structure further breakdown for clarity 2026-04-21 12:36:07 +05:30
nikhilmantri0902
88c43108fc chore: added types package 2026-04-20 18:52:43 +05:30
nikhilmantri0902
ed4cf540e8 chore: inframonitoring types renaming 2026-04-20 18:47:28 +05:30
nikhilmantri0902
9e2dfa9033 chore: rearrangement 2026-04-20 17:51:03 +05:30
nikhilmantri0902
d98d5d68ee chore: rename PodsList -> ListPods 2026-04-20 16:57:21 +05:30
nikhilmantri0902
2cb1c3b73b chore: rename HostsList -> ListHosts 2026-04-20 16:42:19 +05:30
nikhilmantri0902
ae7ca497ad chore: merged base hosts branch and reorganized code 2026-04-20 13:38:25 +05:30
Nikhil Mantri
a579916961 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-20 11:05:36 +05:30
Nikhil Mantri
4a16d56abf feat(infra-monitoring): v2 hosts list - return counts of active & inactive hosts for custom group by attributes (#10956)
* chore: add functionality for showing active and inactive counts in custom group by

* chore: bug fix

* chore: added subquery for active and total count

* chore: ignore empty string hosts in get active hosts

* fix: sinceUnixMilli for determining active hosts compute once per request

* chore: refactor code
2026-04-20 10:41:15 +05:30
Nikhil Mantri
642b5ac3f0 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-16 16:32:39 +05:30
Nikhil Mantri
a12112619c Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-16 15:41:35 +05:30
nikhilmantri0902
014785f1bc chore: ignore empty string hosts in get active hosts 2026-04-16 13:17:15 +05:30
Nikhil Mantri
58ee797b10 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-15 14:18:29 +05:30
Nikhil Mantri
82d236742f Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-15 11:21:33 +05:30
nikhilmantri0902
397e1ad5be chore: added TODOs and made filterByStatus a part of filter struct 2026-04-14 18:32:48 +05:30
nikhilmantri0902
8d6b25ca9b chore: resolved conflicts 2026-04-14 17:09:17 +05:30
nikhilmantri0902
5fa6bd8b8d Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-13 11:02:14 +05:30
nikhilmantri0902
bd9977483b chore: improved description 2026-04-11 11:31:35 +05:30
nikhilmantri0902
50fbdfeeef chore: validate order by to validate function 2026-04-10 19:01:45 +05:30
nikhilmantri0902
e2b1b73e87 chore: improvements 2026-04-10 13:23:33 +05:30
nikhilmantri0902
cb9f3fd3e5 chore: rearrage 2026-04-10 00:39:23 +05:30
nikhilmantri0902
232acc343d chore: escape backtick to prevent sql injection 2026-04-10 00:01:01 +05:30
nikhilmantri0902
2025afdccc chore: endpoint modification openapi 2026-04-09 23:25:59 +05:30
nikhilmantri0902
d2f4d4af93 chore: endpoint correction 2026-04-09 23:21:57 +05:30
Nikhil Mantri
47ff7bbb8e Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-09 23:20:39 +05:30
Nikhil Mantri
724071c5dc Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-09 18:30:15 +05:30
nikhilmantri0902
4d24979358 chore: frontend fix 2026-04-09 18:26:42 +05:30
nikhilmantri0902
042943b10a chore: distributed samples table to local table change for get metadata 2026-04-09 18:24:45 +05:30
nikhilmantri0902
48a9be7ec8 chore: added required metrics check 2026-04-09 17:38:48 +05:30
nikhilmantri0902
a9504b2120 chore: added a TODO remark 2026-04-09 16:08:34 +05:30
nikhilmantri0902
8755887c4a chore: added better metrics existence check 2026-04-09 16:01:35 +05:30
Nikhil Mantri
4cb4662b3a Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-09 15:14:25 +05:30
nikhilmantri0902
e6900dabc8 chore: warnings added passing from queryResponse warning to host lists response struct 2026-04-09 00:09:38 +05:30
nikhilmantri0902
c1ba389b63 chore: add type for response and files rearrange 2026-04-08 23:35:53 +05:30
nikhilmantri0902
3a1f40234f Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-08 23:03:50 +05:30
Nikhil Mantri
2e4891fa63 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-08 16:07:57 +05:30
Nikhil Mantri
04ebc0bec7 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-08 11:08:10 +05:30
nikhilmantri0902
271f9b81ed Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-07 21:55:47 +05:30
nikhilmantri0902
6fa815c294 chore: modified getMetadata query 2026-04-07 18:55:57 +05:30
nikhilmantri0902
63ec518efb chore: added hostName logic 2026-04-07 17:36:15 +05:30
nikhilmantri0902
c4ca20dd90 chore: return errors from getMetadata and lint fix 2026-04-07 17:01:13 +05:30
nikhilmantri0902
e56cc4222b chore: return errors from getMetadata and lint fix 2026-04-07 16:57:35 +05:30
nikhilmantri0902
07d2944d7c chore: yarn generate api 2026-04-07 16:44:06 +05:30
nikhilmantri0902
dea01ae36a chore: hostStatusNone added for clarity that this field can be left empty as well in payload 2026-04-07 16:32:25 +05:30
nikhilmantri0902
62ea5b54e2 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-07 14:09:48 +05:30
nikhilmantri0902
e549a7e42f chore: added pods list api updates 2026-04-07 13:58:10 +05:30
nikhilmantri0902
90e2ebb11f Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-07 13:51:35 +05:30
nikhilmantri0902
61baa1be7a chore: code improvements 2026-04-07 13:49:00 +05:30
nikhilmantri0902
b946fa665f Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-07 11:15:35 +05:30
nikhilmantri0902
2e049556e4 chore: unified composite key function 2026-04-07 11:15:03 +05:30
nikhilmantri0902
492a5e70d7 chore: added pods metrics temporality 2026-04-06 17:33:44 +05:30
nikhilmantri0902
ba1f2771e8 Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-06 17:18:44 +05:30
nikhilmantri0902
7458fb4855 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-06 17:18:01 +05:30
nikhilmantri0902
5f55f3938b chore: added temporalities of metrics 2026-04-06 17:17:15 +05:30
nikhilmantri0902
3e8102485c Merge branch 'infraM/v2_hosts_list_api' into infraM/v2_pods_list_api 2026-04-04 20:52:50 +05:30
nikhilmantri0902
861c682ea5 chore: nil pointer dereference fix in req.Filter 2026-04-04 20:52:08 +05:30
nikhilmantri0902
c8e5895dff chore: nil pointer check 2026-04-04 20:45:04 +05:30
nikhilmantri0902
82d72e7edb chore: pods api meta start time 2026-04-04 17:18:04 +05:30
nikhilmantri0902
a3f8ecaaf1 chore: merged base branch 2026-04-04 16:47:10 +05:30
nikhilmantri0902
19aada656c chore: updated spec 2026-04-04 16:44:15 +05:30
nikhilmantri0902
b21bb4280f chore: updated openapi yml 2026-04-04 16:38:22 +05:30
nikhilmantri0902
bc0a4fdb5c chore: added pods list logic 2026-04-04 13:24:46 +05:30
nikhilmantri0902
37fb0e9254 Merge branch 'infraM/base_dependencies' into infraM/v2_hosts_list_api 2026-04-03 17:49:00 +05:30
nikhilmantri0902
aecfa1a174 chore: added validation on order by 2026-04-02 20:13:30 +05:30
nikhilmantri0902
b869d23d94 chore: moved funcs 2026-04-02 20:02:22 +05:30
nikhilmantri0902
6ee3d44f76 chore: removed isSendingK8sAgentsMetricsCode 2026-04-02 19:58:30 +05:30
nikhilmantri0902
462e554107 chore: yarn generate api 2026-04-02 14:49:15 +05:30
nikhilmantri0902
66afa73e6f chore: return status as a string 2026-04-02 14:39:02 +05:30
nikhilmantri0902
54c604bcf4 chore: added some unit tests 2026-04-02 14:20:27 +05:30
nikhilmantri0902
c1be02ba54 chore: added validate function 2026-04-02 14:14:34 +05:30
nikhilmantri0902
d3c7ba8f45 chore: disk usage 2026-04-02 14:01:18 +05:30
nikhilmantri0902
039c4a0496 fix: bug fix 2026-04-02 11:32:49 +05:30
nikhilmantri0902
51a94b6bbc chore: added logic for hosts v3 api 2026-04-02 02:52:28 +05:30
nikhilmantri0902
bbfbb94f52 chore: merged main 2026-04-01 00:45:40 +05:30
nikhilmantri0902
d1eb9ef16f chore: endpoint detail update 2026-03-31 16:16:31 +05:30
nikhilmantri0902
3db00f8bc3 chore: baseline setup 2026-03-31 15:27:18 +05:30
138 changed files with 6698 additions and 1420 deletions

69
.github/CODEOWNERS vendored
View File

@@ -199,3 +199,72 @@ go.mod @therealpandey
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
## Logs
/frontend/src/pages/Logs/ @SigNoz/events-frontend
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
/frontend/src/container/LogControls/ @SigNoz/events-frontend
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
/frontend/src/components/Logs/ @SigNoz/events-frontend
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
/frontend/src/hooks/logs/ @SigNoz/events-frontend
## Logs Pipelines
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
## Traces / Trace Explorer
/frontend/src/pages/Trace/ @SigNoz/events-frontend
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
/frontend/src/container/Trace/ @SigNoz/events-frontend
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
## Trace Funnels
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
## Trace Details
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
/frontend/src/hooks/trace/ @SigNoz/events-frontend
## Exceptions
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
/frontend/src/container/AllError/ @SigNoz/events-frontend
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
## External APIs
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
## Messaging Queues
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend

View File

@@ -3974,6 +3974,29 @@ components:
enabled:
type: boolean
type: object
InframonitoringtypesAssociatedComponent:
properties:
name:
type: string
type:
$ref: '#/components/schemas/InframonitoringtypesOnboardingComponentType'
required:
- type
- name
type: object
InframonitoringtypesAttributesComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
attributes:
items:
type: string
nullable: true
type: array
required:
- attributes
- associatedComponent
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
@@ -4323,6 +4346,57 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesMetricsComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
metrics:
items:
type: string
nullable: true
type: array
required:
- metrics
- associatedComponent
type: object
InframonitoringtypesMissingAttributesComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
attributes:
items:
type: string
nullable: true
type: array
documentationLink:
type: string
message:
type: string
required:
- attributes
- associatedComponent
- message
- documentationLink
type: object
InframonitoringtypesMissingMetricsComponentEntry:
properties:
associatedComponent:
$ref: '#/components/schemas/InframonitoringtypesAssociatedComponent'
documentationLink:
type: string
message:
type: string
metrics:
items:
type: string
nullable: true
type: array
required:
- metrics
- associatedComponent
- message
- documentationLink
type: object
InframonitoringtypesNamespaceRecord:
properties:
meta:
@@ -4447,6 +4521,71 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesOnboarding:
properties:
missingDefaultEnabledMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
nullable: true
type: array
missingOptionalMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingMetricsComponentEntry'
nullable: true
type: array
missingRequiredAttributes:
items:
$ref: '#/components/schemas/InframonitoringtypesMissingAttributesComponentEntry'
nullable: true
type: array
presentDefaultEnabledMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
nullable: true
type: array
presentOptionalMetrics:
items:
$ref: '#/components/schemas/InframonitoringtypesMetricsComponentEntry'
nullable: true
type: array
presentRequiredAttributes:
items:
$ref: '#/components/schemas/InframonitoringtypesAttributesComponentEntry'
nullable: true
type: array
ready:
type: boolean
type:
$ref: '#/components/schemas/InframonitoringtypesOnboardingType'
required:
- type
- ready
- presentDefaultEnabledMetrics
- presentOptionalMetrics
- presentRequiredAttributes
- missingDefaultEnabledMetrics
- missingOptionalMetrics
- missingRequiredAttributes
type: object
InframonitoringtypesOnboardingComponentType:
enum:
- receiver
- processor
type: string
InframonitoringtypesOnboardingType:
enum:
- hosts
- processes
- pods
- nodes
- deployments
- daemonsets
- statefulsets
- jobs
- namespaces
- clusters
- volumes
type: string
InframonitoringtypesPodCountsByPhase:
properties:
failed:
@@ -15339,6 +15478,72 @@ paths:
summary: List Nodes for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/onboarding:
get:
deprecated: false
description: 'Returns the per-tab readiness of the infra-monitoring section
selected by the ''type'' query parameter (hosts, processes, pods, nodes, deployments,
daemonsets, statefulsets, jobs, namespaces, clusters, volumes). For each collector
receiver or processor that contributes required metrics or attributes, lists
what is present and what is missing, with a prebuilt user-facing message and
a docs link per missing component. Default-enabled metrics are those expected
as soon as the receiver is configured; optional metrics require ''enabled:
true'' in receiver config. ''ready'' is true only when every missing list
is empty.'
operationId: GetOnboarding
parameters:
- in: query
name: type
required: true
schema:
$ref: '#/components/schemas/InframonitoringtypesOnboardingType'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesOnboarding'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get Onboarding Status for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/pods:
post:
deprecated: false

View File

@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function (): void {};
}
// jsdom doesn't implement the Pointer Capture API, which Radix UI primitives
// (e.g. @signozhq/ui Select) call when opening. Stub them so those components
// can be exercised in tests.
if (!HTMLElement.prototype.hasPointerCapture) {
HTMLElement.prototype.hasPointerCapture = function (): boolean {
return false;
};
}
if (!HTMLElement.prototype.releasePointerCapture) {
HTMLElement.prototype.releasePointerCapture = function (): void {};
}
if (typeof window.IntersectionObserver === 'undefined') {
class IntersectionObserverMock {
observe(): void {}

View File

@@ -122,6 +122,13 @@ export const DashboardWidget = Loadable(
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
);
export const DashboardPanelEditorPage = Loadable(
() =>
import(
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
),
);
export const EditRulesPage = Loadable(
() => import(/* webpackChunkName: "Alerts Edit Page" */ 'pages/EditRules'),
);

View File

@@ -11,6 +11,7 @@ import {
ChannelsNew,
CreateNewAlerts,
DashboardPage,
DashboardPanelEditorPage,
DashboardsListPage,
DashboardWidget,
EditRulesPage,
@@ -196,6 +197,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'DASHBOARD_WIDGET',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,
component: DashboardPanelEditorPage,
isPrivate: true,
key: 'DASHBOARD_PANEL_EDITOR',
},
{
path: ROUTES.EDIT_ALERTS,
exact: true,

View File

@@ -4,14 +4,22 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetOnboarding200,
GetOnboardingParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
@@ -619,6 +627,104 @@ export const useListNodes = <
> => {
return useMutation(getListNodesMutationOptions(options));
};
/**
* Returns the per-tab readiness of the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes). For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
* @summary Get Onboarding Status for Infra Monitoring
*/
export const getOnboarding = (
params: GetOnboardingParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetOnboarding200>({
url: `/api/v2/infra_monitoring/onboarding`,
method: 'GET',
params,
signal,
});
};
export const getGetOnboardingQueryKey = (params?: GetOnboardingParams) => {
return [
`/api/v2/infra_monitoring/onboarding`,
...(params ? [params] : []),
] as const;
};
export const getGetOnboardingQueryOptions = <
TData = Awaited<ReturnType<typeof getOnboarding>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetOnboardingParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getOnboarding>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetOnboardingQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getOnboarding>>> = ({
signal,
}) => getOnboarding(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getOnboarding>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetOnboardingQueryResult = NonNullable<
Awaited<ReturnType<typeof getOnboarding>>
>;
export type GetOnboardingQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get Onboarding Status for Infra Monitoring
*/
export function useGetOnboarding<
TData = Awaited<ReturnType<typeof getOnboarding>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetOnboardingParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getOnboarding>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetOnboardingQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get Onboarding Status for Infra Monitoring
*/
export const invalidateGetOnboarding = async (
queryClient: QueryClient,
params: GetOnboardingParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetOnboardingQueryKey(params) },
options,
);
return queryClient;
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* @summary List Pods for Infra Monitoring

View File

@@ -5422,6 +5422,26 @@ export interface GlobaltypesConfigDTO {
mcp_url: string | null;
}
export enum InframonitoringtypesOnboardingComponentTypeDTO {
receiver = 'receiver',
processor = 'processor',
}
export interface InframonitoringtypesAssociatedComponentDTO {
/**
* @type string
*/
name: string;
type: InframonitoringtypesOnboardingComponentTypeDTO;
}
export interface InframonitoringtypesAttributesComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
attributes: string[] | null;
}
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -5878,6 +5898,46 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
metrics: string[] | null;
}
export interface InframonitoringtypesMissingAttributesComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type array,null
*/
attributes: string[] | null;
/**
* @type string
*/
documentationLink: string;
/**
* @type string
*/
message: string;
}
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
/**
* @type string
*/
documentationLink: string;
/**
* @type string
*/
message: string;
/**
* @type array,null
*/
metrics: string[] | null;
}
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -5995,6 +6055,61 @@ export interface InframonitoringtypesNodesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum InframonitoringtypesOnboardingTypeDTO {
hosts = 'hosts',
processes = 'processes',
pods = 'pods',
nodes = 'nodes',
deployments = 'deployments',
daemonsets = 'daemonsets',
statefulsets = 'statefulsets',
jobs = 'jobs',
namespaces = 'namespaces',
clusters = 'clusters',
volumes = 'volumes',
}
export interface InframonitoringtypesOnboardingDTO {
/**
* @type array,null
*/
missingDefaultEnabledMetrics:
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
missingOptionalMetrics:
| InframonitoringtypesMissingMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
missingRequiredAttributes:
| InframonitoringtypesMissingAttributesComponentEntryDTO[]
| null;
/**
* @type array,null
*/
presentDefaultEnabledMetrics:
| InframonitoringtypesMetricsComponentEntryDTO[]
| null;
/**
* @type array,null
*/
presentOptionalMetrics: InframonitoringtypesMetricsComponentEntryDTO[] | null;
/**
* @type array,null
*/
presentRequiredAttributes:
| InframonitoringtypesAttributesComponentEntryDTO[]
| null;
/**
* @type boolean
*/
ready: boolean;
type: InframonitoringtypesOnboardingTypeDTO;
}
export enum InframonitoringtypesPodPhaseDTO {
pending = 'pending',
running = 'running',
@@ -10266,6 +10381,21 @@ export type ListNodes200 = {
status: string;
};
export type GetOnboardingParams = {
/**
* @description undefined
*/
type: InframonitoringtypesOnboardingTypeDTO;
};
export type GetOnboarding200 = {
data: InframonitoringtypesOnboardingDTO;
/**
* @type string
*/
status: string;
};
export type ListPods200 = {
data: InframonitoringtypesPodsDTO;
/**

View File

@@ -24,6 +24,7 @@ const ROUTES = {
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',
ALERTS_NEW: '/alerts/new',

View File

@@ -408,6 +408,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
// The V2 panel editor is a chromeless full-page route (no side nav / top nav),
// like the onboarding and public-dashboard screens.
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
@@ -418,7 +421,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard;
isPublicDashboard ||
isPanelEditorV2;
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);

View File

@@ -7,15 +7,17 @@
&--legend-right {
flex-direction: row;
.chart-layout__legend-wrapper {
padding-left: 0 !important;
}
}
&__legend-wrapper {
// The inline height is the legend rectangle from calculateChartDimensions;
// border-box keeps the padding inside it so the wrapper doesn't grow past
// that height and steal space from the chart. overflow:hidden clips to the
// rectangle so the virtualized legend scrolls within it.
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
overflow: auto;
}
}

View File

@@ -0,0 +1,61 @@
import { act, renderHook } from '@testing-library/react';
import { useConfirmableAction } from '../useConfirmableAction';
describe('useConfirmableAction', () => {
it('starts closed and idle', () => {
const { result } = renderHook(() =>
useConfirmableAction(jest.fn().mockResolvedValue(undefined)),
);
expect(result.current.open).toBe(false);
expect(result.current.isPending).toBe(false);
});
it('request() opens the prompt without running the action', () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
expect(result.current.open).toBe(true);
expect(action).not.toHaveBeenCalled();
});
it('confirm() runs the action and closes on success', async () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
await act(async () => {
await result.current.confirm();
});
expect(action).toHaveBeenCalledTimes(1);
expect(result.current.open).toBe(false);
expect(result.current.isPending).toBe(false);
});
it('keeps the prompt open and resets pending when the action rejects', async () => {
const action = jest.fn().mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
await act(async () => {
await expect(result.current.confirm()).rejects.toThrow('boom');
});
expect(result.current.open).toBe(true);
expect(result.current.isPending).toBe(false);
});
it('cancel() closes the prompt without running the action', () => {
const action = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmableAction(action));
act(() => result.current.request());
act(() => result.current.cancel());
expect(result.current.open).toBe(false);
expect(action).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo, useState } from 'react';
export interface ConfirmableAction {
/** Whether the confirmation prompt is open. */
open: boolean;
/** The confirmed action is in flight. */
isPending: boolean;
/** Open the confirmation prompt (e.g. from a menu item / button). */
request: () => void;
/** Run the action, tracking the in-flight flag; closes the prompt on success. */
confirm: () => Promise<void>;
/** Dismiss the prompt without acting. */
cancel: () => void;
}
/**
* Generic two-step confirm flow for a (usually destructive) async action.
* `request()` opens the prompt, `confirm()` runs `action` while tracking an
* in-flight flag and closes on success, `cancel()` dismisses it. Owns only the
* confirm state machine — what renders the prompt (dialog, popover) is the
* caller's concern, so it stays reusable across confirm surfaces.
*/
export function useConfirmableAction(
action: () => Promise<void>,
): ConfirmableAction {
const [open, setOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const request = useCallback((): void => setOpen(true), []);
const cancel = useCallback((): void => setOpen(false), []);
const confirm = useCallback(async (): Promise<void> => {
setIsPending(true);
try {
await action();
setOpen(false);
} finally {
setIsPending(false);
}
}, [action]);
return useMemo(
() => ({ open, isPending, request, confirm, cancel }),
[open, isPending, request, confirm, cancel],
);
}

View File

@@ -1,3 +1,5 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
@@ -15,6 +17,10 @@
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
@@ -33,6 +39,11 @@
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
@@ -67,18 +78,7 @@
}
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
border-radius: 0.5rem;
}
@include custom-scrollbar;
}
}
@@ -108,6 +108,10 @@
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;

View File

@@ -87,7 +87,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = `${finalFillColor}40`;
} else if (fillMode && fillMode !== FillMode.None) {
if (fillMode === FillMode.Solid) {
lineConfig.fill = finalFillColor;
lineConfig.fill = `${finalFillColor}70`;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');

View File

@@ -0,0 +1,22 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background-color: var(--l1-background-60);
border-bottom: 1px solid var(--l1-border);
}
.title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,86 @@
import { useCallback } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
onSave: () => void;
onClose: () => void;
}
function Header({
isDirty,
isSaving,
onSave,
onClose,
}: HeaderProps): JSX.Element {
const discard = useConfirmableAction(
useCallback(async (): Promise<void> => onClose(), [onClose]),
);
// Confirm before closing with unsaved edits; a pristine panel closes straight away.
const handleCloseClick = useCallback((): void => {
if (isDirty) {
discard.request();
} else {
onClose();
}
}, [isDirty, onClose, discard]);
return (
<div className={styles.header}>
<div className={styles.title}>
<Button
variant="ghost"
color="secondary"
size="icon"
suffix={<X size={14} />}
data-testid="panel-editor-v2-close"
onClick={handleCloseClick}
/>
<Divider type="vertical" />
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
</div>
<ConfirmDialog
open={discard.open}
onOpenChange={(next): void => {
if (!next) {
discard.cancel();
}
}}
title="Discard changes?"
titleIcon={<SolidAlertTriangle size={14} color="#fdd600" />}
confirmText="Discard"
confirmColor="destructive"
cancelText="Keep editing"
onConfirm={discard.confirm}
onCancel={discard.cancel}
data-testid="panel-editor-v2-discard-modal"
>
<Typography>Your unsaved edits to this panel will be lost.</Typography>
</ConfirmDialog>
</div>
);
}
export default Header;

View File

@@ -0,0 +1,28 @@
// Full-page editor: fills the route's content area as a header-over-split
// column (the editor is its own page now, not a modal overlay).
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
}
.left {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.right {
display: flex;
}
.handle {
background: var(--l1-border);
&:hover {
background: var(--l2-border);
}
}

View File

@@ -0,0 +1,45 @@
@use '../../../../../styles/scrollbar' as *;
.container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: auto;
background-color: var(--l1-background);
@include custom-scrollbar;
}
.scrollArea {
padding: 12px;
}
.tabsContainer {
width: 100%;
:global(.ant-tabs-tab) {
background-color: var(--l2-background) !important;
border-color: var(--l2-border) !important;
}
:global(.ant-tabs-tab-active) {
background-color: var(--l1-background) !important;
}
:global(.ant-tabs-nav) {
&::before {
border-color: var(--l2-border);
}
}
}
.queryTypeTab {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.runQueryBtnContainer {
padding: 4px 0 8px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
}

View File

@@ -0,0 +1,165 @@
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch (the button's cancel action). */
onCancelQuery: () => void;
/** Optional content pinned below the builder (e.g. the List columns editor). */
footer?: ReactNode;
}
/**
* Builder UI for the V2 panel editor's left pane: queryType tabs (Query Builder /
* ClickHouse / PromQL) plus the Stage & Run button, all reading/writing the global
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
footer,
}: PanelEditorQueryBuilderProps): JSX.Element {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
);
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
// capture phase so it still fires for fields that stop bubbling (filter search,
// CodeMirror).
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
onStageRunQuery();
}
},
[onStageRunQuery],
);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
[],
);
const items = useMemo(() => {
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery
/>
</div>
),
},
[EQueryType.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[EQueryType.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>
),
label: 'PromQL',
component: <PromQLQueryContainer />,
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
}));
}, [panelType, filterConfigs, isDarkMode]);
return (
<div
className={styles.container}
data-testid="panel-editor-v2-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<div className={styles.scrollArea}>
<Tabs
type="card"
className={styles.tabsContainer}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
<RunQueryBtn
className="run-query-dashboard-btn"
label="Stage & Run Query"
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={onCancelQuery}
/>
</span>
}
items={items}
/>
</div>
{footer}
</div>
);
}
export default PanelEditorQueryBuilder;

View File

@@ -0,0 +1,59 @@
.preview {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
padding: 24px;
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
background-size: 20px 20px;
border-bottom: 1px solid var(--l1-border);
}
.header {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
}
.queryType {
display: inline-flex;
padding: 4px 8px 4px 6px;
align-items: center;
gap: 6px;
border-radius: 4px;
background: var(--l3-background);
backdrop-filter: blur(6px);
width: fit-content;
}
.container {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
}
.surface {
flex: 1;
min-width: 0;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
display: flex;
background: var(--l2-background);
padding: 8px;
}
.state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--l2-forground);
font-size: 13px;
text-align: center;
}

View File

@@ -0,0 +1,83 @@
import { Spline } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
panelDef: RenderablePanelDefinition | undefined;
data: PanelQueryData;
isLoading: boolean;
error: Error | null;
/** Re-run the query (drives PanelBody's error-state retry). */
refetch: () => void;
/** Drag-to-zoom on a time-axis chart → updates the (URL-synced) time window. */
onDragSelect: (start: number, end: number) => void;
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
pagination?: PanelPagination;
}
/**
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
* is the production render path. The query result is owned by the editor root.
*/
function PreviewPane({
panelId,
panel,
panelDef,
data,
isLoading,
error,
refetch,
onDragSelect,
pagination,
}: PreviewPaneProps): JSX.Element {
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
</div>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.container}>
<div className={styles.surface}>
{panelDef ? (
<PanelBody
panelDefinition={panelDef}
panel={panel}
panelId={panelId}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_EDIT}
pagination={pagination}
/>
) : (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
)}
</div>
</div>
</div>
);
}
export default PreviewPane;

View File

@@ -0,0 +1,93 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorDraft } from '../usePanelEditorDraft';
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name, description },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorDraft', () => {
it('exposes the panel spec and starts clean', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
expect(result.current.spec).toBe(result.current.draft.spec);
expect(result.current.spec.display?.name).toBe('CPU');
expect(result.current.isSpecDirty).toBe(false);
});
it('flags dirty and writes through on a display (title) edit via setSpec', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
display: { ...result.current.spec.display, name: 'Memory' },
}),
);
expect(result.current.isSpecDirty).toBe(true);
expect(result.current.draft.spec?.display?.name).toBe('Memory');
});
it('flags dirty on a plugin-spec (non-display) edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'bytes' } },
},
} as typeof result.current.spec),
);
expect(result.current.isSpecDirty).toBe(true);
expect(
(
result.current.draft.spec?.plugin?.spec as {
formatting?: { unit?: string };
}
)?.formatting?.unit,
).toBe('bytes');
});
it('does not flag spec-dirty when only spec.queries changes (owned by the builder)', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
queries: [{ id: 'committed-by-builder' }],
} as unknown as typeof result.current.spec),
);
expect(result.current.isSpecDirty).toBe(false);
});
it('reset restores the spec and clears dirty after an edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));
act(() =>
result.current.setSpec({
...result.current.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'ms' } },
},
} as typeof result.current.spec),
);
act(() => result.current.reset());
expect(result.current.isSpecDirty).toBe(false);
expect(result.current.spec.display?.name).toBe('CPU');
});
});

View File

@@ -0,0 +1,331 @@
import { renderHook } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
getIsQueryModified: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
fromPerses: jest.fn(),
toPerses: jest.fn(),
}));
// commitQuery's no-op guard compares queries at the envelope level; with the
// adapters mocked, unwrap identity-style so the opaque fixtures stay distinct
// (CONVERTED vs SAVED) and the commit decisions are what's under test.
jest.mock('../../../queryV5/buildQueryRangeRequest', () => ({
toQueryEnvelopes: jest.fn((queries: unknown) => queries),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockUseShareBuilderUrl = useShareBuilderUrl as unknown as jest.Mock;
const mockGetIsQueryModified = getIsQueryModified as unknown as jest.Mock;
const mockFromPerses = fromPerses as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
// Opaque fixtures — the adapters are mocked, so only identity matters here.
const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
function makeDraft(
queries = SAVED_QUERIES,
kind = 'signoz/TimeSeriesPanel',
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'Panel' },
plugin: { kind, spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
function builderState(
overrides: Partial<{
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
}> = {},
): {
currentQuery: Query;
stagedQuery: Query | null;
handleRunQuery: jest.Mock;
} {
return {
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
stagedQuery: STAGED_V1,
handleRunQuery: jest.fn(),
...overrides,
};
}
describe('usePanelEditorQuerySync', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFromPerses.mockReturnValue(SEED_V1);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
mockGetIsQueryModified.mockReturnValue(false);
mockUseQueryBuilder.mockReturnValue(builderState());
});
function setup(
opts: {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
} = {},
): {
result: {
current: {
runQuery: () => void;
isQueryDirty: boolean;
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
};
};
setSpec: jest.Mock;
refetch: jest.Mock;
rerender: () => void;
} {
const setSpec = opts.setSpec ?? jest.fn();
const refetch = opts.refetch ?? jest.fn();
const draft = opts.draft ?? makeDraft();
const { result, rerender } = renderHook(() =>
usePanelEditorQuerySync({
draft,
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
}),
);
return { result, setSpec, refetch, rerender };
}
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
forceReset: true,
});
});
it('does not touch the draft on mount for an unedited panel', () => {
const { setSpec, refetch } = setup();
// Mount runs the type-change effect once; an unedited query must no-op.
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).not.toHaveBeenCalled();
});
it('compares the live query against the saved query (seed), not the staged query', () => {
const currentQuery = { id: 'current', queryType: 'builder' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery }));
const { result } = setup();
result.current.runQuery();
// Baseline is the saved seed — a stale staged/URL query must not be the
// reference, or a real datasource switch would read as "unchanged".
expect(mockGetIsQueryModified).toHaveBeenCalledWith(currentQuery, SEED_V1);
});
describe('runQuery', () => {
it('stages the query (handleRunQuery)', () => {
const handleRunQuery = jest.fn();
mockUseQueryBuilder.mockReturnValue(builderState({ handleRunQuery }));
const { result } = setup();
result.current.runQuery();
expect(handleRunQuery).toHaveBeenCalledTimes(1);
});
it('commits a modified query into the draft and does not force a refetch', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
expect(refetch).not.toHaveBeenCalled();
});
it('forces a refetch and leaves the draft alone when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).not.toHaveBeenCalled();
expect(refetch).toHaveBeenCalledTimes(1);
});
it('commits a datasource switch even when the staged query is stale (no revert to saved)', () => {
// A stale staged query (e.g. URL-restored after refresh) must not be used
// as the baseline; the switch is detected against the saved seed and the
// live query is committed so the preview fetches it.
mockUseQueryBuilder.mockReturnValue(builderState({ stagedQuery: null }));
mockGetIsQueryModified.mockReturnValue(true);
const { result, setSpec, refetch } = setup();
result.current.runQuery();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
expect(refetch).not.toHaveBeenCalled();
});
});
describe('query-type switch', () => {
it('commits the active query when the query type changes', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Switch query type → the effect should commit.
state.currentQuery = { id: 'b', queryType: 'promql' } as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when the active query type is unchanged', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Same query type, different object → effect must not re-fire.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('datasource switch', () => {
const withSource = (id: string, dataSource: string): Query =>
({
id,
queryType: 'builder',
builder: { queryData: [{ dataSource }] },
}) as unknown as Query;
it('commits the active query when a query datasource changes', () => {
const state = builderState({ currentQuery: withSource('a', 'logs') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Switch datasource logs → traces → the effect should commit (→ refetch).
state.currentQuery = withSource('b', 'traces');
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when the datasource is unchanged', () => {
const state = builderState({ currentQuery: withSource('a', 'logs') });
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Same datasource, different object → effect must not re-fire.
state.currentQuery = withSource('b', 'logs');
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('query dirty + save', () => {
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
// Baseline is the builder's own normalized staged query — immune to the
// raw-seed vs builder-normalized serialization drift.
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
expect.anything(),
STAGED_V1,
);
expect(result.current.isQueryDirty).toBe(true);
});
it('is not query-dirty when the live query matches the baseline', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
it('buildSaveSpec bakes the live query in when dirty', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
});
});
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toBe(spec);
});
});
});

View File

@@ -0,0 +1,82 @@
import { renderHook } from '@testing-library/react';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorSave } from '../usePanelEditorSave';
const mockInvalidateQueries = jest.fn();
jest.mock('react-query', () => ({
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
invalidateQueries: mockInvalidateQueries,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
usePatchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
describe('usePanelEditorSave', () => {
const mutateAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
});
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
const spec = {
display: { name: 'New title', description: 'desc' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: 'bytes' } },
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
await result.current.save(spec);
expect(mutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'dash-1' },
data: [
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: spec,
},
],
});
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
expect(mockInvalidateQueries).toHaveBeenCalledWith([
'/api/v2/dashboards/dash-1',
]);
});
it('surfaces the mutation loading state as isSaving', () => {
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: true,
error: null,
});
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
expect(result.current.isSaving).toBe(true);
});
});

View File

@@ -0,0 +1,50 @@
import { useCallback, useMemo, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import type { PanelEditorDraftApi } from '../types';
/**
* Owns the editable draft of a single panel, seeded once from the loaded panel and
* mutated locally until save. Kept in the perses `DashboardtypesPanelDTO` shape so the
* preview renders it through the dashboard's renderer registry and the save hook
* patches it without conversion. Everything the config pane edits flows through the
* single `spec`/`setSpec` pair.
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
const setSpec = useCallback((next: DashboardtypesPanelSpecDTO): void => {
setDraft((prev) => ({ ...prev, spec: next }));
}, []);
const reset = useCallback((): void => {
setDraft(initialPanel);
}, [initialPanel]);
// Deep compare, ignoring `spec.queries`: the query is owned by the builder and
// re-serialized into the draft as a preview cache, so its representation drifts
// without a real edit. Query dirtiness is tracked separately; here we only flag
// divergence in the display + plugin spec slices.
const isSpecDirty = useMemo(
() =>
!isEqual(
{ ...draft, spec: { ...draft.spec, queries: null } },
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
),
[draft, initialPanel],
);
return {
draft,
spec: draft.spec,
setSpec,
isSpecDirty,
reset,
};
}

View File

@@ -0,0 +1,145 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { isEqual } from 'lodash-es';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { fromPerses, toPerses } from '../../queryV5/persesQueryAdapters';
interface UsePanelEditorQuerySyncArgs {
draft: DashboardtypesPanelDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
refetch: () => void;
}
interface UsePanelEditorQuerySyncApi {
/** Run the current query (Stage & Run / ⌘↵). */
runQuery: () => void;
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
isQueryDirty: boolean;
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
}
/**
* Bridges the shared (URL-synced) query builder and the V2 editor draft: seeds the
* builder from the saved panel, then commits the active query into `draft.spec.queries`
* (what the preview fetches) on a query-type/datasource switch and on Stage & Run.
*/
export function usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
// Saved queries, captured once: seed the builder and serve as the restore target.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
const seedQuery = useMemo(
() => fromPerses(savedQueries, panelType),
[savedQueries, panelType],
);
// Force-reset the builder to the SAVED panel on first render only, discarding any
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
forceReset: isInitialRenderRef.current,
});
useEffect(() => {
isInitialRenderRef.current = false;
}, []);
// Commit the live query into the draft (what the preview fetches). The dirty check
// compares against the SAVED query (`seedQuery`), not the URL-synced staged query,
// which can carry stale state across a refresh and make a real switch read as
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
// the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
? toPerses(query, panelType)
: savedQueries;
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so comparing them structurally would falsely dirty the draft.
const current = draft.spec?.queries ?? [];
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
return false;
}
setSpec({ ...draft.spec, queries: next });
return true;
},
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
);
// Latest query/commit, read by the structural-change effect without re-subscribing.
const commitRef = useRef(commitQuery);
commitRef.current = commitQuery;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
// mount: the draft already holds the saved queries the builder is force-reset to.
const dataSourceSignature = useMemo(
() =>
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
[currentQuery.builder],
);
const didMountRef = useRef(false);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
}
commitRef.current(queryRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
if (!commitQuery(currentQuery)) {
refetch();
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty baseline: the builder's OWN normalized saved query (first non-null
// `stagedQuery` after the mount reset). Comparing builder-normalized to
// builder-normalized avoids serialization drift reading an untouched query as
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
// captured once and never moved by Stage & Run, so it stays anchored to saved.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
setQueryBaseline(stagedQuery);
}
}, [queryBaseline, stagedQuery]);
const isQueryDirty =
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
isQueryDirty
? { ...spec, queries: toPerses(currentQuery, panelType) }
: spec,
[isQueryDirty, currentQuery, panelType],
);
return { runQuery, isQueryDirty, buildSaveSpec };
}

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesPatchOpDTO,
} from 'api/generated/services/sigNoz.schemas';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
}
interface UsePanelEditorSaveApi {
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
isSaving: boolean;
error: Error | null;
}
/**
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
* title/description). `add` doubles as create-or-replace, avoiding a separate
* existence check.
*/
export function usePanelEditorSave({
dashboardId,
panelId,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: DashboardtypesPatchOpDTO.add,
path: `/spec/panels/${panelId}/spec`,
value: spec,
},
];
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(
getGetDashboardV2QueryKey({ id: dashboardId }),
);
},
[dashboardId, panelId, mutateAsync, queryClient],
);
return { save, isSaving: isLoading, error: (error as Error) ?? null };
}

View File

@@ -0,0 +1,173 @@
import { useCallback } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
}
/**
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
* split with the live preview + query builder on the left and the config pane on the
* right. Owns the draft state and the save round-trip.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
// Panel kind → V1 panel type, which drives the query builder and preview.
const fullKind = draft.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
// One shared query result for the whole editor; the preview renders it.
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
const {
data,
isLoading,
isFetching,
error,
cancelQuery,
refetch,
pagination,
} = usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDef,
});
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
});
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties.
const isDirty = isSpecDirty || isQueryDirty;
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const onSave = useCallback(async (): Promise<void> => {
try {
// Bake the live query into the spec so unstaged edits are saved too.
await save(buildSaveSpec(draft.spec));
toast.success('Panel saved');
onSaved();
} catch {
toast.error('Failed to save panel');
}
}, [save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
onSave={onSave}
onClose={onClose}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
<PreviewPane
panelId={panelId}
panel={draft}
panelDef={panelDef}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelType={panelType}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
/>
</ResizablePanelGroup>
</div>
);
}
export default PanelEditorContainer;

View File

@@ -0,0 +1,15 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
/**
* `Storage`-shaped adapter for `useDefaultLayout`, backed by the scoped localStorage
* wrappers that prefix keys with the URL base path so layout stays isolated per deployment.
*/
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
getItem: (key: string): string | null => getLocalStorageApi(key),
setItem: (key: string, value: string): void => {
setLocalStorageApi(key, value);
},
};
export default layoutStorage;

View File

@@ -0,0 +1,25 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Local draft state for the panel being edited, kept as a perses `DashboardtypesPanelDTO`
* so the live preview and the save patch share one shape (no intermediate translation).
*/
export interface PanelEditorDraftApi {
/** The current (possibly edited) panel. Always defined once seeded. */
draft: DashboardtypesPanelDTO;
/** The panel spec — the single editing surface for the config pane. */
spec: DashboardtypesPanelSpecDTO;
/** Replace the whole panel spec (the registry lens returns a new one per edit). */
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/**
* True when the draft's display/plugin-spec slices diverge from the loaded panel.
* Excludes `spec.queries` — owned by the shared builder, tracked via
* `usePanelEditorQuerySync.isQueryDirty`.
*/
isSpecDirty: boolean;
/** Restore the draft to the originally-loaded panel. */
reset: () => void;
}

View File

@@ -42,9 +42,7 @@ function BarPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing.
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
[panel.spec.plugin.spec],
@@ -55,9 +53,8 @@ function BarPanelRenderer({
[panel.spec.queries],
);
// X-scale clamps come from the request that produced the data (falls back
// to the global picker inside the helper). The generated request DTO is
// structurally the hand-written V5 request; the cast is the boundary.
// X-scale clamps come from the request that produced the data. The generated
// request DTO is structurally the V5 request; the cast is the boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
@@ -100,10 +97,8 @@ function BarPanelRenderer({
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
// TooltipPlugin mutates `config` for cursor sync; rebuild on syncMode change
// so a fresh instance doesn't inherit stale sync settings (e.g. "No Sync").
dashboardPreference?.syncMode,
],
);
@@ -126,10 +121,8 @@ function BarPanelRenderer({
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
// Keying on sync prefs forces a full chart teardown/re-mount so stale sync
// settings aren't inherited — the only way to fully reset the uPlot instance.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(

View File

@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
search: false,
},
};

View File

@@ -1,9 +1,12 @@
import type { SectionConfig } from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
];

View File

@@ -16,10 +16,7 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
@@ -34,14 +31,7 @@ export interface BuildBarChartConfigArgs {
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
/** Builds a `UPlotConfigBuilder` for a Bar chart panel: shared scaffolding, optional stacking, one bar series per result. */
export function buildBarChartConfig({
panelId,
spec,
@@ -97,11 +87,8 @@ interface AddSeriesArgs {
}
/**
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
* when `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*
* Adds one bar series per flattened V5 series (plus stacking bands). Each gets its
* own per-query step interval so bar widths match the backend's sampling cadence.
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({

View File

@@ -34,9 +34,7 @@ function HistogramPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
[panel.spec.plugin.spec],

View File

@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
search: false,
},
};

View File

@@ -22,12 +22,10 @@ const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Bins raw series values into a uPlot-aligned histogram. Bucket size is the
* `bucketWidth` override, else the smallest predefined Grafana bucket that fits
* the `range / bucketCount` target while staying ≥ the input's smallest non-zero
* delta (never sub-dividing below the input resolution).
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
@@ -58,10 +56,9 @@ export function prepareHistogramData({
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(series, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
// Merged mode leaves trailing empty frames — drop those. Per-query mode keeps
// one column per result row (even empty ones), else the column count falls below
// the series count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
@@ -76,7 +73,7 @@ export function prepareHistogramData({
return merged;
}
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
/** Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity). */
function toBinnableValue(value: number): number {
return Number.isFinite(value) ? value : 0;
}
@@ -128,8 +125,10 @@ function selectBucketSize({
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
/**
* When merging is on, fold all frames into the first; the trailing empty
* frames stay in the array so downstream `.filter(length > 0)` drops them.
*/
function buildFrames(
series: PanelSeries[],
mergeAllActiveQueries: boolean,

View File

@@ -1,6 +1,21 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
{
kind: 'legend',
controls: { position: true },
// Merging all queries collapses to one distribution with no legend.
isHidden: (spec): boolean =>
Boolean(
(spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO).histogramBuckets
?.mergeAllActiveQueries,
),
},
{
kind: 'buckets',
controls: { count: true, width: true, mergeQueries: true },
},
{ kind: 'contextLinks' },
];

View File

@@ -12,8 +12,7 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
// Merged-series colors mirror the V1 default so the merged view looks unchanged.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
@@ -30,13 +29,9 @@ export interface BuildHistogramConfigArgs {
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
* Builds a `UPlotConfigBuilder` for a Histogram panel. Unlike time-axis panels,
* histograms have no time scale or drag-to-zoom: reuses `buildBaseConfig`, then
* overrides the scales to auto-linear and installs a drag-disabled cursor.
*/
export function buildHistogramConfig({
panelId,
@@ -47,8 +42,6 @@ export function buildHistogramConfig({
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
// Histograms have no time axis — no stepIntervals, and no click plugin
// (the renderer passes no onClick), so the base config needs no response.
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
@@ -62,8 +55,7 @@ export function buildHistogramConfig({
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
// Override the time-axis scales — histograms are distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
@@ -81,10 +73,9 @@ interface AddSeriesArgs {
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
* Adds histogram bar series. In `mergeAllActiveQueries` mode `prepareHistogramData`
* produces a single Y column, so we add exactly one series with the fixed merged-mode
* colors; otherwise one series per result row.
*/
function addSeries({
builder,

View File

@@ -17,9 +17,7 @@ function NumberPanelRenderer({
panel,
data,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
// documented boundary narrowing.
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
[panel.spec.plugin.spec],

View File

@@ -24,9 +24,7 @@ interface ValueDisplayProps {
/**
* Renders a single large scalar with optional prefix/suffix units and threshold
* recoloring (text or background). A V2-native replacement for the V1
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
* typography primitives.
* recoloring (text or background). V2-native replacement for the V1 `ValueGraph`.
*/
function ValueDisplay({
value,

View File

@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
search: false,
},
};

View File

@@ -1,17 +1,10 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
/**
* Reduces the scalar tables of a V5 response to the single number a
* NumberPanel renders.
*
* V2 always issues `requestType: 'scalar'` for VALUE panels, so the response
* is a scalar table per query (see `prepareScalarTables`). The value is the
* first row's `isValueColumn` cell of the first table that has rows —
* falling back to the row's first cell when no column is marked as the
* value (mirrors the V1 `formatForWeb` fallback read).
*
* Returns `null` when there is no numeric value to show, which the renderer
* maps to the "No Data" state.
* Reduces the scalar tables of a V5 response to the single number a NumberPanel
* renders: the first row's `isValueColumn` cell of the first table with rows,
* falling back to the row's first cell (mirrors the V1 `formatForWeb` read).
* Returns `null` when there is no numeric value (renderer shows "No Data").
*/
export function prepareNumberData(tables: PanelTable[]): number | null {
for (const table of tables) {

View File

@@ -1,8 +1,8 @@
import type { SectionConfig } from '../../types/sections';
// A number panel renders one scalar — no axes, legend, or stacking. Just value
// formatting and thresholds that recolor the value/background.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'thresholds', controls: { variant: 'comparison' } },
{ kind: 'contextLinks' },
];

View File

@@ -1,42 +1,9 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesComparisonThresholdDTO } from 'api/generated/services/sigNoz.schemas';
import type {
PanelThreshold,
ThresholdComparisonOperator,
ThresholdDisplayFormat,
} from '../../types/threshold';
import type { PanelThreshold } from '../../types/threshold';
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
// Perses comparison operators → the symbol operators V2 threshold evaluation
// uses.
const OPERATOR_MAP: Record<
DashboardtypesComparisonOperatorDTO,
ThresholdComparisonOperator
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
};
const FORMAT_MAP: Record<
DashboardtypesThresholdFormatDTO,
ThresholdDisplayFormat
> = {
[DashboardtypesThresholdFormatDTO.text]: 'text',
[DashboardtypesThresholdFormatDTO.background]: 'background',
};
/**
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
* V2-native `PanelThreshold` consumed by `ValueDisplay` / threshold
* evaluation. No dependency on the V1 `ThresholdProps` shape.
*/
/** Maps spec `ComparisonThresholdDTO`s onto the V2-native `PanelThreshold` (no V1 `ThresholdProps` dependency). */
export function mapNumberThresholds(
thresholds: DashboardtypesComparisonThresholdDTO[] | null | undefined,
): PanelThreshold[] {
@@ -44,11 +11,5 @@ export function mapNumberThresholds(
return [];
}
return thresholds.map((threshold) => ({
color: threshold.color,
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
value: threshold.value,
unit: threshold.unit,
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
}));
return thresholds.map(toPanelThreshold);
}

View File

@@ -24,9 +24,7 @@ function PiePanelRenderer({
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
// documented boundary narrowing.
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
[panel.spec.plugin.spec],

View File

@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: false,
search: false,
},
};

View File

@@ -12,11 +12,9 @@ export interface PreparePieDataArgs {
}
/**
* Turns the scalar tables of a V5 response into pie slices: one slice per
* group row. The aggregation column holds the value, the group column(s)
* form the label. Colours honour `customColors` then fall back to a
* deterministic palette colour; non-positive / non-numeric values are
* dropped.
* Turns the scalar tables of a V5 response into pie slices (one per group row):
* value column → value, group column(s) → label. Colours honour `customColors`
* then fall back to the deterministic palette; non-positive/non-numeric dropped.
*/
export function preparePieData({
tables,

View File

@@ -1,8 +1,10 @@
import type { SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a
// legend. `mode` is omitted: the pie legend is always interactive swatches.
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
// Legend `colors` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'contextLinks' },
];

View File

@@ -42,10 +42,8 @@ function TimeSeriesPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
// documented boundary narrowing — not a blind assertion. Memoized so the
// `?? {}` fallback doesn't produce a fresh object on each render.
// The registry guarantees the kind, so the cast is a boundary narrowing.
// Memoized so the `?? {}` fallback doesn't produce a fresh object each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
[panel.spec.plugin.spec],
@@ -56,12 +54,9 @@ function TimeSeriesPanelRenderer({
[panel.spec.queries],
);
// X-scale clamps come from the request that produced the data, so each
// panel pins to the window it actually fetched — important during
// drag-zoom transitions when the time picker has moved but new data
// hasn't arrived yet. Falls back to the global picker inside the helper.
// The generated request DTO is structurally the hand-written V5 request;
// the cast is the documented boundary.
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched — matters during drag-zoom transitions before
// new data arrives. The generated request DTO is structurally the V5 request.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
@@ -104,10 +99,8 @@ function TimeSeriesPanelRenderer({
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
// TooltipPlugin mutates `config` for cursor sync; rebuild on syncMode change
// so a fresh instance doesn't inherit stale sync settings (e.g. "No Sync").
dashboardPreference?.syncMode,
],
);
@@ -130,12 +123,8 @@ function TimeSeriesPanelRenderer({
[panelId],
);
/**
* The uPlot key prop is the only way to force a full teardown and re-mount
* of the chart. By including the syncMode and syncFilterMode in the key,
* we ensure that changes to these preferences trigger a fresh chart instance,
* preventing stale sync settings from being inherited.
*/
// Keying on sync prefs forces a full chart teardown/re-mount so stale sync
// settings aren't inherited — the only way to fully reset the uPlot instance.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(

View File

@@ -10,4 +10,12 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
actions: {
view: true,
edit: true,
clone: true,
download: false,
createAlert: true,
search: false,
},
};

View File

@@ -1,15 +1,20 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true, colors: true } },
{
kind: 'formatting',
kind: 'chartAppearance',
controls: {
unit: true,
decimals: true,
lineStyle: true,
lineInterpolation: true,
fillMode: true,
showPoints: true,
spanGaps: true,
},
},
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
];

View File

@@ -31,10 +31,7 @@ const DEFAULT_POINT_SIZE = 5;
export interface BuildTimeSeriesConfigArgs {
panelId: string;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
@@ -49,14 +46,7 @@ export interface BuildTimeSeriesConfigArgs {
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the TimeSeries-specific concern: one series per result, with visuals
* resolved from `spec.chartAppearance`.
*/
/** Builds a `UPlotConfigBuilder` for a TimeSeries panel: shared scaffolding plus one series per result. */
export function buildTimeSeriesConfig({
panelId,
spec,
@@ -104,11 +94,7 @@ interface AddSeriesArgs {
}
/**
* Adds one uPlot series per flattened V5 series to the scaffolded builder.
* The visual resolution (line style, interpolation, fill mode, span gaps)
* reads from `spec.chartAppearance`; the label is resolved via the legend
* matrix in `resolveSeriesLabelV5`. Mutates the builder in place.
*
* Adds one uPlot series per flattened V5 series; mutates the builder in place.
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({

View File

@@ -7,7 +7,7 @@ import type {
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
import { PanelKind } from './types/panelKind';
// Pure assembly: each kind owns its own PanelDefinition (see
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a

View File

@@ -2,12 +2,7 @@ import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { PanelKind } from './panelKind';
/**
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
* each non-chart kind carries the context its drill-down needs. The `source`
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
* handler) discriminate without assuming a chart shape.
*/
/** Source-tagged click events; each non-chart kind carries its own drill-down context. */
export type ChartClickEvent = ChartClickData;
export type TableClickEvent = {
rowData: Record<string, unknown>;
@@ -28,11 +23,9 @@ export type PanelClickEvent =
type DragSelect = (start: number, end: number) => void;
/**
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
* rendererProps.ts indexes this map, so a missing kind is a compile error there.
* Per-kind interaction props — each kind exposes only the gestures it supports.
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
* is a compile error there.
*/
export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TimeSeriesPanel': {
@@ -51,9 +44,8 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
};
/**
* Widest interaction surface — used where the panel kind is not known
* statically (the registry render boundary; see `getPanelDefinition`). It is
* the structural supertype the per-kind shapes are cast to exactly once.
* Widest interaction surface — used where the kind isn't known statically (the
* registry render boundary). The supertype the per-kind shapes are cast to once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;

View File

@@ -6,23 +6,45 @@ import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/**
* 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.
*/
export interface PanelActionCapabilities {
/** Kind has a full-screen view — gates the "View" action. */
view: boolean;
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
edit: boolean;
/** Kind can be cloned — 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". */
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`.
*/
search: boolean;
}
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
actions: PanelActionCapabilities;
}
// Keyed registry that preserves the kind ↔ definition correlation: indexing
// with a literal kind yields that kind's exactly-typed PanelDefinition.
// Indexing with a literal kind yields that kind's exactly-typed PanelDefinition.
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
// At the render boundary the concrete kind isn't known statically (a registry
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
// concentrating the single unavoidable cast in one place instead of leaking it
// to every call site.
// 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

@@ -2,11 +2,9 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import type { DashboardtypesPanelPluginKindDTO } from 'api/generated/services/sigNoz.schemas';
/**
* String-literal union of every panel kind, derived from the generated enum so
* the contract stays the single source of truth. Kept as a `${enum}` union
* (not the nominal enum) so plain string-literal kinds — `PanelRendererProps<
* 'signoz/TimeSeriesPanel'>`, registry keys, `PanelInteractionMap` keys —
* remain assignable without enum-member ceremony at every call site.
* String-literal union of every panel kind, derived from the generated enum.
* A `${enum}` union (not the nominal enum) so plain string-literal kinds stay
* assignable without enum-member ceremony at every call site.
*/
export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;

View File

@@ -4,47 +4,32 @@ import type {
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { PanelInteractionMap } from './interactions';
import type { PanelKind } from './panelKind';
/**
* Dashboard-wide rendering preferences propagated down to every panel renderer
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
* sync, tooltip filter mode, dashboard id for scoped state) without each
* renderer rediscovering them via hooks.
*/
/** Dashboard-wide rendering preferences propagated to every panel renderer. */
export interface DashboardPreference {
/**
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
* hovering one panel highlights the corresponding x on every other panel.
* Always present — `DashboardCursorSync.None` is the off state.
*/
/** Cursor-sync mode; always present — `DashboardCursorSync.None` is the off state. */
syncMode: DashboardCursorSync;
/**
* Filter applied to the synced tooltip across panels (e.g. only show series
* whose label matches the hovered series).
*/
/** Filter applied to the synced tooltip across panels. */
syncFilterMode?: SyncTooltipFilterMode;
/**
* Dashboard id — useful for renderers that scope per-dashboard state
* (e.g. pinned-tooltip persistence, drill-down history).
*/
/** Dashboard id, for renderers that scope per-dashboard state. */
dashboardId?: string;
}
// Kind-agnostic props every renderer receives, regardless of panel kind. The
// kind-specific interaction props (onClick payload, onDragSelect) are layered
// on per-kind by PanelRendererProps<K>.
// Kind-agnostic props every renderer receives. Kind-specific interaction props
// are layered on per-kind by PanelRendererProps<K>.
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive their concrete `spec` and the
* perses-shaped `queries` from this. Passing the full panel keeps the prop
* surface stable as new panel-level fields are added to the wire format.
* Required: the render boundary (`Panel`) only mounts a renderer once the
* panel and its kind are resolved, so a renderer never sees an absent panel.
* The whole perses panel — renderers derive `spec` and `queries` from this.
* Required: the render boundary only mounts a renderer once the panel and its
* kind are resolved, so a renderer never sees an absent panel.
*/
panel: DashboardtypesPanelDTO;
/** Raw V5 fetch result — response + the request that produced it. */
@@ -53,24 +38,21 @@ export interface BaseRendererProps {
error: Error | null;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/**
* Render context — varies behavior (e.g. dashboard widget vs. standalone
* full-screen vs. inside the editor). See PanelMode for the contract.
*/
/** Render context (dashboard widget vs. standalone vs. editor); see PanelMode. */
panelMode: PanelMode;
/**
* Dashboard-level preferences that should propagate to every panel
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
* resolving these; the renderer just consumes them.
*/
/** Dashboard-level preferences propagated to every panel; shell resolves, renderer consumes. */
dashboardPreference?: DashboardPreference;
/**
* Free-text filter from the header search box, applied client-side. Only
* meaningful for kinds that declare `actions.search`; others ignore it.
*/
searchTerm?: string;
/** Server-side paging handles. Present only for raw/list panels; others ignore it. */
pagination?: PanelPagination;
}
// Renderer props for a specific panel kind: the shared base plus that kind's
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
// only reference the gestures that kind supports. Indexing PanelInteractionMap
// here forces the map to cover every PanelKind. The default K = PanelKind
// yields the widest surface (a union over all kinds).
// Renderer props for a specific kind: shared base plus that kind's interaction
// surface. Indexing PanelInteractionMap forces it to cover every PanelKind; the
// default K = PanelKind yields the widest surface (a union over all kinds).
export type PanelRendererProps<K extends PanelKind = PanelKind> =
BaseRendererProps & PanelInteractionMap[K];

View File

@@ -1,8 +1,25 @@
import type {
DashboardLinkDTO,
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTableFormattingDTO,
DashboardtypesTableThresholdDTO,
DashboardtypesThresholdWithLabelDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
BarChart,
Columns3,
Hash,
ListEnd,
Layers,
LayoutDashboard,
Link,
Palette,
Ruler,
SlidersHorizontal,
@@ -18,38 +35,117 @@ export interface SectionMetadata {
description?: string;
}
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
// Section components type their controls prop via `SectionControls['axes']`.
export type SectionControls = {
formatting: { unit?: boolean; decimals?: boolean };
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
legend: { position?: boolean; mode?: boolean };
thresholds: { list?: boolean };
/**
* Which threshold editor a kind uses. All three variants persist to the same
* `plugin.spec.thresholds` key with different element shapes:
* - `label` — value + color + label lines (TimeSeries / Bar)
* - `comparison` — value crosses an operator → recolor (Number)
* - `table` — per-column comparison (Table)
*/
export type ThresholdVariant = 'label' | 'comparison' | 'table';
/** Union of every threshold element shape stored under `plugin.spec.thresholds`. */
export type AnyThreshold =
| DashboardtypesThresholdWithLabelDTO
| DashboardtypesComparisonThresholdDTO
| DashboardtypesTableThresholdDTO;
/**
* Each section ↔ one slice of the panel spec it edits. Most slices live under
* `spec.plugin.spec.<key>`; `contextLinks` is panel-level (`spec.links`).
*/
// Superset spanning every kind's formatting DTO; the `controls` bag gates which
// fields a kind actually writes.
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
export interface SectionSpecMap {
formatting: PanelFormattingSlice; // spec.plugin.spec.formatting
axes: DashboardtypesAxesDTO; // spec.plugin.spec.axes
legend: DashboardtypesLegendDTO; // spec.plugin.spec.legend
chartAppearance: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
buckets: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
// spec.plugin.spec.visualization — typed as the Bar shape (widest superset);
// the `controls` bag gates which fields each kind writes.
visualization: DashboardtypesBarChartVisualizationDTO;
thresholds: AnyThreshold[]; // spec.plugin.spec.thresholds (variant picks the editor)
contextLinks: DashboardLinkDTO[]; // spec.links (PANEL-level)
columns: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
}
/**
* Controlled sections — a kind exposes a subset of the section's controls (V2
* analogue of V1's `allowSoftMinMax` / `allowLegendColors` flags).
*/
export interface SectionControls {
formatting: { unit?: boolean; decimals?: boolean; columnUnits?: boolean };
axes: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
legend: { position?: boolean; colors?: boolean }; // colors → customColors
chartAppearance: {
lineStyle?: boolean;
fillOpacity?: boolean;
stacked?: boolean;
lineInterpolation?: boolean;
fillMode?: boolean;
showPoints?: boolean;
spanGaps?: boolean;
};
columnUnits: { perColumnUnit?: boolean };
buckets: { count?: boolean; min?: boolean; max?: boolean };
};
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
// stacking → stackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
visualization: {
timePreference?: boolean;
stacking?: boolean;
fillSpans?: boolean;
};
// Editor discriminator (not a spec field): which threshold variant a kind edits.
thresholds: { variant?: ThresholdVariant };
}
// Source of truth for sections. Its keys define SectionKind; its values are the
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
// one entry here + one entry in SectionControls.
export const SECTIONS = {
export type ControlledSectionKind = keyof SectionControls;
/** Atomic sections — no sub-controls; a kind either shows them or not. */
export type AtomicSectionKind = 'contextLinks' | 'columns';
export type SectionKind = ControlledSectionKind | AtomicSectionKind;
/** Predicate to hide a section from the current spec; returning true removes it. */
export type SectionVisibilityPredicate = (
spec: DashboardtypesPanelSpecDTO,
) => boolean;
/**
* What a kind declares in `kinds/<Kind>/sections.ts`: a controlled section with
* its `controls` subset, or an atomic section bare (`{ kind }`).
*/
export type SectionConfig =
| {
[K in ControlledSectionKind]: {
kind: K;
controls: SectionControls[K];
isHidden?: SectionVisibilityPredicate;
};
}[ControlledSectionKind]
| { kind: AtomicSectionKind; isHidden?: SectionVisibilityPredicate };
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
// live in the ConfigPane section registry.
export const SECTION_METADATA = {
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: ListEnd },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
legend: { title: 'Legend', icon: Layers },
chartAppearance: { title: 'Chart appearance', icon: Palette },
columnUnits: { title: 'Column units', icon: Columns3 },
buckets: { title: 'Buckets', icon: BarChart },
} as const satisfies Record<string, SectionMetadata>;
visualization: { title: 'Visualization', icon: LayoutDashboard },
buckets: { title: 'Histogram / Buckets', icon: BarChart },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
contextLinks: { title: 'Context Links', icon: Link },
columns: { title: 'Columns', icon: Columns3 },
} as const satisfies Record<SectionKind, SectionMetadata>;
export type SectionKind = keyof typeof SECTIONS;
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];
/**
* Props every section editor receives: its slice (`value`), an `onChange`, and
* (controlled sections only) the per-kind `controls` subset.
*/
export type SectionEditorProps<K extends SectionKind> = {
value: SectionSpecMap[K] | undefined;
onChange: (next: SectionSpecMap[K]) => void;
} & (K extends ControlledSectionKind
? { controls: SectionControls[K] }
: unknown);

View File

@@ -1,13 +1,27 @@
/**
* V2-native threshold model.
*
* The panel spec carries thresholds as `DashboardtypesComparisonThresholdDTO`
* (operator/format expressed as `above`/`below`/`text`/`background`). For
* evaluation and rendering we work with the symbol operators and lowercase
* display formats, kept here so V2 panels never reach into the V1
* `container/NewWidget` `ThresholdProps` shape.
* V2-native threshold model. The spec carries thresholds as DTOs (operator as
* `above`/`below`/…); this maps them to symbol operators + lowercase formats so
* V2 panels never reach into the V1 `container/NewWidget` `ThresholdProps` shape.
*/
import type {
DashboardtypesComparisonOperatorDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Comparison-shaped fields shared by every threshold DTO that recolors on an
* operator crossing. Container DTOs add their own keys (e.g. a table threshold's
* `columnName`) around this core.
*/
export interface ComparisonThresholdShape {
color: string;
value: number;
operator?: DashboardtypesComparisonOperatorDTO;
unit?: string;
format?: DashboardtypesThresholdFormatDTO;
}
/** Comparison operators a threshold can use, as evaluable symbols. */
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
@@ -16,8 +30,8 @@ export type ThresholdDisplayFormat = 'text' | 'background';
/**
* A threshold normalized for evaluation/rendering. `operator`/`format` are
* optional because the spec allows partially-configured thresholds; a
* threshold with no operator never matches.
* optional because the spec allows partial config; a threshold with no operator
* never matches.
*/
export interface PanelThreshold {
color: string;

View File

@@ -20,11 +20,9 @@ import {
} from './selectionPreferences';
/**
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
* name but accepts perses-shaped inputs directly (so callers don't translate
* once per panel). The series-rendering step is panel-specific and lives in
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
* thresholds, axes, drag-to-zoom, click plugin).
* Inputs for the shared V2 chart pipeline. Accepts perses-shaped inputs directly
* so callers don't translate per panel. Wires only the scaffolding (scales,
* thresholds, axes, drag-to-zoom, click plugin); series rendering is per-panel.
*/
export interface BuildBaseConfigArgs {
panelId: string;
@@ -46,10 +44,7 @@ export interface BuildBaseConfigArgs {
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
/**
* Tuple-shaped payload for the shared click plugin (see
* `toClickPluginPayload`). Omitted by panels without click interactions.
*/
/** Payload for the shared click plugin; omitted by panels without click interactions. */
clickPayload?: MetricRangePayloadProps;
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
@@ -62,10 +57,9 @@ export interface BuildBaseConfigArgs {
}
/**
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
* then call `addSeries`/`addPlugin` on the returned builder for their own
* panel-specific rendering.
* Builds the panel-agnostic scaffolding of a uPlot chart (scales, thresholds,
* axes, drag-to-zoom, click plugin). Callers then `addSeries`/`addPlugin` on the
* returned builder for their own rendering.
*/
export function buildBaseConfig({
panelId,
@@ -165,9 +159,10 @@ function makeTzDate(
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
}
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
// panels that need to feed the same threshold list elsewhere (e.g. to a series
// `addSeries` thresholds hook) don't have to redo the mapping.
/**
* Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
* panels feeding the same list elsewhere don't redo the mapping.
*/
export function mapThresholds(
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
): ThresholdsDrawHookOptions['thresholds'] {
@@ -183,10 +178,9 @@ export function mapThresholds(
}
/**
* V5 backend reports per-query step intervals; we feed the smallest one through
* to uPlot so the X-axis tick density matches the densest query. An empty map
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math
* fall back to `undefined` (uPlot's "auto") in that case.
* Smallest per-query step interval, fed to uPlot so tick density matches the
* densest query. Falls back to `undefined` (uPlot "auto") on an empty map, since
* `Math.min` returns `Infinity` there and would corrupt scale math.
*/
function minStepInterval(
stepIntervals: Record<string, number>,

View File

@@ -12,12 +12,9 @@ import {
} from 'lib/uPlotV2/config/types';
/**
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
* two — don't coerce, map.
*
* Kept as a single source of truth so every panel that reads chart-appearance
* fields stays in sync as either side's enum evolves.
* Bridges the V2 wire-format enums to the uPlotV2 chart enums. String values
* diverge between the two — don't coerce, map. Single source of truth shared by
* every panel that reads chart-appearance fields.
*/
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {

View File

@@ -7,16 +7,13 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LEGEND_POSITION_MAP } from './enumMaps';
/**
* Resolvers that turn raw `spec` chart-appearance fields into the chart's
* runtime values, falling back to the chart defaults for missing/unknown input.
*/
// Resolvers turning raw `spec` chart-appearance fields into runtime chart
// values, falling back to chart defaults for missing/unknown input.
/**
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
* (`'0'``'4'` plus the sentinel `'full'`). The chart consumes a numeric
* `PrecisionOption` (`0``4`) or the same `'full'` sentinel from its own
* enum. Missing / unknown → `undefined` (chart uses its default).
* (`'0'``'4'` plus the `'full'` sentinel). Maps to a numeric `PrecisionOption`
* or the `'full'` sentinel; missing/unknown → `undefined` (chart default).
*/
export function resolveDecimalPrecision(
precision: DashboardtypesPrecisionOptionDTO | undefined,
@@ -42,8 +39,8 @@ export function resolveDecimalPrecision(
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
* the threshold so uPlot only bridges short runs of nulls.
* wire. Empty/missing → span all gaps (default); numeric → forward the threshold
* so uPlot only bridges short runs of nulls.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
@@ -55,10 +52,7 @@ export function resolveSpanGaps(
return Number.isFinite(parsed) ? parsed : true;
}
/**
* Resolves the legend position for a panel. Missing / unknown values fall
* back to `BOTTOM` to match the chart's default and the V1 behavior.
*/
/** Legend position; missing/unknown falls back to `BOTTOM` (chart default, V1 parity). */
export function resolveLegendPosition(
position: DashboardtypesLegendPositionDTO | undefined,
): LegendPosition {

View File

@@ -13,10 +13,8 @@ import type {
/**
* Threshold evaluation for V2 panels — a self-contained port of the V1
* `GridTableComponent`/`ValueGraph` logic that depends only on shared,
* non-V1 primitives (`convertValue`, the Y-axis unit catalog). No imports
* from `container/NewWidget`, `container/GridTableComponent`, or
* `components/ValueGraph`.
* `GridTableComponent`/`ValueGraph` logic, depending only on non-V1 primitives
* (`convertValue`, the Y-axis unit catalog) so it never imports V1 surfaces.
*/
/** Resolves which unit category a unit id belongs to, or null if unknown. */
@@ -25,9 +23,8 @@ function getCategoryName(unitId: string): YAxisCategoryNames | null {
const foundCategory = categories.find((category) =>
category.units.some((unit) => {
// Category units use universal ids; thresholds/panel units may use
// Grafana-style ids. Match either the universal id directly or its
// mapped Grafana id.
// Category units use universal ids; panel/threshold units may use
// Grafana-style ids. Match the universal id or its mapped Grafana id.
if (unit.id === unitId) {
return true;
}
@@ -38,10 +35,7 @@ function getCategoryName(unitId: string): YAxisCategoryNames | null {
return foundCategory ? foundCategory.name : null;
}
/**
* Converts `value` from `fromUnit` to `toUnit`, returning null when the
* conversion is invalid (unknown unit, or units in different categories).
*/
/** Converts `value` between units; null when invalid (unknown, or different categories). */
function convertUnit(
value: number,
fromUnit?: string,
@@ -85,9 +79,8 @@ function evaluateCondition(
}
/**
* Whether `value` (expressed in `panelUnit`) satisfies `threshold`. When the
* threshold declares its own unit, the panel value is converted into that unit
* before comparing; if the conversion is invalid we compare the raw value.
* Whether `value` (in `panelUnit`) satisfies `threshold`. Converts into the
* threshold's unit before comparing; falls back to the raw value if invalid.
*/
export function doesValueMatchThreshold(
value: number,
@@ -112,9 +105,8 @@ export interface ActiveThreshold {
}
/**
* Resolves the threshold to apply for `value`. Among matching thresholds the
* one declared earliest (lowest index) wins, mirroring V1 precedence; a match
* count greater than one flags a conflict.
* Resolves the threshold to apply for `value`. Earliest-declared match wins
* (V1 precedence); more than one match flags a conflict.
*/
export function resolveActiveThreshold(
thresholds: PanelThreshold[],

View File

@@ -2,16 +2,10 @@ import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
/**
* Formats a scalar for display in a V2 panel, honoring the configured decimal
* precision. The shared, unit-aware `getYAxisFormattedValue` is the single
* formatting helper across V2 panels (number/table/list/pie); this wrapper is
* the only seam through which panels touch it.
*
* Precision is applied REGARDLESS of whether a unit is set. When no unit is
* configured we format through the `'none'` unit, which still respects
* precision — this is the fix for decimal precision being silently dropped on
* unitless panels (the old `unit ? format() : value.toString()` gate threw the
* precision away whenever the unit was empty).
* Formats a scalar for display in a V2 panel, honoring decimal precision. The
* single seam through which panels touch `getYAxisFormattedValue`. Unitless
* values format through the `'none'` unit, which still respects precision — so
* precision isn't silently dropped when no unit is set.
*/
export function formatPanelValue(
value: number,

View File

@@ -2,13 +2,10 @@ import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schem
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into the list of builder queries it contains —
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
* carry the legend / groupBy / aggregation context downstream code needs.
*
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
* summary type — so callers consume the same type the wire format defines.
* Flattens a panel's queries into its builder queries, unwrapping
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
* TraceOperator) are dropped they lack the legend/groupBy/aggregation context
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
*/
export function getBuilderQueries(
queries: DashboardtypesQueryDTO[] | null | undefined,

View File

@@ -0,0 +1,49 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
ComparisonThresholdShape,
PanelThreshold,
ThresholdComparisonOperator,
ThresholdDisplayFormat,
} from '../types/threshold';
// Perses comparison operators → the symbol operators V2 threshold evaluation uses.
const OPERATOR_MAP: Record<
DashboardtypesComparisonOperatorDTO,
ThresholdComparisonOperator
> = {
[DashboardtypesComparisonOperatorDTO.above]: '>',
[DashboardtypesComparisonOperatorDTO.below]: '<',
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
[DashboardtypesComparisonOperatorDTO.equal]: '=',
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
};
const FORMAT_MAP: Record<
DashboardtypesThresholdFormatDTO,
ThresholdDisplayFormat
> = {
[DashboardtypesThresholdFormatDTO.text]: 'text',
[DashboardtypesThresholdFormatDTO.background]: 'background',
};
/**
* Maps a comparison-shaped spec threshold onto the V2-native `PanelThreshold`.
* The single place the Perses operator/format enums cross into the symbol model,
* shared by every kind that carries comparison thresholds (Number, Table, …).
*/
export function toPanelThreshold(
threshold: ComparisonThresholdShape,
): PanelThreshold {
return {
color: threshold.color,
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
value: threshold.value,
unit: threshold.unit,
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
};
}

View File

@@ -8,10 +8,9 @@ export interface ParsedFormattedValue {
}
/**
* Splits a formatted value string (e.g. "$ 1.2K", "295.43 ms") into its
* numeric core and any prefix/suffix unit so each part can be styled
* independently. Falls back to treating the whole string as the numeric value
* when it doesn't match the expected shape.
* Splits a formatted value (e.g. "$ 1.2K", "295.43 ms") into its numeric core
* and prefix/suffix unit for independent styling. Non-matching input falls back
* to the whole string as the numeric value.
*/
export function parseFormattedValue(value: string): ParsedFormattedValue {
const matches = value.match(

View File

@@ -31,12 +31,10 @@ export function resolveSeriesLabelV5(
}
/**
* Applies the V1 legend matrix: `single-vs-many builder queries ×
* with/without groupBy × single-vs-many aggregations`. Returns `baseLabel`
* unchanged for panels without builder queries (PromQL, ClickHouseSQL) and
* for builder series whose aggregation carries no alias/expression — metric
* aggregations don't have those fields, so they naturally short-circuit to
* the base label here.
* Applies the V1 legend matrix: single-vs-many builder queries × with/without
* groupBy × single-vs-many aggregations. Returns `baseLabel` unchanged for
* non-builder panels and for series whose aggregation has no alias/expression
* (e.g. metric aggregations, which lack those fields).
*/
function resolveLabel(
identity: SeriesIdentity,
@@ -56,9 +54,8 @@ function resolveLabel(
const aggregations = matching.aggregations ?? [];
const aggregation = aggregations[aggIndex];
// `alias` / `expression` exist on Log/Trace aggregations only
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
// `in` guards narrow the union without a cast.
// `alias`/`expression` exist on Log/Trace aggregations only (not
// `MetricAggregation`); the `in` guards narrow the union without a cast.
const aggregationAlias =
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
const aggregationExpression =
@@ -93,7 +90,7 @@ interface FormatContext {
singleAggregation: boolean;
}
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
/** Panel has one builder query — ports V1's `getLegendForSingleAggregation`. */
function formatForSinglePanelQuery({
aggregationAlias,
aggregationExpression,
@@ -114,10 +111,11 @@ function formatForSinglePanelQuery({
return aggregationAlias || aggregationExpression;
}
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
// Differs from the single-query path in two cells: the no-groupBy / single-agg
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
// multi-agg cell prepends the base label.
/**
* Multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
* Differs from the single-query path in the no-groupBy cells: single-agg falls
* through to `baseLabel` (not `legend`), and multi-agg prepends the base label.
*/
function formatForMultiplePanelQueries({
aggregationAlias,
aggregationExpression,

View File

@@ -1,25 +1,18 @@
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { SelectionPreferencesSource } from 'lib/uPlotV2/config/types';
/**
* Drag-to-zoom "selection preference" wiring, grouped on its own so the base
* config builder stays focused on assembling the chart. Both helpers are driven
* purely by the render context (`PanelMode`).
*/
// Drag-to-zoom "selection preference" wiring, driven by the render context.
/**
* Whether a chart's drag-selection preference should be persisted. Only the
* read-only dashboard view persists it; editor/preview contexts keep it
* ephemeral so an in-progress edit doesn't mutate saved state.
* dashboard view persists it; editor/preview keep it ephemeral so an in-progress
* edit doesn't mutate saved state.
*/
export function shouldSaveSelectionPreference(panelMode: PanelMode): boolean {
return panelMode === PanelMode.DASHBOARD_VIEW;
}
/**
* Where the chart reads/writes its selection preference: localStorage for the
* persisted view contexts, in-memory otherwise.
*/
/** Where the preference is stored: localStorage for view contexts, in-memory otherwise. */
export function resolveSelectionPreferencesSource(
panelMode: PanelMode,
): SelectionPreferencesSource {

View File

@@ -1,26 +1,30 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
DashboardtypesPanelPluginKindDTO as PanelKind,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { Warning } from 'types/api';
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelBody from './PanelBody/PanelBody';
import UnsupportedPanelBody from './PanelBody/UnsupportedPanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
import styles from './Panel.module.scss';
/** Panel action context — present together only in editable sectioned mode. */
/**
* Layout context for the panel actions menu — pure data, present only in
* editable mode. No callbacks: the menu resolves its own mutations from
* store-backed hooks (useDeletePanel / useMovePanelToSection), and edit is
* URL-driven (useOpenPanelEditor).
*/
export interface PanelActionsConfig {
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
interface PanelProps {
@@ -50,15 +54,32 @@ function Panel({
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel.spec.queries?.length ?? 0;
// A per-panel relative time preference (anything other than global_time) is
// surfaced as a pill in the header. `visualization` is common to every
// plugin-spec variant — localized cast reads it without narrowing on kind.
const timePreference = (
panel.spec.plugin?.spec as
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
| undefined
)?.visualization?.timePreference;
const timeLabel = panelTimePreferenceLabel(timePreference);
const panelDefinition = getPanelDefinition(fullKind);
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDefinition && isVisible !== false,
});
// Header search: only kinds that declare it (e.g. tables) render the box; the
// term is owned here and threaded to both the header (input) and the renderer
// (filter), the two being siblings under this orchestrator.
const searchable = !!panelDefinition?.actions.search;
const [searchTerm, setSearchTerm] = useState('');
const { data, isLoading, isFetching, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDefinition && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -81,13 +102,15 @@ function Panel({
<PanelHeader
title={headerTitle}
panelId={panelId}
panelKind={fullKind}
isFetching={isFetching}
error={error}
// The V5 response `warning` is the same object the legacy chain
// surfaced as `Warning` — passed through untouched; the cast is the
// generated-DTO → hand-written-type boundary.
warning={data.response?.data?.warning as Warning | undefined}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
{panelDefinition ? (
<PanelBody
@@ -100,6 +123,8 @@ function Panel({
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
/>
) : (
// TODO: remove this after all panel kinds are supported

View File

@@ -1,98 +1,70 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { EllipsisVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { PanelActionsConfig } from '../Panel';
import { usePanelActionItems } from './usePanelActionItems';
import styles from './PanelActionsMenu.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
interface PanelActionsMenuProps {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
panelKind: PanelKind;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* Purely presentational: the trigger button + dropdown, plus the delete
* confirmation dialog. Which items appear — and the delete-confirm state — is
* owned by `usePanelActionItems` (kind ∧ role ∧ context gating per action).
*/
function PanelActionsMenu({
panelId,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: PanelActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
panelKind,
panelActions,
}: PanelActionsMenuProps): JSX.Element | null {
const { items, deleteConfirm } = usePanelActionItems({
panelId,
panelKind,
panelActions,
});
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
if (items.length === 0) {
return null;
}
return (
<DropdownMenuSimple menu={{ items }}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</Button>
</DropdownMenuSimple>
<>
<DropdownMenuSimple menu={{ items }} align="end">
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</Button>
</DropdownMenuSimple>
<ConfirmDeleteDialog
open={deleteConfirm.open}
title="Delete panel?"
description="This panel will be removed from the dashboard. This action cannot be undone."
isLoading={deleteConfirm.isPending}
onConfirm={deleteConfirm.confirm}
onClose={deleteConfirm.cancel}
/>
</>
);
}

View File

@@ -0,0 +1,231 @@
import { act, renderHook } from '@testing-library/react';
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
() => ({
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
}),
);
const mockMovePanel = jest.fn();
jest.mock('../../hooks/useMovePanelToSection', () => ({
useMovePanelToSection: (): jest.Mock => mockMovePanel,
}));
const mockDeletePanel = jest.fn();
jest.mock('../../hooks/useDeletePanel', () => ({
useDeletePanel: (): jest.Mock => mockDeletePanel,
}));
const mockClonePanel = jest.fn();
jest.mock('../../hooks/useClonePanel', () => ({
useClonePanel: (): jest.Mock => mockClonePanel,
}));
// Role is the only thing read off the app context; useComponentPermission runs
// for real so the tests exercise the actual role → permission mapping.
let mockRole: ROLES = 'ADMIN';
jest.mock('providers/App/App', () => ({
useAppContext: (): { user: { role: ROLES } } => ({
user: { role: mockRole },
}),
}));
function section(
layoutIndex: number,
title: string | undefined,
): DashboardSection {
return {
id: `section-${layoutIndex}`,
layoutIndex,
title,
items: [],
repeatVariable: undefined,
};
}
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
const baseArgs = {
panelId: 'panel-1',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
};
function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
return result.items.map((item) =>
'key' in item && item.key !== undefined ? item.key : item.type,
);
}
describe('usePanelActionItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRole = 'ADMIN';
useDashboardStore.setState({ isEditable: true });
});
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
// download stays hidden: no current kind declares the capability
// (V1 parity — CSV export was table-only).
});
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
mockRole = 'AUTHOR';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
});
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
mockRole = 'VIEWER';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
]);
});
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
const { result } = renderHook(() =>
// A kind with no registered definition — exercises the "unsupported kind"
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
// so it drops too; only the kind-agnostic layout actions remain.
usePanelActionItems({
...baseArgs,
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
}),
);
expect(itemKeys(result.current)).toStrictEqual([
'move',
'divider',
'delete-panel',
]);
});
it('read-only dashboard keeps only View (V1 parity)', () => {
useDashboardStore.setState({ isEditable: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
});
it('move is disabled when there is no other titled section to move to', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: {
currentLayoutIndex: 0,
sections: [section(0, 'Overview'), section(1, undefined)],
},
}),
);
const move = result.current.items.find((i) => 'key' in i && i.key === 'move');
expect(move).toMatchObject({ disabled: true });
});
it('edit opens the panel editor for this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const edit = result.current.items.find(
(i) => 'key' in i && i.key === 'edit-panel',
);
(edit as { onClick: () => void }).onClick();
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
});
it('move targets call the mutation with from/to layout indexes', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const move = result.current.items.find(
(i) => 'key' in i && i.key === 'move',
) as {
children: { key: string; onClick: () => void }[];
};
expect(move.children).toHaveLength(1);
move.children[0].onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 0,
toLayoutIndex: 1,
});
});
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const del = result.current.items.find(
(i) => 'key' in i && i.key === 'delete-panel',
);
// Clicking the menu item only opens the dialog — no mutation yet.
expect(result.current.deleteConfirm.open).toBe(false);
act(() => {
(del as { onClick: () => void }).onClick();
});
expect(result.current.deleteConfirm.open).toBe(true);
expect(mockDeletePanel).not.toHaveBeenCalled();
// Confirming runs the delete and closes the dialog.
await act(async () => {
await result.current.deleteConfirm.confirm();
});
expect(mockDeletePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 0,
});
expect(result.current.deleteConfirm.open).toBe(false);
});
it('clone calls the clone mutation with the panel and its layout index', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const clone = result.current.items.find(
(i) => 'key' in i && i.key === 'clone-panel',
);
(clone as { onClick: () => void }).onClick();
expect(mockClonePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
layoutIndex: 0,
});
});
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
['view-panel', 'create-alert'].forEach((key) => {
const item = result.current.items.find((i) => 'key' in i && i.key === key);
(item as { onClick: () => void }).onClick();
});
expect(alertSpy).toHaveBeenCalledTimes(2);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
alertSpy.mockRestore();
});
});

View File

@@ -0,0 +1,44 @@
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { ComponentTypes } from 'utils/permission';
/**
* Every action the panel menu can offer: per-kind gated capabilities (minus
* `search`, a header control) plus the chrome actions every kind gets. The
* `Record<PanelActionId, …>` below forces a meta entry per id, so adding an
* action without declaring its gates is a compile error.
*/
export type PanelActionId =
| Exclude<keyof PanelActionCapabilities, 'search'>
| 'move'
| 'delete';
export interface PanelActionMeta {
/**
* Role gate: componentPermission key checked against the current user.
* Absent = available to every role (V1 parity: view, download and
* create-alerts were never role-gated).
*/
permission?: ComponentTypes;
/**
* Kind gate: the PanelActionCapabilities flag this action requires.
* Chrome actions (move/clone/delete) are layout concerns available for
* every panel kind — including kinds V2 can't render — so they declare none.
*/
capability?: keyof PanelActionCapabilities;
}
/**
* Single source of truth for how each panel action is gated, mirroring V1's
* WidgetHeader rules. The third gate — context (editable, target sections) — is
* runtime state resolved in `usePanelActionItems`, not declarable here.
*/
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
view: { capability: 'view' },
edit: { permission: 'edit_widget', capability: 'edit' },
clone: { permission: 'edit_widget' },
download: { capability: 'download' },
createAlert: { capability: 'createAlert' },
// Moving a panel between sections mutates the dashboard layout.
move: { permission: 'edit_dashboard' },
delete: { permission: 'delete_widget' },
};

View File

@@ -0,0 +1,218 @@
import { useCallback, useMemo } from 'react';
import {
Bell,
CloudDownload,
Copy,
FolderInput,
Fullscreen,
PenLine,
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import useComponentPermission from 'hooks/useComponentPermission';
import {
type ConfirmableAction,
useConfirmableAction,
} from 'hooks/useConfirmableAction';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { useAppContext } from 'providers/App/App';
import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
const EMPTY_SECTIONS: DashboardSection[] = [];
/** Placeholder for V1-parity actions whose V2 implementations land later. */
function notImplementedYet(feature: string): void {
// eslint-disable-next-line no-alert -- temporary placeholder, see above
alert(`${feature} option clicked`);
}
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
panelKind: PanelKind;
/** Layout context for move/delete — absent outside editable mode. */
panelActions?: PanelActionsConfig;
}
export interface PanelActionItems {
items: MenuItem[];
/** Two-step confirm flow for the destructive Delete action. */
deleteConfirm: ConfirmableAction;
}
/**
* Resolves the panel actions menu items (V1 WidgetHeader set plus V2's "Move to
* section"). Every action passes three gates before it appears:
*
* kind — what the panel kind declares it supports (PanelDefinition.actions);
* unknown kinds support no kind-gated actions.
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
* actions without a permission key are open to every role, V1 parity).
* context — runtime state: dashboard editable (store), layout config present.
* View and Download remain available on read-only dashboards, as in V1.
*/
export function usePanelActionItems({
panelId,
panelKind,
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const { user } = useAppContext();
const [canEditWidget, canMove, canDelete] = useComponentPermission(
[
// edit_widget gates both Edit and Clone, exactly as in V1.
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
],
user.role,
);
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
const movePanel = useMovePanelToSection({ sections });
const deletePanel = useDeletePanel({ sections });
const clonePanel = useClonePanel({ sections });
const kindActions = getPanelDefinition(panelKind)?.actions;
// Delete runs on confirm, not on click — the menu item opens a prompt.
const deleteConfirm = useConfirmableAction(
useCallback(async (): Promise<void> => {
if (!panelActions) {
return;
}
await deletePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
});
}, [deletePanel, panelActions, panelId]),
);
// Stable opener so the items memo doesn't rebuild on dialog state changes.
const { request: requestDelete } = deleteConfirm;
const items = useMemo<MenuItem[]>(() => {
const panelGroup: MenuItem[] = [];
if (kindActions?.view) {
panelGroup.push({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && kindActions?.edit) {
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
icon: <PenLine size={14} />,
onClick: (): void => openPanelEditor(panelId),
});
}
// Clone needs the section context (source spec + dimensions) to place the
// copy, so — unlike Edit — it requires panelActions.
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
icon: <Copy size={14} />,
onClick: (): void =>
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
}),
});
}
const dataGroup: MenuItem[] = [];
if (kindActions?.download) {
dataGroup.push({
key: 'download-panel',
label: 'Download as CSV',
icon: <CloudDownload size={14} />,
onClick: (): void => notImplementedYet('Download'),
});
}
if (isEditable && kindActions?.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
icon: <Bell size={14} />,
onClick: (): void => notImplementedYet('Create Alerts'),
});
}
const moveGroup: MenuItem[] = [];
if (canMove && panelActions) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
);
moveGroup.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: panelActions.currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
});
}
const deleteGroup: MenuItem[] =
canDelete && panelActions
? [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void => requestDelete(),
},
]
: [];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index === 0 ? group : [{ type: 'divider' as const }, ...group],
);
}, [
isEditable,
canEditWidget,
canMove,
canDelete,
kindActions,
panelActions,
sections,
panelId,
openPanelEditor,
movePanel,
clonePanel,
requestDelete,
]);
return { items, deleteConfirm };
}

View File

@@ -6,13 +6,17 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { panelStatusFromError } from '../PanelStatus/utils';
import styles from './PanelBody.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind — always present (`Panel` renders the
* unsupported fallback itself when no renderer is registered). */
* unsupported fallback itself when none is registered). */
panelDefinition: RenderablePanelDefinition;
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -21,18 +25,24 @@ interface PanelBodyProps {
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
dashboardPreference: DashboardPreference;
/** Dashboard-wide preferences (cursor sync, …); absent in the editor preview. */
dashboardPreference?: DashboardPreference;
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
panelMode?: PanelMode;
/** Header search term — only consumed by kinds that declare header search. */
searchTerm?: string;
/** Server-side paging handles — only consumed by raw/list renderers. */
pagination?: PanelPagination;
}
/**
* Renders the content of a panel whose kind has a registered renderer, as an
* explicit state machine so each state is handled deliberately (no implicit
* fall-through):
* Renders a panel whose kind has a registered renderer, as an explicit state
* machine:
*
* error + no data → error message with retry
* first load (no data) → loading indicator
* otherwise → the kind's renderer (which owns its own "No Data" state, and
* keeps stale data mounted during background refetches)
* otherwise → the kind's renderer (owns its own "No Data" state and keeps
* stale data mounted during background refetches)
*/
function PanelBody({
panelDefinition,
@@ -44,19 +54,24 @@ function PanelBody({
refetch,
onDragSelect,
dashboardPreference,
panelMode = PanelMode.DASHBOARD_VIEW,
searchTerm,
pagination,
}: PanelBodyProps): JSX.Element {
// Surface a hard failure only when there's no (stale) data to show; otherwise
// keep the last-good chart and let the header indicate the refresh.
// react-query keeps the previous response during background refetches, so
// `data.response` presence is the "have something to show" signal.
// `data.response` presence is the "have something to show" signal — surface a
// hard failure only when there's nothing to keep on screen.
const hasData = !!data.response;
if (error && !hasData) {
// Parse the API error like the header popover does, so the body shows the
// backend message (not the raw axios "status code 4xx").
const errorDetail = panelStatusFromError(error);
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{error.message || 'Failed to load panel data'}
{errorDetail?.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
@@ -65,9 +80,9 @@ function PanelBody({
);
}
// First load only — background refetches keep the response populated so the
// chart stays mounted instead of blinking.
if (isLoading && !hasData) {
// First load only — refetches keep the response populated so the chart stays
// mounted instead of blinking.
if (isLoading) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
@@ -84,9 +99,11 @@ function PanelBody({
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_VIEW}
panelMode={panelMode}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
searchTerm={searchTerm}
pagination={pagination}
/>
</div>
);

View File

@@ -7,10 +7,8 @@ interface UnsupportedPanelBodyProps {
}
/**
* Body shown when no renderer is registered for the panel's kind. Split out from
* `PanelBody` so that `PanelBody` only ever runs with a resolved renderer — the
* "kind not yet supported" path is handled here, before any data fetching is
* surfaced.
* Body shown when no renderer is registered for the panel's kind. Split out so
* `PanelBody` only ever runs with a resolved renderer.
*/
function UnsupportedPanelBody({
kind,

View File

@@ -30,7 +30,7 @@
.actions {
display: flex;
align-items: center;
gap: 4px;
gap: 8px;
cursor: default;
}
@@ -39,3 +39,17 @@
color: var(--l2-foreground);
flex-shrink: 0;
}
// Per-panel time-preference pill (e.g. `6h`), shown when the panel overrides
// the dashboard time window.
.timePill {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
line-height: 16px;
color: var(--l3-foreground);
background: var(--l3-background);
border: 1px solid var(--l3-border);
cursor: default;
}

View File

@@ -1,39 +1,58 @@
import { useMemo, type ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { Loader } from '@signozhq/icons';
import cx from 'classnames';
import type { Warning } from 'types/api';
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import type { PanelActionsConfig } from '../Panel';
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
panelStatusFromError,
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
title: ReactNode;
panelId: string;
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
/** Full plugin kind — drives kind-gated menu actions; */
panelKind: PanelKind;
/** Background refresh in flight — shows a spinner without blinking the chart. */
isFetching: boolean;
/** Latest query error, if any — surfaced as a header error indicator. */
/** Latest query error — surfaced as a header error indicator. */
error?: Error | null;
/** Non-fatal query warning lifted from the response payload. */
warning?: Warning;
/** Move/delete actions — present only in editable sectioned mode. */
warning?: WarningDTO;
/** Per-panel time-preference label; null when it follows the dashboard window. */
timeLabel?: PanelTimePreferenceLabel | null;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
/** Kind declares header search — renders the box. */
searchable?: boolean;
/** Current search term; shell owns it, the renderer applies the filter. */
searchTerm?: string;
/** Pushes a new search term up to the shell. */
onSearchChange?: (value: string) => void;
}
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
title,
panelId,
panelKind,
isFetching,
error,
warning,
timeLabel,
panelActions,
searchable,
searchTerm = '',
onSearchChange,
}: PanelHeaderProps): JSX.Element {
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
@@ -57,19 +76,26 @@ function PanelHeader({
{/* `panel-no-drag` opts this region out of the grid drag handle so the
actions menu is clickable instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{searchable && onSearchChange && (
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
)}
{timeLabel && (
<TooltipSimple title={timeLabel.full} arrow>
<span className={styles.timePill} data-testid="panel-time-preference">
{timeLabel.short}
</span>
</TooltipSimple>
)}
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
{warningDetail && (
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{panelActions && (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
)}
{/* Renders nothing when no action survives its gates (kind/role/context). */}
<PanelActionsMenu
panelId={panelId}
panelKind={panelKind}
panelActions={panelActions}
/>
</div>
</div>
);

View File

@@ -0,0 +1,9 @@
// Expanded state: a compact input that fits the header row.
.input {
width: 180px;
}
.clear {
--button-height: 18px;
--button-padding: 0;
}

View File

@@ -0,0 +1,91 @@
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { Input } from '@signozhq/ui/input';
import { Search, X } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './PanelHeaderSearch.module.scss';
import { Button } from '@signozhq/ui/button';
interface PanelHeaderSearchProps {
/** Current filter term, owned by the panel shell. */
value: string;
/** Pushes the new term up; the renderer applies the filter. */
onChange: (value: string) => void;
}
/**
* Collapsible header search (V1 parity): an icon that expands into an input and
* collapses once empty and blurred. Owns only its chrome, never the term.
*/
function PanelHeaderSearch({
value,
onChange,
}: PanelHeaderSearchProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const collapseIfEmpty = (): void => {
if (!value) {
setExpanded(false);
}
};
const clear = (): void => {
onChange('');
setExpanded(false);
};
if (!expanded) {
return (
<TooltipSimple title="Search" arrow>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => setExpanded(true)}
data-testid="panel-header-search-trigger"
aria-label="Search"
>
<Search size={14} />
</Button>
</TooltipSimple>
);
}
return (
<Input
autoFocus
size={14}
value={value}
placeholder="Search…"
containerClassName={styles.input}
testId="panel-header-search-input"
prefix={<Search size={14} />}
suffix={
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
className={styles.clear}
onClick={clear}
data-testid="panel-header-search-clear"
aria-label="Clear search"
>
<X size={14} />
</Button>
}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
onChange(e.target.value)
}
onBlur={collapseIfEmpty}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Escape') {
clear();
}
}}
/>
);
}
export default PanelHeaderSearch;

View File

@@ -1,41 +1,71 @@
import { BookOpenText } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { BookOpenText, CircleX, TriangleAlert } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import type { PanelStatusDetail } from './types';
import type { PanelStatusDetail, PanelStatusVariant } from './types';
import styles from './PanelStatusPopover.module.scss';
interface PanelStatusContentProps {
variant: PanelStatusVariant;
detail: PanelStatusDetail;
}
/**
* Popover body for a panel status (error or warning): a code + summary header
* with an optional docs link, followed by any per-item messages. Pure
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
*/
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
const VARIANT_ICON = {
error: { Icon: CircleX, color: Color.BG_CHERRY_500 },
warning: { Icon: TriangleAlert, color: Color.BG_AMBER_500 },
};
/** Popover card for a panel status (error or warning). Pure presentation. */
function PanelStatusContent({
variant,
detail,
}: PanelStatusContentProps): JSX.Element {
const { code, message, docsUrl, messages } = detail;
const { Icon, color } = VARIANT_ICON[variant];
return (
<section className={styles.content} data-testid="panel-status-content">
<header className={styles.summary}>
<div className={styles.summaryText}>
<h2 className={styles.code}>{code}</h2>
<p className={styles.message}>{message}</p>
<div className={styles.summaryLeft}>
<span className={styles.iconWrapper}>
<Icon size={16} color={color} />
</span>
<div className={styles.summaryText}>
{code && <h2 className={styles.code}>{code}</h2>}
<p className={styles.message}>{message}</p>
</div>
</div>
{docsUrl && (
<Typography.Link
className={styles.docsLink}
href={docsUrl}
target="_blank"
rel="noreferrer"
data-testid="panel-status-docs"
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<BookOpenText size={14} />}
>
<BookOpenText size={14} />
<span>Open Docs</span>
</Typography.Link>
<a
href={docsUrl}
className={styles.docsLink}
target="_blank"
rel="noreferrer"
data-testid="panel-status-docs"
>
Open Docs
</a>
</Button>
)}
</header>
{messages.length > 0 && (
<div className={styles.messageBadge}>
<span className={styles.badge}>
<span className={styles.badgeDot} />
<span className={styles.badgeText}>MESSAGES</span>
<span className={styles.badgeCount}>{messages.length}</span>
</span>
<span className={styles.badgeLine} />
</div>
)}
{messages.length > 0 && (
<ul className={styles.messageList}>
{messages.map((m) => (

View File

@@ -1,3 +1,5 @@
@use '../../../../../../styles/scrollbar' as *;
.trigger {
display: inline-flex;
align-items: center;
@@ -5,61 +7,150 @@
flex-shrink: 0;
}
// Strip the tooltip's own padding/width cap so the card content (which owns its
// 16px section padding) frames cleanly — a padding-less surface, like the
// shared WarningPopover, restyled with V2 tokens.
.tooltipContent {
max-width: 520px !important;
padding: 0 !important;
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 600px;
padding: 12px;
min-width: 320px;
}
// === Summary header: icon + code/message, optional docs button ===
.summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 16px;
}
.summaryLeft {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.iconWrapper {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-top: 2px;
}
.summaryText {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.code {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--l2-foreground);
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.message {
margin: 4px 0 0;
font-size: 12px;
color: var(--l1-foreground);
margin: 0;
color: var(--l2-foreground);
font-size: 13px;
line-height: 18px;
word-break: break-word;
}
.docsLink {
display: flex !important;
align-items: center;
justify-content: center;
gap: 4px;
flex-shrink: 0;
font-size: 12px;
white-space: nowrap;
text-decoration: none;
color: var(--l2-foreground);
}
// === MESSAGES count pill + dotted rule ===
.messageBadge {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px 12px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border: 1px solid var(--l1-border);
border-radius: 20px;
}
.badgeDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-sakura-500);
}
.badgeText {
color: var(--l1-foreground);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.5px;
}
.badgeCount {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
}
.badgeLine {
flex: 1;
height: 8px;
background-image: radial-gradient(
circle,
var(--l3-background) 1px,
transparent 2px
);
background-size: 8px 11px;
}
// === Per-item messages ===
.messageList {
margin: 0;
padding-left: 16px;
max-height: 240px;
padding: 0 16px 16px;
list-style: none;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
@include custom-scrollbar;
}
.messageItem {
position: relative;
padding-left: 14px;
color: var(--l2-foreground);
font-family: 'Geist Mono', monospace;
font-size: 12px;
color: var(--l1-foreground);
line-height: 18px;
word-break: break-word;
}
.messageItem::before {
content: '';
position: absolute;
left: 2px;
top: 7px;
width: 2px;
height: 4px;
border-radius: 50px;
background: var(--l1-border);
}

View File

@@ -20,9 +20,8 @@ interface PanelStatusPopoverProps {
}
/**
* Header status indicator: a variant-coloured icon (error → CircleX,
* warning → TriangleAlert) that opens a tooltip with the status detail. One
* component drives both variants so error and warning surfacing stay in lockstep.
* Header status indicator: an icon that opens a tooltip with the status detail.
* One component drives both variants so error and warning stay in lockstep.
*/
function PanelStatusPopover({
variant,
@@ -32,9 +31,13 @@ function PanelStatusPopover({
const Icon = variant === 'error' ? CircleX : TriangleAlert;
return (
<TooltipSimple title={<PanelStatusContent detail={detail} />} arrow>
{/* Wrapping span gives a ref-able, hoverable trigger (icon
components don't forward refs) and a stable testid anchor. */}
<TooltipSimple
title={<PanelStatusContent variant={variant} detail={detail} />}
side="top"
align="end"
arrow
tooltipContentProps={{ className: styles.tooltipContent }}
>
<span
className={styles.trigger}
aria-label={ariaLabel}

View File

@@ -1,7 +1,7 @@
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { StatusCodes } from 'http-status-codes';
import type { Warning } from 'types/api';
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
@@ -61,16 +61,14 @@ describe('panelStatusFromWarning', () => {
expect(panelStatusFromWarning(undefined)).toBeNull();
});
it('maps a warning to the normalized status shape', () => {
const warning: Warning = {
code: 'partial_data',
it('maps a warning to the normalized status shape (no code — V5 warnings carry none)', () => {
const warning: WarningDTO = {
message: 'Some series were dropped',
url: 'https://docs/warn',
warnings: [{ message: 'series A truncated' }],
};
expect(panelStatusFromWarning(warning)).toStrictEqual({
code: 'partial_data',
message: 'Some series were dropped',
docsUrl: 'https://docs/warn',
messages: ['series A truncated'],

View File

@@ -3,13 +3,12 @@ export type PanelStatusVariant = 'error' | 'warning';
/**
* Normalized status shape that both an API error and a query warning adapt into,
* so a single popover can render either. Mirrors the fields the backend supplies
* on its `ErrorV2` / `Warning` envelopes (code + summary + optional docs link +
* per-item messages).
* so a single popover can render either. Mirrors the backend `ErrorV2`/`Warning`
* envelope fields (code + summary + optional docs link + per-item messages).
*/
export interface PanelStatusDetail {
/** Short status code (e.g. an error/warning code) shown as the heading. */
code: string;
/** Status code shown as the heading. Only present in error cases. */
code?: string;
/** Human-readable summary line. */
message: string;
/** Optional docs link; renders an "Open Docs" action when present. */

View File

@@ -1,7 +1,7 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Warning } from 'types/api';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelStatusDetail } from './types';
@@ -9,12 +9,9 @@ import type { PanelStatusDetail } from './types';
* Adapts a query failure into the normalized status shape.
*
* The generated `queryRangeV5` client's reject interceptor passes the raw
* `AxiosError` through untouched — it is NOT pre-converted to `APIError` so
* the error arriving here is an axios error. `convertToApiError` is the
* app-standard normalizer for generated-API axios errors: it pulls the backend
* `code / message / url / errors` envelope off the response and supplies
* sensible fallbacks for anything missing, so there's always a structured
* detail to surface.
* `AxiosError` through untouched (NOT pre-converted to `APIError`), so
* `convertToApiError` is needed here to pull the backend `code/message/url/
* errors` envelope off the response (with fallbacks) into a structured detail.
*/
export function panelStatusFromError(
error: Error | null | undefined,
@@ -41,16 +38,17 @@ export function panelStatusFromError(
/** Adapts a query warning into the normalized status shape. */
export function panelStatusFromWarning(
warning: Warning | null | undefined,
warning: WarningDTO | undefined,
): PanelStatusDetail | null {
if (!warning) {
return null;
}
return {
code: warning.code,
message: warning.message,
message: warning.message || 'Warning',
docsUrl: warning.url || undefined,
messages: (warning.warnings ?? []).map((w) => w.message),
messages: (warning.warnings ?? [])
.map((w) => w.message)
.filter((message): message is string => Boolean(message)),
};
}

View File

@@ -1,18 +1,30 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader/PanelHeader';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// PanelHeader's status indicators render a radix tooltip, which needs a
// TooltipProvider ancestor (supplied globally by AppLayout at runtime).
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
render(<TooltipProvider>{ui}</TooltipProvider>);
// The actions menu has its own gating logic (kind/role/context) and its own
// tests; stub it so this test exercises only the header's status indicators.
jest.mock(
'../PanelActionsMenu/PanelActionsMenu',
() =>
function MockPanelActionsMenu(): null {
return null;
},
);
const baseProps = {
title: 'My panel',
kind: 'TimeSeries',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panelId: 'panel-1',
isFetching: false,
};
@@ -41,3 +53,69 @@ describe('PanelHeader status indicators', () => {
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
});
});
describe('PanelHeader search', () => {
it('renders no search affordance when the panel is not searchable', () => {
renderWithProvider(<PanelHeader {...baseProps} />);
expect(
screen.queryByTestId('panel-header-search-trigger'),
).not.toBeInTheDocument();
});
it('expands the collapsed trigger into an input and reports changes', async () => {
const user = userEvent.setup();
const onSearchChange = jest.fn();
renderWithProvider(
<PanelHeader
{...baseProps}
searchable
searchTerm=""
onSearchChange={onSearchChange}
/>,
);
await user.click(screen.getByTestId('panel-header-search-trigger'));
// The input is controlled to the (fixed) `searchTerm` here, so each keystroke
// reports a single character — assert one to confirm changes are propagated.
const input = screen.getByTestId('panel-header-search-input');
await user.type(input, 'f');
expect(onSearchChange).toHaveBeenCalledWith('f');
});
it('clears the term and collapses when the clear button is pressed', async () => {
const user = userEvent.setup();
const onSearchChange = jest.fn();
renderWithProvider(
<PanelHeader
{...baseProps}
searchable
searchTerm="frontend"
onSearchChange={onSearchChange}
/>,
);
await user.click(screen.getByTestId('panel-header-search-trigger'));
await user.click(screen.getByTestId('panel-header-search-clear'));
expect(onSearchChange).toHaveBeenCalledWith('');
expect(screen.getByTestId('panel-header-search-trigger')).toBeInTheDocument();
});
});
describe('PanelHeader time-preference pill', () => {
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
timeLabel={{ short: '6h', full: 'Last 6 hr' }}
/>,
);
expect(screen.getByTestId('panel-time-preference')).toHaveTextContent('6h');
});
it('renders no pill when the panel follows the dashboard time', () => {
renderWithProvider(<PanelHeader {...baseProps} timeLabel={null} />);
expect(screen.queryByTestId('panel-time-preference')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,121 @@
import { renderHook } from '@testing-library/react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import type { DashboardSection } from '../../../../utils';
import { useClonePanel } from '../useClonePanel';
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
}));
const mockToastPromise = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
toast: { promise: (...args: unknown[]): unknown => mockToastPromise(...args) },
}));
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
const sourcePanel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardSection['items'][number]['panel'];
function sections(): DashboardSection[] {
return [
{
id: 'section-0',
layoutIndex: 0,
title: 'Overview',
repeatVariable: undefined,
items: [
{ id: 'p1', x: 0, y: 0, width: 8, height: 5, panel: sourcePanel },
{ id: 'p2', x: 8, y: 0, width: 4, height: 5, panel: sourcePanel },
],
},
];
}
describe('useClonePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
});
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
{
op: 'add',
path: '/spec/panels/cloned-id',
value: sourcePanel,
},
{
op: 'add',
path: '/spec/layouts/0/spec/items/-',
value: {
// Same dimensions as the source panel (p1: 8x5).
x: 0,
// Bottom of the section: max(y + height) over existing items = 5.
y: 5,
width: 8,
height: 5,
content: { $ref: '#/spec/panels/cloned-id' },
},
},
]);
});
it('deep-copies the spec — the cloned value is not the same object reference', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatch.mock.calls[0][1];
expect(ops[0].value).toStrictEqual(sourcePanel);
expect(ops[0].value).not.toBe(sourcePanel);
});
it('no-ops when the panel is not found in the section', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'missing', layoutIndex: 0 });
expect(mockPatch).not.toHaveBeenCalled();
expect(mockToastPromise).not.toHaveBeenCalled();
});
it('reports in-flight → done/failed state via toast.promise', async () => {
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockToastPromise).toHaveBeenCalledWith(
expect.any(Promise),
expect.objectContaining({
loading: 'Cloning panel…',
success: 'Panel cloned',
error: 'Failed to clone panel',
}),
);
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatch.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await expect(
result.current({ panelId: 'p1', layoutIndex: 0 }),
).resolves.toBeUndefined();
expect(mockToastPromise).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { addPanelToSectionOps, panelRef } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface ClonePanelArgs {
panelId: string;
layoutIndex: number;
}
/**
* Duplicates a panel: deep-copies the source spec under a fresh id and drops a
* same-size grid item at the bottom of the section, as one atomic patch. Mirrors
* V1's clone (verbatim spec copy, no rename).
*/
export function useClonePanel({
sections,
}: Params): (args: ClonePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
return useCallback(
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
const section = sections.find((s) => s.layoutIndex === layoutIndex);
const source = section?.items.find((i) => i.id === panelId);
if (!dashboardId || !section || !source?.panel) {
return;
}
const newPanelId = uuid();
const nextY = section.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const clone = patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId: newPanelId,
panel: cloneDeep(source.panel),
layoutIndex,
item: {
x: 0,
y: nextY,
width: source.width,
height: source.height,
content: { $ref: panelRef(newPanelId) },
},
}),
);
// toast.promise reports the failure, so no separate error modal here.
toast.promise(clone, {
loading: 'Cloning panel…',
success: 'Panel cloned',
error: 'Failed to clone panel',
position: 'top-center',
});
// Refetch only on success; swallow the rejection (toast owns the error
// UX) to avoid an unhandled rejection.
try {
await clone;
refetch();
} catch {
// no-op — toast.promise owns the error UX.
}
},
[sections, dashboardId, refetch],
);
}

View File

@@ -12,23 +12,15 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
export interface PanelInteractions {
/**
* Drag-select a time range on a chart → write the window to the URL + global
* time so every panel re-fetches against the same range.
*/
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
onDragSelect: (start: number, end: number) => void;
/**
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
* off the dashboard id from the route.
*/
/** Dashboard-wide rendering preferences (cursor sync, tooltip filter). */
dashboardPreference: DashboardPreference;
}
/**
* Encapsulates the cross-panel interactions shared by every dashboard-view
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
* preferences. Keeping this out of the `Panel` component keeps the component a
* thin render orchestrator and lets the wiring be unit-tested in isolation.
* Cross-panel interactions shared by every dashboard-view panel: drag-to-zoom
* time selection and the cursor-sync / tooltip-filter preferences.
*/
export function usePanelInteractions(): PanelInteractions {
const dispatch = useDispatch();

View File

@@ -8,8 +8,6 @@ import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/pan
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
@@ -26,10 +24,8 @@ interface SectionProps {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
@@ -38,8 +34,6 @@ function Section({
section,
onAddPanel,
sections,
onMovePanel,
onDeletePanel,
dragHandle,
}: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
@@ -92,8 +86,6 @@ function Section({
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);

View File

@@ -2,8 +2,6 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
@@ -16,10 +14,8 @@ interface SectionGridProps {
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function SectionGrid({
@@ -27,8 +23,6 @@ function SectionGrid({
layoutIndex,
isVisible,
sections,
onMovePanel,
onDeletePanel,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
@@ -62,6 +56,9 @@ function SectionGrid({
margin={[8, 8]}
>
{items.map((item) => (
// A layout item can reference a panel id that no longer exists in the
// panels map (orphan); render an empty grid cell for it rather than a
// panel with no content.
<div key={item.id}>
{item.panel && (
<Panel
@@ -69,12 +66,10 @@ function SectionGrid({
panelId={item.id}
isVisible={isVisible}
panelActions={
isEditable && onMovePanel && onDeletePanel
isEditable
? {
currentLayoutIndex: layoutIndex,
sections: sections ?? [],
onMovePanel,
onDeletePanel,
}
: undefined
}

View File

@@ -12,8 +12,6 @@ import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.sche
import type { DashboardSection } from '../../utils';
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import Section from './Section/Section';
@@ -38,8 +36,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
} = useSectionDragReorder({ sections, layouts });
const onAddPanel = useAddPanelToSection({ sections });
const onMovePanel = useMovePanelToSection({ sections });
const onDeletePanel = useDeletePanel({ sections });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
@@ -75,8 +71,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<Section
@@ -84,8 +78,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
),
)}

View File

@@ -3,24 +3,18 @@ import { CSS } from '@dnd-kit/utilities';
import type { DashboardSection } from '../../utils';
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
import Section from './Section/Section';
interface SortableSectionProps {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
function SortableSection({
section,
sections,
onAddPanel,
onMovePanel,
onDeletePanel,
}: SortableSectionProps): JSX.Element {
const {
attributes,
@@ -48,8 +42,6 @@ function SortableSection({
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>

View File

@@ -48,8 +48,11 @@ function PanelsAndSectionsLayout({
return <SectionList sections={sections} layouts={layouts} />;
}
// Free-flow (no titled sections): panels still get the layout context so
// the menu's delete action can patch the section's items (previously a
// silent noop in this mode).
return sections.map((section) => (
<Section key={section.id} section={section} />
<Section key={section.id} section={section} sections={sections} />
));
};

View File

@@ -0,0 +1,89 @@
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import { resolvePanelTimeWindow } from '../resolvePanelTimeWindow';
const GLOBAL_START = 1_000_000;
const GLOBAL_END = 5_000_000;
describe('resolvePanelTimeWindow', () => {
it('uses the dashboard window when there is no preference', () => {
expect(
resolvePanelTimeWindow({
timePreference: undefined,
globalStartMs: GLOBAL_START,
globalEndMs: GLOBAL_END,
}),
).toStrictEqual({ startMs: GLOBAL_START, endMs: GLOBAL_END });
});
it('uses the dashboard window for global_time', () => {
expect(
resolvePanelTimeWindow({
timePreference: DashboardtypesTimePreferenceDTO.global_time,
globalStartMs: GLOBAL_START,
globalEndMs: GLOBAL_END,
}),
).toStrictEqual({ startMs: GLOBAL_START, endMs: GLOBAL_END });
});
it('anchors a relative preset to the dashboard end', () => {
expect(
resolvePanelTimeWindow({
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
globalStartMs: GLOBAL_START,
globalEndMs: GLOBAL_END,
}),
).toStrictEqual({ startMs: GLOBAL_END - 5 * 60 * 1000, endMs: GLOBAL_END });
});
it('resolves the larger presets to the V1-equivalent spans', () => {
const cases: [DashboardtypesTimePreferenceDTO, number][] = [
[DashboardtypesTimePreferenceDTO.last_1_hr, 60],
[DashboardtypesTimePreferenceDTO.last_1_day, 24 * 60],
[DashboardtypesTimePreferenceDTO.last_1_week, 7 * 24 * 60],
[DashboardtypesTimePreferenceDTO.last_1_month, 30 * 24 * 60],
];
cases.forEach(([pref, minutes]) => {
expect(
resolvePanelTimeWindow({
timePreference: pref,
globalStartMs: GLOBAL_START,
globalEndMs: GLOBAL_END,
}),
).toStrictEqual({
startMs: GLOBAL_END - minutes * 60 * 1000,
endMs: GLOBAL_END,
});
});
});
it('lets an explicit override win over the preference', () => {
expect(
resolvePanelTimeWindow({
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
globalStartMs: GLOBAL_START,
globalEndMs: GLOBAL_END,
override: { startMs: 42, endMs: 99 },
}),
).toStrictEqual({ startMs: 42, endMs: 99 });
});
it('floors fractional milliseconds', () => {
expect(
resolvePanelTimeWindow({
timePreference: undefined,
globalStartMs: 1.9,
globalEndMs: 9.9,
}),
).toStrictEqual({ startMs: 1, endMs: 9 });
expect(
resolvePanelTimeWindow({
timePreference: DashboardtypesTimePreferenceDTO.last_5_min,
globalStartMs: 0,
globalEndMs: 9.9,
override: { startMs: 4.7, endMs: 8.2 },
}),
).toStrictEqual({ startMs: 4, endMs: 8 });
});
});

View File

@@ -1,6 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { renderHook } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelQuery } from '../usePanelQuery';
@@ -10,6 +10,14 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
// usePanelQuery reads the query client only to cancel in-flight fetches; the
// fetch hook itself is mocked, so a stub client is enough.
jest.mock('react-query', () => ({
useQueryClient: (): { cancelQueries: jest.Mock } => ({
cancelQueries: jest.fn(),
}),
}));
jest.mock('../useGetQueryRangeV5', () => ({
useGetQueryRangeV5: jest.fn(),
}));
@@ -164,7 +172,9 @@ describe('usePanelQuery', () => {
expect(result.current.error?.message).toBe('boom');
});
it('combines isLoading and isFetching into a single isLoading flag', () => {
it('exposes isLoading (first fetch) and isFetching (any fetch) as distinct flags', () => {
// A background refetch (data present elsewhere) is in flight: isFetching is
// true but isLoading stays false so the panel keeps showing its data.
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: false,
@@ -174,6 +184,20 @@ describe('usePanelQuery', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(true);
});
it('reports isLoading on the first fetch (no cached data yet)', () => {
mockUseGetQueryRangeV5.mockReturnValue({
data: undefined,
isLoading: true,
isFetching: true,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
@@ -237,10 +261,175 @@ describe('usePanelQuery', () => {
);
});
it('builds an empty composite and disables the fetch when panel is undefined (no crash)', () => {
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
const [{ requestPayload, enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.compositeQuery.queries).toStrictEqual([]);
expect(enabled).toBe(false);
it('uses the time override (not redux) for the request window and cache key', () => {
const panel = builderPanel();
renderHook(() =>
usePanelQuery({
panel,
panelId: 'p1',
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
}),
);
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
// Window comes from the override, not the redux nanosecond time.
expect(requestPayload.start).toBe(1_700_000_000_000);
expect(requestPayload.end).toBe(1_700_000_600_000);
// Cache key keys off the override so the preview refetches independently
// of the dashboard and never collides with its redux-keyed entry.
expect(queryKey).toStrictEqual(
expect.arrayContaining([
'p1',
'override-1700000000000-1700000600000',
'signoz/TimeSeriesPanel',
panel.spec?.queries,
]),
);
expect(queryKey).not.toContain(DEFAULT_GLOBAL_TIME.minTime);
});
it('floors fractional override ms — V1 time helpers emit floats but start/end are int64', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
}),
);
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_700_000_000_000);
expect(requestPayload.end).toBe(1_700_000_600_000);
// The cache key carries the floored values so it matches the request.
expect(queryKey).toStrictEqual(
expect.arrayContaining(['override-1700000000000-1700000600000']),
);
});
describe('list pagination', () => {
const listPanel = (
querySpec: Record<string, unknown>,
): DashboardtypesPanelDTO =>
panelWith('signoz/ListPanel', { name: 'A', signal: 'logs', ...querySpec });
it('exposes server paging at the default page size when the query has no limit', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.pageSize).toBe(25);
expect(result.current.pagination?.pageSizeOptions).toStrictEqual([
10, 25, 50, 100, 200,
]);
});
it('disables the server pager when the query has an explicit limit (V1 parity)', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({ limit: 100 }), panelId: 'p1' }),
);
expect(result.current.pagination).toBeUndefined();
});
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
act(() => result.current.pagination?.setPageSize(50));
expect(result.current.pagination?.pageSize).toBe(50);
const lastCall = mockUseGetQueryRangeV5.mock.calls.at(-1) as [
{ queryKey: unknown[] },
];
// Page size participates in the cache key so each size is its own entry.
expect(lastCall[0].queryKey).toStrictEqual(expect.arrayContaining([50]));
});
// A raw V5 response carrying `rowCount` rows (+ an optional cursor), shaped
// the way getRawResults reads it.
const rawResponse = (rowCount: number, nextCursor?: string): unknown => ({
data: {
type: 'raw',
data: {
results: [
{
rows: Array.from({ length: rowCount }, () => ({ data: {} })),
...(nextCursor ? { nextCursor } : {}),
},
],
},
},
});
const withResponse = (response: unknown): void => {
mockUseGetQueryRangeV5.mockReturnValue({
data: response,
isLoading: false,
isFetching: false,
error: null,
});
};
it('starts on page 0 with no prev/next and does not throw before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination?.pageIndex).toBe(0);
expect(result.current.pagination?.canPrev).toBe(false);
expect(result.current.pagination?.canNext).toBe(false);
});
it('flags canNext on a full page and clears it on a partial page', () => {
withResponse(rawResponse(25));
const full = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(full.result.current.pagination?.canNext).toBe(true);
withResponse(rawResponse(10));
const partial = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(partial.result.current.pagination?.canNext).toBe(false);
});
it('flags canNext from a nextCursor even on a partial page', () => {
withResponse(rawResponse(3, 'cursor-1'));
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination?.canNext).toBe(true);
});
it('advances pageIndex and enables canPrev after goNext', () => {
withResponse(rawResponse(25));
// Stable panel reference: a fresh one each render would change the
// `queries` identity and trip the offset-reset effect (real props are stable).
const panel = listPanel({});
const { result } = renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
expect(result.current.pagination?.pageIndex).toBe(0);
act(() => result.current.pagination?.goNext());
expect(result.current.pagination?.pageIndex).toBe(1);
expect(result.current.pagination?.canPrev).toBe(true);
});
it('stays defined and zero-paged for a non-raw (scalar) response', () => {
withResponse({ data: { type: 'scalar', data: { results: [] } } });
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.canNext).toBe(false);
expect(result.current.pagination?.pageIndex).toBe(0);
});
it('ignores a non-positive page size so paging never goes invalid', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
act(() => result.current.pagination?.setPageSize(0));
expect(result.current.pagination?.pageSize).toBe(25);
expect(result.current.pagination?.pageIndex).toBe(0);
});
});
});

View File

@@ -0,0 +1,131 @@
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
/** Absolute time window in epoch milliseconds — the V5 request's native unit. */
export interface PanelTimeWindow {
startMs: number;
endMs: number;
}
// Span per relative preference, in ms. `global_time` is absent (follow the dashboard
// window, default branch below). Mirrors V1's `getStartAndEndTime` (last_1_month = 30 days).
const MINUTE_MS = 60 * 1000;
const PRESET_SPAN_MS: Partial<Record<DashboardtypesTimePreferenceDTO, number>> =
{
[DashboardtypesTimePreferenceDTO.last_5_min]: 5 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_15_min]: 15 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_30_min]: 30 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_1_hr]: 60 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_6_hr]: 6 * 60 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_1_day]: 24 * 60 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_3_days]: 3 * 24 * 60 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_1_week]: 7 * 24 * 60 * MINUTE_MS,
[DashboardtypesTimePreferenceDTO.last_1_month]: 30 * 24 * 60 * MINUTE_MS,
};
// Short + full labels per relative preference, for the header time pill. `global_time` is
// absent — a panel that follows the dashboard window shows no pill.
const TIME_PREFERENCE_LABEL: Partial<
Record<DashboardtypesTimePreferenceDTO, { short: string; full: string }>
> = {
[DashboardtypesTimePreferenceDTO.last_5_min]: {
short: '5m',
full: 'Last 5 min',
},
[DashboardtypesTimePreferenceDTO.last_15_min]: {
short: '15m',
full: 'Last 15 min',
},
[DashboardtypesTimePreferenceDTO.last_30_min]: {
short: '30m',
full: 'Last 30 min',
},
[DashboardtypesTimePreferenceDTO.last_1_hr]: {
short: '1h',
full: 'Last 1 hr',
},
[DashboardtypesTimePreferenceDTO.last_6_hr]: {
short: '6h',
full: 'Last 6 hr',
},
[DashboardtypesTimePreferenceDTO.last_1_day]: {
short: '1d',
full: 'Last 1 day',
},
[DashboardtypesTimePreferenceDTO.last_3_days]: {
short: '3d',
full: 'Last 3 days',
},
[DashboardtypesTimePreferenceDTO.last_1_week]: {
short: '1w',
full: 'Last 1 week',
},
[DashboardtypesTimePreferenceDTO.last_1_month]: {
short: '1mo',
full: 'Last 1 month',
},
};
export interface PanelTimePreferenceLabel {
/** Compact pill label, e.g. `6h`. */
short: string;
/** Full label for the pill's tooltip, e.g. `Last 6 hr`. */
full: string;
}
/** Pill labels for a panel's relative time preference, or `null` when it follows the dashboard window (`global_time`/unset). */
export function panelTimePreferenceLabel(
timePreference: DashboardtypesTimePreferenceDTO | undefined,
): PanelTimePreferenceLabel | null {
if (
!timePreference ||
timePreference === DashboardtypesTimePreferenceDTO.global_time
) {
return null;
}
return TIME_PREFERENCE_LABEL[timePreference] ?? null;
}
interface ResolvePanelTimeWindowArgs {
/** The panel's saved per-panel time preference (`visualization.timePreference`). */
timePreference: DashboardtypesTimePreferenceDTO | undefined;
/** Dashboard global window (epoch ms) — used as-is for `global_time`. */
globalStartMs: number;
globalEndMs: number;
/** Explicit caller window (epoch ms), e.g. the editor preview. When present it wins outright over the panel preference. */
override?: PanelTimeWindow;
}
/**
* Resolves the absolute `[startMs, endMs]` window a panel queries over.
*
* Precedence: `override` → relative `timePreference` preset → dashboard global window. A
* preset is anchored to `globalEndMs`, not wall-clock `Date.now()`, so the window is stable
* across renders (no refetch loop) and tracks the dashboard's refresh. All values floored:
* V5 start/end are int64 on the wire and upstream ms can carry a fraction.
*/
export function resolvePanelTimeWindow({
timePreference,
globalStartMs,
globalEndMs,
override,
}: ResolvePanelTimeWindowArgs): PanelTimeWindow {
if (override) {
return {
startMs: Math.floor(override.startMs),
endMs: Math.floor(override.endMs),
};
}
const endMs = Math.floor(globalEndMs);
const spanMs =
timePreference &&
timePreference !== DashboardtypesTimePreferenceDTO.global_time
? PRESET_SPAN_MS[timePreference]
: undefined;
if (spanMs !== undefined) {
return { startMs: endMs - spanMs, endMs };
}
return { startMs: Math.floor(globalStartMs), endMs };
}

View File

@@ -13,13 +13,11 @@ export interface UseGetQueryRangeV5Args {
enabled: boolean;
}
// 4xx responses are deterministic (bad query, auth) — retrying re-sends a
// request that will fail identically. Same policy as V1's useGetQueryRange.
// react-query hands the retry callback the *raw* thrown value, which on this
// path is the AxiosError the generated client rejects with (it is not yet
// normalized to APIError) — so we inspect it at the axios level for the cancel
// signal and the HTTP status. Normalization to APIError happens later, at the
// display boundary (see PanelStatus `panelStatusFromError`).
/**
* Don't retry deterministic 4xx (bad query, auth) — they fail identically (V1 parity).
* The retry callback gets the raw AxiosError this path rejects with (not yet normalized to
* APIError — that happens later at the display boundary), so inspect it at the axios level.
*/
function retryUnlessClientError(failureCount: number, error: Error): boolean {
if (isAxiosError(error)) {
if (error.code === 'ERR_CANCELED') {
@@ -34,11 +32,9 @@ function retryUnlessClientError(failureCount: number, error: Error): boolean {
}
/**
* Pure-V5 query-range fetch: posts the generated request DTO via the
* generated `queryRangeV5` call and returns the raw generated response —
* no V1 `Query` shape on either leg. Wrapped in `useQuery` (not the
* generated `useQueryRangeV5` mutation hook) because panel fetches need
* caching, `enabled` gating, and refetch semantics.
* Pure-V5 query-range fetch: posts the generated request DTO and returns the raw response.
* Wrapped in `useQuery` (not the generated `useQueryRangeV5` mutation) for caching, `enabled`
* gating, and refetch.
*/
export function useGetQueryRangeV5({
requestPayload,

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