Compare commits

...

149 Commits

Author SHA1 Message Date
Vinícius Lourenço
d6cd221cf1 chore(storybook): survey component usage from the story shots scripts 2026-09-21 16:35:59 -03:00
Vinícius Lourenço
bf1443f52a feat(storybook): add open-dropdown stories across the pages that use one 2026-09-21 15:47:29 -03:00
Vinícius Lourenço
b442844f0b fix(storybook): read the service map tags type from its new home 2026-09-21 13:50:22 -03:00
Vinícius Lourenço
7dda74db53 chore(storybook): drop the sidebar order entries for the removed legacy explorers 2026-09-21 13:49:40 -03:00
Vinícius Lourenço
8e71a96244 revert(logs): remove the legacy explorer story 2026-09-21 13:49:40 -03:00
Vinícius Lourenço
1d42399d8e revert(traces): remove the legacy explorer story 2026-09-21 13:49:40 -03:00
Vinícius Lourenço
235496bda2 fix(storybook): select the AI explorer quick filter through the v2 renderer 2026-09-21 13:49:35 -03:00
Vinícius Lourenço
31337bb3b3 fix(storybook): stop a story inheriting the previous one's failed requests 2026-09-21 13:49:35 -03:00
Vinícius Lourenço
2b28da96de fix(storybook): match the span mapper group fixture to the regenerated DTO 2026-09-21 13:49:35 -03:00
Vinícius Lourenço
c31b845a1d Merge remote-tracking branch 'origin/main' into feat/add-more-stories 2026-09-21 13:49:21 -03:00
Aditya Singh
3ecc21377d refactor(traces): delete the old trace explorer code (#12919)
#### Description

- deleted the old trace explorer at `/trace`. its apis
(`/getSpanFilters`, `/getFilteredSpans`, `/getTagFilters`,
`/getTagValues`) were removed from the backend in #6464 so the page has
been 404ing on every request since then. takes `container/Trace`,
`store/actions/trace`, the `traces` redux slice and the dead `api/trace`
clients with it.
- deleted the dead trees that came along with it.. the pre quick filters
`Filter` panel in traces explorer, `GantChart`, `TraceFlameGraph`,
`TraceDetail` and the `/trace-old` redirect. none of these had a real
consumer, they were only compiling because of a stray type import here
and there.
- moved the shared bits out before deleting anything around them..
`Tags`/`OperatorValues`, `getMs`, `StyledCSS` and the live trace detail
helpers. `DurationSection` now sits next to the quick filters duration
renderer since that is its only consumer. `filterUtils` is split, key
catalogue to constants since three unrelated places read it, rest kept
local to the renderer.
- dropped `ROUTES.TRACE`. four places were using it as a string prefix
to build trace detail links, which only worked because `/trace/:id`
shares the prefix. moved them to `generatePath(ROUTES.TRACE_DETAIL, { id
})` like `FieldCell` already does.
- removed `NewExplorerCTA`. with old logs explorer (#12914) and this one
gone it has no route left to render on.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6103

#### Additional Information

- `/trace` and `/trace-old/:id` now fall through to home like any
unknown route. did not add a redirect since that means keeping
`ROUTES.TRACE` around along with its permission and authz test entries.
can add it back if we think bookmarks matter.
- `NEW_ROUTES_MENU_ITEM_KEY_MAP` still has a `'/trace'` key on purpose,
sidebar strips the url to its first segment and that is what highlights
Traces on detail pages.
- could not test four paths on staging.. llm explorer list view, kafka
drop rate links, linked spans and the apm view traces popup. all one
line `generatePath` changes with unit coverage.
- both this and #12914 touch `NewExplorerCTA`, whichever merges second
needs a rebase.
2026-09-21 16:34:29 +00:00
Gaurav Tewari
b071610a27 chore: use endpoint for fetching data (#12926)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Previously we had hardcoded the api data in the Frontend End. now we
have removed it. it's a api from where we fetch data in frontend.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/1b348d4c-c2c1-47c3-b338-2e5b352ea73c



https://github.com/user-attachments/assets/71009d56-0dc8-44b2-9946-0850086ac397





<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-21 14:49:41 +00:00
Swapnil Nakade
41d4818030 feat(authz): enable FGA for cloud integration (#12895)
#### Description
- enabling FGA support for cloud integration end points

#### Issues closed by this PR
Closes: https://github.com/SigNoz/platform-pod/issues/2946

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-09-21 14:20:11 +00:00
Abhi kumar
a2e42df790 fix(charts): stop tooltip labels breaking mid-word (#12894)
#### Description

- Chart tooltip labels were breaking mid-word (`Metric` / `s`) whenever
a row's value was wide. `overflow-wrap: anywhere` drops a flex item's
min-content width to one character, so the label was the only thing in
the row that could give way — the value and the dashed leader kept their
width and squeezed it to nothing.
- The label now uses `overflow-wrap: break-word`, so it breaks a token
only when a line can't hold it whole, and wraps at 3 lines before
ellipsizing. `title` carries the full text.
- Tooltip width floor goes 300 → 360. The width comes from the *legend*
label length, so charts with short series names (billing) got the
narrowest box even though their values are the longest. 360 is already
the upper bound that formula produces, so no tooltip is wider than the
widest one today, and the width still never changes between hovers.

#### Screenshots / Screen Recordings

| Before | After |
| --- | --- |
| Label crushed to ~40px, broken mid-word | Label intact on one line,
value pinned at the row end |

#### Additional Information

Verified in a headless browser at the new width: short labels stay on
one line even with a longer value than the reported case, long series
names wrap at token boundaries with the value still at the end of the
row, and no row overflows horizontally.

Closes https://github.com/SigNoz/pulse-pod/issues/365
2026-09-21 11:57:13 +00:00
Naman Verma
f9928aa4db feat: add plugin schema for area chart panel (#12635)
#### Description

Add a new plugin schema for area chart. This also adds a new enum for
fill mode specific to area charts cuz the default is different wrt time
series chart.

Frontend changes to be built on top of this

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/294
Closes https://github.com/SigNoz/pulse-pod/issues/214

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-09-21 11:14:29 +00:00
Aditya Singh
1c7811c414 refactor(log-details): move infra metrics charts to v5 query_range (#12916)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- moves the metrics tab in log details + span details (same InfraMetrics
component) from v4 to v5 query_range.. pod, node and host charts
- no payload rewrite, the v5 preparer already handles the legacy builder
shape so constants stay as is
- having helper now always emits the v5 expression form and the
`useV5HavingFormat` flag is gone. host payload is shared with Hosts V2
which was already on v5, so both callers line up now
- fixed legends while here.. on v5 the response has per-query metadata
and getLegend was resolving against the explorer's currentQuery, so
CPU/Memory usage showed `count()` instead of the pod name. charts now
pass their own payload query, same as K8s V2 EntityMetrics
- node/pod toggle: re-clicking the pressed pill deselects (radix single
toggle-group emits '') and blanked the tab. ignoring empty values now.
pre-existing, component fix tracked in
https://github.com/SigNoz/components/issues/402

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5807




https://github.com/user-attachments/assets/62826754-e1eb-44b3-af60-50a2b614048d



#### Additional Information

- staging has no bare-vm logs (host.name without k8s.node.name), so the
host chart path from the drawer wasn't hit directly.. verified via Hosts
details which uses the same getHostQueryPayload
- cc. @H4ad
2026-09-21 09:01:08 +00:00
Vinícius Lourenço
baafb7a723 ci(storybook): give each story test a 30s timeout 2026-09-17 13:43:37 -03:00
Vinícius Lourenço
558c4d7cbe chore(oxlint): ban msw imports in .stories.tsx files 2026-09-17 13:43:37 -03:00
Vinícius Lourenço
c9be3c98e6 feat(storybook): add the noz global config handler 2026-09-17 13:43:37 -03:00
Vinícius Lourenço
113c744395 docs(storybook): update the harness readme 2026-09-17 13:43:37 -03:00
Vinícius Lourenço
c1daa3ec63 feat(storybook): add the tooltips foundations story 2026-09-17 13:43:37 -03:00
Vinícius Lourenço
72b30684b4 feat(storybook): add the layering foundations story 2026-09-17 13:43:37 -03:00
Vinícius Lourenço
b2f992ef5f feat(storybook): add the feedback foundations story 2026-09-17 13:43:37 -03:00
Vinícius Lourenço
3fc63da52c feat(components): add storybook story for the tanstack table view 2026-09-17 13:43:36 -03:00
Vinícius Lourenço
d0a91261a3 feat(components): add storybook story for the table 2026-09-17 13:43:36 -03:00
Vinícius Lourenço
81afa641a2 feat(components): add storybook story for the modal 2026-09-17 13:43:36 -03:00
Vinícius Lourenço
e3db2d824b feat(components): add storybook story for the quick filters 2026-09-17 13:43:35 -03:00
Vinícius Lourenço
314bacfc7c feat(components): add storybook story for the new select 2026-09-17 13:43:35 -03:00
Vinícius Lourenço
ea698bb952 feat(components): add storybook story for the fields selector 2026-09-17 13:43:35 -03:00
Vinícius Lourenço
e756dec84d feat(components): add storybook story for the custom time picker 2026-09-17 13:43:34 -03:00
Vinícius Lourenço
50a3927c87 feat(storybook): add the canvas decorator for component stories 2026-09-17 13:43:34 -03:00
Vinícius Lourenço
4279d60f70 fix(storybook): freeze the clock every story reads 2026-09-17 13:43:34 -03:00
Vinícius Lourenço
1a57cb4948 fix(storybook): load every locale namespace before the first story 2026-09-17 13:43:34 -03:00
Vinícius Lourenço
d3f38fc961 feat(alerts): add stories for the google chat channel 2026-09-17 13:43:34 -03:00
Vinícius Lourenço
191a029cab feat(alerts): add stories for the microsoft teams channel 2026-09-17 13:43:33 -03:00
Vinícius Lourenço
030146b201 feat(alerts): add story for the email channel edit page 2026-09-17 13:43:33 -03:00
Vinícius Lourenço
e243b00561 feat(alerts): add stories for the opsgenie channel 2026-09-17 13:43:33 -03:00
Vinícius Lourenço
562e21e036 feat(ai-observability): add storybook story for the explorer page 2026-09-17 13:43:33 -03:00
Vinícius Lourenço
58e62342fe feat(alerts): add stories for the new channel types 2026-09-17 13:43:33 -03:00
Vinícius Lourenço
077a9e14f7 fix(storybook): answer the v4 licenses endpoints 2026-09-17 13:43:33 -03:00
Vinícius Lourenço
dd71f54b1a fix(storybook): keep third-party frames out of the story iframe 2026-09-17 13:43:32 -03:00
Vinícius Lourenço
81dd301273 fix(storybook): answer the legacy autocomplete endpoints 2026-09-17 13:43:32 -03:00
Vinícius Lourenço
6ebfb44708 feat(system): add storybook story for the workspace suspended page 2026-09-17 13:43:32 -03:00
Vinícius Lourenço
16a947a9e0 feat(system): add storybook story for the workspace locked page 2026-09-17 13:43:32 -03:00
Vinícius Lourenço
9edf2a19c7 feat(system): add storybook story for the workspace access restricted page 2026-09-17 13:43:32 -03:00
Vinícius Lourenço
40227aa010 feat(system): add shared storybook mock data for the workspace states 2026-09-17 13:43:32 -03:00
Vinícius Lourenço
793b1d24a5 feat(system): add storybook story for the unauthorized page 2026-09-17 13:43:31 -03:00
Vinícius Lourenço
16f70a0dba feat(traces): add storybook story for the explorer page 2026-09-17 13:43:31 -03:00
Vinícius Lourenço
abe3e5b833 feat(traces): add storybook story for the funnel details page 2026-09-17 13:43:31 -03:00
Vinícius Lourenço
3efe81571e feat(traces): add storybook story for the trace details page 2026-09-17 13:43:31 -03:00
Vinícius Lourenço
dfcf85d499 feat(traces): add storybook story for the legacy explorer page 2026-09-17 13:43:31 -03:00
Vinícius Lourenço
1de77074b1 feat(system): add storybook story for the support page 2026-09-17 13:43:31 -03:00
Vinícius Lourenço
8ae0ee45f8 feat(system): add storybook story for the status page 2026-09-17 13:43:30 -03:00
Vinícius Lourenço
80dce376d1 feat(auth): add storybook story for the sign up page 2026-09-17 13:43:30 -03:00
Vinícius Lourenço
b6af98a0a8 feat(settings): add storybook story for the workspace page 2026-09-17 13:43:30 -03:00
Vinícius Lourenço
191e039890 feat(settings): add storybook story for the single sign-on page 2026-09-17 13:43:30 -03:00
Vinícius Lourenço
9134d0a854 feat(settings): add storybook story for the keyboard shortcuts page 2026-09-17 13:43:30 -03:00
Vinícius Lourenço
5ff24cbc67 feat(settings): add storybook story for the service accounts page 2026-09-17 13:43:30 -03:00
Vinícius Lourenço
2814ec8988 feat(settings): add storybook story for the roles page 2026-09-17 13:43:29 -03:00
Vinícius Lourenço
f542b1de05 feat(settings): add storybook story for the role editor page 2026-09-17 13:43:29 -03:00
Vinícius Lourenço
9f6c6af4c2 feat(settings): add storybook story for the role details page 2026-09-17 13:43:29 -03:00
Vinícius Lourenço
9d15f22ca6 feat(settings): add storybook story for the members page 2026-09-17 13:43:29 -03:00
Vinícius Lourenço
2bbda851dd feat(settings): add storybook story for the mcp server page 2026-09-17 13:43:29 -03:00
Vinícius Lourenço
e3a2998412 feat(settings): add storybook story for the ingestion page 2026-09-17 13:43:29 -03:00
Vinícius Lourenço
b8bebadda9 feat(settings): add storybook story for the billing page 2026-09-17 13:43:28 -03:00
Vinícius Lourenço
f2e2327664 feat(settings): add storybook story for the account page 2026-09-17 13:43:28 -03:00
Vinícius Lourenço
d12d3cb62f feat(services): add storybook story for the list page 2026-09-17 13:43:28 -03:00
Vinícius Lourenço
bfa7482548 feat(services): add storybook story for the top level operations page 2026-09-17 13:43:28 -03:00
Vinícius Lourenço
6e4e2c8ec4 feat(auth): add storybook story for the reset password page 2026-09-17 13:43:28 -03:00
Vinícius Lourenço
c79511637d feat(dashboards): add storybook story for the public page 2026-09-17 13:43:27 -03:00
Vinícius Lourenço
ef72bf1b36 feat(onboarding): add storybook story for the questionnaire page 2026-09-17 13:43:27 -03:00
Vinícius Lourenço
864539cac7 feat(onboarding): add storybook story for the add data source page 2026-09-17 13:43:27 -03:00
Vinícius Lourenço
1051051dc8 feat(settings): add shared storybook mock data 2026-09-17 13:43:27 -03:00
Vinícius Lourenço
eff954b49d feat(metrics): add storybook story for the explorer page 2026-09-17 13:43:27 -03:00
Vinícius Lourenço
c64e898b32 feat(services): add storybook story for the detail page 2026-09-17 13:43:27 -03:00
Vinícius Lourenço
b977149cfd feat(metering): add storybook story for the cost meter page 2026-09-17 13:43:26 -03:00
Vinícius Lourenço
57c9011e39 feat(messaging-queues): add storybook story for the overview page 2026-09-17 13:43:26 -03:00
Vinícius Lourenço
72333a75a9 feat(messaging-queues): add storybook story for the kafka page 2026-09-17 13:43:26 -03:00
Vinícius Lourenço
849c77d06b feat(messaging-queues): add storybook story for the kafka detail page 2026-09-17 13:43:26 -03:00
Vinícius Lourenço
8ae9914143 feat(messaging-queues): add storybook story for the celery page 2026-09-17 13:43:26 -03:00
Vinícius Lourenço
3e2326b475 feat(messaging-queues): add shared storybook mock data 2026-09-17 13:43:26 -03:00
Vinícius Lourenço
f4b1889558 feat(logs): add storybook story for the settings page 2026-09-17 13:43:25 -03:00
Vinícius Lourenço
2b1bb1a3c7 feat(logs): add storybook story for the saved views page 2026-09-17 13:43:25 -03:00
Vinícius Lourenço
cbea5a528c feat(logs): add storybook story for the pipelines page 2026-09-17 13:43:25 -03:00
Vinícius Lourenço
5b13ae2ca3 feat(logs): add storybook story for the explorer page 2026-09-17 13:43:25 -03:00
Vinícius Lourenço
c23f0a0a28 feat(logs): add storybook story for the live tail page 2026-09-17 13:43:25 -03:00
Vinícius Lourenço
ff244f53c6 feat(logs): add shared storybook mock data 2026-09-17 13:43:24 -03:00
Vinícius Lourenço
138b14d14a feat(logs): add storybook story for the legacy explorer page 2026-09-17 13:43:24 -03:00
Vinícius Lourenço
51fd759c71 feat(auth): add storybook story for the login page 2026-09-17 13:43:24 -03:00
Vinícius Lourenço
c6155d313f feat(system): add storybook story for the license page 2026-09-17 13:43:24 -03:00
Vinícius Lourenço
8166da8158 feat(ai-observability): add storybook story for the overview page 2026-09-17 13:43:24 -03:00
Vinícius Lourenço
991ca7573c feat(ai-observability): add storybook story for the model pricing page 2026-09-17 13:43:23 -03:00
Vinícius Lourenço
09d07ca3a6 feat(ai-observability): add storybook story for the attribute mapping page 2026-09-17 13:43:23 -03:00
Vinícius Lourenço
11cb979f27 feat(integrations): add storybook story for the list page 2026-09-17 13:43:23 -03:00
Vinícius Lourenço
8a8fe5371c feat(integrations): add storybook story for the details page 2026-09-17 13:43:23 -03:00
Vinícius Lourenço
9a3c1ad181 feat(integrations): add storybook story for the cloud account page 2026-09-17 13:43:23 -03:00
Vinícius Lourenço
36a452d790 feat(integrations): add shared storybook mock data 2026-09-17 13:43:22 -03:00
Vinícius Lourenço
b053f8b255 feat(infrastructure): add storybook story for the kubernetes volumes page 2026-09-17 13:43:22 -03:00
Vinícius Lourenço
6759d3d188 feat(infrastructure): add storybook story for the kubernetes statefulsets page 2026-09-17 13:43:22 -03:00
Vinícius Lourenço
9613130cc0 feat(infrastructure): add storybook story for the kubernetes pods page 2026-09-17 13:43:22 -03:00
Vinícius Lourenço
9d9be599eb feat(infrastructure): add storybook story for the kubernetes nodes page 2026-09-17 13:43:21 -03:00
Vinícius Lourenço
ca27cb3494 feat(infrastructure): add storybook story for the kubernetes namespaces page 2026-09-17 13:43:21 -03:00
Vinícius Lourenço
97f6b99707 feat(infrastructure): add storybook story for the kubernetes jobs page 2026-09-17 13:43:21 -03:00
Vinícius Lourenço
dcf68caf6c feat(infrastructure): add storybook story for the kubernetes deployments page 2026-09-17 13:43:20 -03:00
Vinícius Lourenço
364d6b52f1 feat(infrastructure): add storybook story for the kubernetes daemonsets page 2026-09-17 13:43:20 -03:00
Vinícius Lourenço
e1ac3f7b1b feat(infrastructure): add storybook story for the kubernetes clusters page 2026-09-17 13:43:19 -03:00
Vinícius Lourenço
a205710d7f feat(infrastructure): add shared storybook mocks for the kubernetes pages 2026-09-17 13:43:19 -03:00
Vinícius Lourenço
bcf32dec1f refactor(infrastructure): rework the overview storybook story 2026-09-16 09:41:52 -03:00
Vinícius Lourenço
f1f63b7fa7 refactor(home): update the storybook story 2026-09-16 09:41:52 -03:00
Vinícius Lourenço
81e8bff6fa feat(auth): add storybook story for the forgot password page 2026-09-16 09:41:52 -03:00
Vinícius Lourenço
e5699b1461 feat(exceptions): add storybook story for the detail page 2026-09-16 09:41:52 -03:00
Vinícius Lourenço
8882260445 feat(system): add storybook story for the error fallback page 2026-09-16 09:41:52 -03:00
Vinícius Lourenço
3569ba7a61 feat(alerts): add storybook story for the edit page 2026-09-16 09:41:52 -03:00
Vinícius Lourenço
f80c2b4fc4 feat(dashboards): add storybook story for the list page 2026-09-16 09:41:51 -03:00
Vinícius Lourenço
8290a1d9fd feat(dashboards): add storybook story for the panel editor page 2026-09-16 09:41:51 -03:00
Vinícius Lourenço
67c650c62d feat(dashboards): add storybook story for the detail page 2026-09-16 09:41:51 -03:00
Vinícius Lourenço
255061b53f feat(dashboards): add shared storybook mock data 2026-09-16 09:41:51 -03:00
Vinícius Lourenço
94befc2a1d feat(alerts): add storybook story for the create page 2026-09-16 09:41:51 -03:00
Vinícius Lourenço
19926f9e20 feat(external-apis): add storybook story 2026-09-16 09:41:51 -03:00
Vinícius Lourenço
5e86f134d7 feat(exceptions): add storybook story for the list page 2026-09-16 09:41:50 -03:00
Vinícius Lourenço
05a33d82ae feat(alerts): add storybook story for the triggered page 2026-09-16 09:21:28 -03:00
Vinícius Lourenço
9630622f28 feat(alerts): add storybook story for the routing policies page 2026-09-16 09:21:28 -03:00
Vinícius Lourenço
b8853a0556 feat(alerts): add storybook story for the planned downtime page 2026-09-16 09:21:28 -03:00
Vinícius Lourenço
62aac2ac7b feat(alerts): add storybook story for the channels new page 2026-09-16 09:21:28 -03:00
Vinícius Lourenço
3167bf13ea feat(alerts): add storybook story for the channels edit page 2026-09-16 09:21:28 -03:00
Vinícius Lourenço
8501d6bc5f feat(alerts): add storybook story for the channels list page 2026-09-16 09:21:28 -03:00
Vinícius Lourenço
50acd473e6 feat(alerts): add storybook story for the rules page 2026-09-16 09:21:27 -03:00
Vinícius Lourenço
811c66af36 feat(alerts): add storybook story for the overview page 2026-09-16 09:21:27 -03:00
Vinícius Lourenço
d023e48635 feat(alerts): add storybook story for the history page 2026-09-16 09:21:27 -03:00
Vinícius Lourenço
3fa5a754f3 feat(alerts): add shared storybook mock data 2026-09-16 09:21:27 -03:00
Vinícius Lourenço
c306fb1a6d feat(noz): add storybook story 2026-09-16 09:21:27 -03:00
Vinícius Lourenço
d4affb3452 feat(metering): add storybook story for the usage explorer page 2026-09-16 09:21:27 -03:00
Vinícius Lourenço
afbaad9511 feat(services): add storybook story for the service map page 2026-09-16 09:21:26 -03:00
Vinícius Lourenço
43e79a249a feat(system): add storybook story for the not found page 2026-09-16 09:21:26 -03:00
Vinícius Lourenço
fcea1b86ce fix(ai-assistant): keep the composer height on auto while hidden 2026-09-16 09:21:26 -03:00
Vinícius Lourenço
aa93c94e0f docs(storybook): update the harness readme 2026-09-16 09:21:26 -03:00
Vinícius Lourenço
30eb205623 feat(storybook): expand the shared query_range mock data 2026-09-16 09:21:26 -03:00
Vinícius Lourenço
9284e40cfc feat(storybook): sort the sidebar like the side nav 2026-09-16 09:21:26 -03:00
Vinícius Lourenço
a3220483ff feat(storybook): add the docs addon 2026-09-16 09:21:25 -03:00
Vinícius Lourenço
4461c07231 feat(storybook): warn when a doc comment clobbers the story mocks 2026-09-16 09:20:39 -03:00
Vinícius Lourenço
44bf33669f feat(storybook): support an initial location state 2026-09-16 09:20:39 -03:00
Vinícius Lourenço
755cb5ae0f fix(storybook): mirror the story search onto the preview url 2026-09-16 09:20:39 -03:00
Vinícius Lourenço
c7f65e8cb4 fix(storybook): let msw answer server-sent events 2026-09-16 09:20:38 -03:00
Vinícius Lourenço
c2dc2e7f46 fix(storybook): keep esbuild as the css minifier 2026-09-16 09:20:38 -03:00
Vinícius Lourenço
42aa496f54 fix(storybook): set up the monaco loader in the preview 2026-09-16 09:20:38 -03:00
Vinícius Lourenço
a7af3761dd fix(storybook): point the redux singleton at the story store 2026-09-16 09:20:38 -03:00
Vinícius Lourenço
704a9a18b9 docs(storybook): update the page story skill 2026-09-16 09:20:38 -03:00
Vinícius Lourenço
d86a910e24 feat(infra-monitoring): add storybook 2026-09-16 09:20:38 -03:00
Vinícius Lourenço
892ef131d7 feat(storybook): add the hold-tooltips-open control 2026-09-16 09:20:37 -03:00
507 changed files with 37708 additions and 10195 deletions

View File

@@ -26,6 +26,135 @@ process on top of it.
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
report the story as done without it.
## Where it lands in the sidebar
The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`), so
a page sits where someone would click it in the product. Four things decide that,
and all four are part of writing the story, not a follow-up.
**Title.** `Pages/<Area>/<Page>`, where `<Area>` is the nav section and `<Page>`
is the label the nav gives it.
- The leaf is the product's label, never the component's name: `MetricsExplorer`
is `Metrics/Explorer`, `MeterExplorer` is `Metering/Cost Meter`,
`AIAssistantPage` is `Noz`.
- Never repeat the area in the leaf: `Alerts/Rules`, not `Alerts/AlertRules`.
- A leaf never shares its name with a sibling folder. The folder wins and the
page becomes `List`, or `Overview` for a tab strip: `Services/List` beside
`Services/Detail`.
- Title Case with spaces. No camelCase, no kebab.
- Four levels is the floor to stay under: `Pages/Alerts/Channels/New` is as deep
as it goes.
- Pages nobody navigates to on purpose go under `Pages/System` (`Status`,
`Unauthorized`, `Workspace Locked`), and the pre-session pages under
`Pages/Auth`.
- A page whose permission stories earn their own folder becomes one:
`Pages/Settings/Billing/Overview` beside `Pages/Settings/Billing/Authz`. See
**Permission stories** below.
**Order.** The `storySort.order` literal in `.storybook/preview.tsx` carries the
order for every level. A new page in an existing area is appended to that area's
array, in the order the product lists it; a new area goes where the side nav
puts it. Storybook parses the order out of the file statically, so it has to
stay an inline literal. Missing entries fall to the end of their level rather
than disappearing, so a forgotten edit is a page at the bottom of its area, not
a broken sidebar.
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag
filter answers questions with. Only these:
| Tag | When |
| --- | --- |
| `authz` | The page gates UI on permission checks through `lib/authz` (`AuthZButton`, `AuthZGuard`, `useAuthZ`). Both the page's file and its `Authz` file carry it. |
| `role-gated` | The page still branches on the legacy role (`user.role`, `hasEditPermission`) and has no authz check. |
| `beta` | `isBeta` on its nav entry. Drop the tag when the product drops the badge. |
| `legacy` | Superseded by another page but still routed. The doc comment names the page to start from instead. |
| `play` | The story file has a `play` function, so at least one state is reached by an interaction. |
`autodocs` comes from `preview.tsx` and is never written on a meta.
**Doc comment on the meta.** What the page is, in the page's own terms, then a
blank line, then the route:
```tsx
const pageStory = storyMocks(logsExplorerMocks, {
route: explorerRoute('explorer'),
layout: 'app',
});
/**
* The logs explorer: the query builder, the list, the frequency chart and the log
* detail drawer, with quick filters and saved views beside them.
*
* Route: `/logs/logs-explorer`.
*/
const meta = {
title: 'Pages/Logs/Explorer',
tags: ['play'],
component: LogsModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<LogsExplorerArgs>;
```
The `pageStory` const and the trailing `parameters` line are what make the doc
comment safe. The comment compiles to a `parameters` property that the csf plugin
appends after the spread, so a meta that spreads `storyMocks(...)` and stops
there loses `parameters.signoz` and renders the page against the global handlers
alone: every one of the page's endpoints misses. Restating `parameters` as a
literal gives the plugin something to merge into. `resolveStory` logs the
combination that says it happened, so the console names it rather than leaving it
to be found by reading the page.
It is the description on the page's Docs page, which is the only place a reader
who is not in the code finds out what the page is for. Two or three sentences:
what it shows, what drives it, and the gating worth knowing about (`Gated on
authz permissions`, `follows the legacy editor role`). A control-driven route
says so instead of a path: ``Route: `/metrics-explorer/*`, the tab control picks
which``.
## Permission stories
A page that gates UI on `lib/authz` keeps its permission states in a folder of
their own, so the page's own file stays about the page and the sidebar answers
"what does this permission do" in one place.
**Layout.** A second story file at `stories/authz/<Page>.authz.stories.tsx`,
titled `Pages/<Area>/<Page>/Authz`, which turns the page into a folder: its own
file is retitled `Pages/<Area>/<Page>/Overview`, and `.storybook/preview.tsx`
gains the sub-order (`'Billing', ['Overview', 'Authz']`). Both files carry the
`authz` tag and share the page's one mocks module, which the authz file imports
as `../<Page>.stories.mocks`. It declares no controls and no mock data of its
own: a permission story that needs a new response is a control the page's mocks
were missing.
**One story per permission the page reads**, named for what is gone: `NoRead`,
`NoList`, `NoUpdate`, `NoCreate`, `NoDelete`. Then the combinations the page
itself distinguishes, and only those: `NoManage` where two permissions gate one
button, `ReadOnly` where everything but reading is denied, `NoSubscriptionAccess`
where none of the resource's permissions are held, and `CheckFailed` for
`authzState: 'error'`, which is the page's fail-open path rather than a denial.
**Revoke, never allow-list.** Each story is a full grant minus what its name
says: `args: { revoked: ['read:subscription'] }`. The `Revoked` control subtracts
from the preset, so the story stays "an admin missing one permission" as the
catalogue grows, and the diff against the page's `Default` is the one permission.
Rebuilding the allow-list by hand drifts the moment a resource is added.
**Never a role preset in this folder.** `access: 'viewer'` moves the legacy role,
the side nav and every other resource's permissions at the same time, so the
story no longer shows what its name claims. A persona is a story on the page's
own file, and only when the product has that persona.
**Pair the revocation with the state that renders the gated control.** A button
that only exists on a trial needs the plan too:
`args: { plan: 'on-trial', revoked: ['create:subscription'] }`. A permission
whose denial changes nothing on screen gets no story: say so in the PR.
Verify these by their disabled states, not their text. The page reads the same
either way, so a story that is wrong looks right: read `disabled` off the buttons
the permission gates, and check the denial callout is there or gone.
## Rules
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
@@ -50,7 +179,29 @@ process on top of it.
- **File layout**: every story file for a page lives under
`src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`,
payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing
page-specific in `src/storybook/controls/`.
page-specific in `src/storybook/controls/`. A page that is a tab strip over
several routes gets one story file per tab, in its own folder under the module
page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its
own mocks and `__story_mockdata__/`; the builders more than one tab needs stay
in the module page's own `stories/__story_mockdata__/`
(`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as
`../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab
strip is there, and the `route` its mocks return decides which tab is open.
A page's permission stories go one level further down, in
`stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see
**Permission stories**.
- **A state only a click reaches is a story with a `play` function**, not a
control: a drawer, a modal, an edit mode the page holds in component state.
Drive it with `userEvent` and the queries from `storybook/test`, take the first
of a repeated row action, and wait on the state's own text. The page fetches
before it renders a row, so the finder needs a timeout past the 1s default. A
state the app drops again on its own, such as one keyed on an array identity
that a refetch replaces, does not get a story: it would not survive being
looked at. A *sequence* of such states, a wizard's steps or a
questionnaire's pages, is still a control: declare the steps in the mocks
module and walk them from a `play` on the meta that destructures `mount`, which
is what makes Storybook replay it on an arg change. See
[references/controls.md](references/controls.md).
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file
under a `__story_mockdata__/` open with this banner, above the imports:
@@ -74,6 +225,13 @@ process on top of it.
writing a response shape inline, check if a builder exists; if not and the
shape will repeat, add it there. Page-specific builders stay in the page's
`__story_mockdata__/`.
- **The story's own doc comment is per state.** Every `export const` gets one:
what that state shows, not how it is built. It renders in the States list on
the page's Docs page, so `Undocumented.` there is a story nobody described.
- **Story names come from a fixed vocabulary** where one fits: `Default`,
`Viewer`, `Empty`, `Loading`, `Error`. Page-specific states get page-specific
names (`NoIngestion`, `Unlicensed`), never a second spelling of one of those
(`ViewerAccess`, `NonAdmin`).
- **No comment is the default.** Write one only for what the code cannot show:
a shape the backend dictates, an app bug the mock reproduces, an ordering or
cap the page depends on, a workaround and the reason for it. Never restate a
@@ -85,6 +243,16 @@ process on top of it.
## Done means
- [ ] `Default` shows the page with data, checked in dark and light
- [ ] title follows the sidebar rules, tags declared, and the page's entry added
to the `storySort.order` literal in `.storybook/preview.tsx`
- [ ] the meta carries its doc comment with the `Route:` line, the meta restates
`parameters: { ...pageStory.parameters }` after the spread, and every story
export carries its own doc comment
- [ ] the page's Docs page renders: description, controls table, and one row per
state with no `Undocumented.`
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it
reads, each reached by `revoked`, none of them a role preset, and each one
checked by the `disabled` state of what the permission gates
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
- [ ] every control flipped once, its effect seen on screen
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled

View File

@@ -128,20 +128,83 @@ export const servicesMocks = defineStoryMocks({
// src/pages/Services/stories/Services.stories.tsx
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
const pageStory = storyMocks(servicesMocks, {
route: ROUTES.APPLICATION,
layout: 'app',
});
/**
* Every instrumented service with its p99, error rate and throughput.
*
* Route: `/services`.
*/
const meta = {
title: 'Pages/Services',
title: 'Pages/Services/List',
component: Services,
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION, layout: 'app' }),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ServicesArgs>;
```
`PageStoryArgs` folds in the global controls, so a story's `args` can set
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
## A step the page keeps in component state
A wizard's step, a questionnaire's page, a picker's next question: the page holds
it in `useState` and nothing in the URL says which one is open. It is still a
control. Declare the steps in the mocks module and drive them from a `play` on
the **meta**, so every story of the page inherits the walk and only sets `args`:
```tsx
// <Page>.stories.mocks.tsx
export const SETUP_STEPS = ['pick-source', 'pick-framework', 'configure'] as const;
export type SetupStep = (typeof SETUP_STEPS)[number];
controls: {
step: choiceControl<SetupStep>('Setup step', { group: SETUP, options: SETUP_STEPS, value: 'pick-source' }),
},
```
```tsx
// <Page>.stories.tsx
const meta = {
play: async ({ mount, args, canvasElement }): Promise<void> => {
await mount();
await advanceToSetupStep(canvasElement, args.step);
},
...storyMocks(pageMocks),
} satisfies Meta<PageArgs>;
export const Configure: Story = { args: { step: 'configure' } };
```
**Destructuring `mount` is what makes it a control.** Storybook re-runs a play
function on an arg change only for a story whose play asks to be remounted
(`usesMount`); otherwise it re-renders the tree the previous walk left behind and
the panel looks broken. With `mount` destructured, the story renders when `play`
calls it, and every arg change replays the walk from a fresh mount.
The walk itself:
- one `answer` function per step, in an array indexed the same as the step list,
so reaching step *n* is `answers.slice(0, STEPS.indexOf(step))`;
- answer each step with the least its Next button accepts, and prefer a "do this
later" over filling a slider;
- run them sequentially (`reduce` over a promise), since each answer is what
renders the step the next one reads;
- bail out when the page did not start where the walk expects, such as a source
deep-linked past the questions. Check for the first step's own text rather than
reading another control's value.
An endpoint that only settles the transition between two steps (the profile a
questionnaire saves before its last page) takes a plain resolver, or the Data
control on `loading` strands the walk halfway.
## Not a control
- Anything the global controls already cover: banner, side nav, data state,
access preset, permissions, check state.
access preset, granted permissions, revoked permissions, check state.
- A knob whose effect nobody can see on the page. Delete it or find the widget it
was supposed to drive.
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
@@ -155,9 +218,12 @@ const meta = {
Default to a control. Write a story when the state is worth a link:
- the fresh workspace, because that is what a new user sees
- the restricted user, when permissions visibly change the page
- a page-defining mode (a tab, a category) that has its own layout
A permission that visibly changes the page is a story too, but it goes in the
page's `Authz` folder, one per permission, turned with the `Revoked` control.
See **Permission stories** in SKILL.md.
Combinations of controls do not need stories, which is what the panel is for.
Each story gets one prose doc comment: what it shows, in the page's own terms.

View File

@@ -12,11 +12,12 @@ cd frontend && pnpm storybook --ci --quiet # :6006, background it
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
already-running server; an empty first poll is not a broken `stories` glob.
Story ids come from the meta title: `Pages/Services` `pages-services`, plus the
story export in kebab-case. Render one story on its own:
Story ids come from the meta title: `Pages/Services/List`
`pages-services-list`, plus the story export in kebab-case. Render one story on
its own:
```
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
```
## Flip controls from the URL

View File

@@ -1,6 +1,6 @@
---
name: storybook-visual-diff
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Also surveys where a component is used across the UI, ringing each instance in red and collecting every one into a single contact sheet. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, find which pages a change affects, or show every place a component appears.
---
# Storybook visual diff
@@ -22,7 +22,7 @@ says, take it and do not ask again; ask only for what is genuinely missing, in
| To settle | Ask | Options |
| --- | --- | --- |
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
| Job | "What should this run produce?" | shoot only · survey where a component is used (§4) · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
| Themes | "Which themes?" | dark · dark + light |
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
@@ -39,6 +39,7 @@ The job decides which loop below to run:
| Job | Loop |
| --- | --- |
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
| **usage survey** | §1, §4, stop. One run, no diff: the question is where a component appears, not what moved. |
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
@@ -122,6 +123,8 @@ node scripts/story-shots.mjs .story-shots/baseline \
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
| `--highlight <selector>` | also write `<id>--highlight.png`, every match ringed in red with 6px of padding |
| `--crop <selector>` | also write one `crops/<id>--<n>.png` per match, and montage the theme's crops into `crops.png` |
| `--flat` | write `<out>/<id>.png`, no theme directory |
| `--no-caption` | leave the caption band off the shots |
| `--list` | print the matched stories and exit |
@@ -129,8 +132,9 @@ node scripts/story-shots.mjs .story-shots/baseline \
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
rows) and how the run was configured (args, clock, width, height, grow, motion,
settle, ignore). Keep the flags identical between the two runs or the diff pairs
nothing.
settle, ignore, highlight, crop). Keep the flags identical between the two runs
or the diff pairs nothing. `--highlight` and `--crop` write extra files beside
the shots; §4 is what they are for.
Every shot carries the caption band described below, so a single screenshot says
what it is on its own. `--no-caption` leaves it off, and so does a machine
@@ -208,10 +212,75 @@ pixelmatch's `includeAA: false`. So the script implements that comparison:
A pair whose shots are different sizes is compared over the overlap, and every
row and column that exists in only one of them counts as changed.
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
(new on the feature branch, renamed, retitled) has nothing to pair with and is
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
before reading the numbers.
Pairing is by `<theme>/<story-id>.png`. A file that exists on one side only (a
story added on the feature branch, renamed, retitled, or one whose capture
failed) has nothing to compare against. It is not skipped: the side that has the
shot is written out, captioned `missing previous` or `missing current`, and every
one of its pixels counts as changed, so it sorts to the top of the report and is
printed with that note. In the parallel modes the run that does not have it gets
a placeholder tile saying so, in the theme's own colours, so the montage keeps
its three tiles. Without this a whole component going missing reads as a clean
run.
## 4. Surveying where a component is used
A different question from a diff: not *what moved*, but *where does this
component appear and what does each instance look like*. One run answers it.
```bash
node scripts/story-shots.mjs .story-shots/button-group --port 6007 --theme dark,light \
--highlight '.ant-btn-group, div[role="group"][class*="button-group"]' \
--crop '.ant-btn-group, div[role="group"][class*="button-group"]' \
--stories pages-home--default,pages-alerts-history--default,...
```
Four things come out, per theme:
- `<theme>/<id>.png` — the page as it is.
- `<theme>/<id>--highlight.png` — the same page with every instance ringed in
red. This is what says *where on the page*, which a crop cannot.
- `<theme>/crops/<id>--<n>.png` — each instance on its own.
- `<theme>/crops.png` — every crop of that theme in one labelled contact sheet.
The sheet is the useful artifact. Twelve instances across nine pages is one
image to read, not twelve files to open in turn, and the label under each says
which story it came from.
### Finding the selector and the stories
1. **Grep the source for the import, not the tag.** `Button.Group` and
`ButtonGroup` are two different components in this repo: antd's, and
`@signozhq/ui/button`'s. A survey that greps one misses the other.
2. **Read the rendered markup, not the JSX.** `--crop` takes a CSS selector
against the DOM. antd's group is `.ant-btn-group`; the design-system one is a
`div[role="group"]` whose class is a hashed CSS module, hence
`[class*="button-group"]`. Open the built component under
`node_modules/@signozhq/ui/dist/` when the class is not obvious.
3. **Map each source file to the story that renders it.** Follow the consumers:
a container renders inside a page, and the page's story is the one to shoot.
A component behind a drawer or a tab needs the story whose `args` open it
(`--args drawer:endpoint-stats`), not the page default.
4. **Let the run itself confirm the mapping.** Each story logs `N cropped`. A
`0 cropped` line means that story never reaches the state, so swap the story
rather than the selector.
### What a zero means
- **`0 cropped` on a story** — the component is not on that page in that state.
Wrong story, or the state is behind an interaction the story has no `play`
for. A modal nobody opens cannot be surveyed; say so instead of shooting the
page it sits behind.
- **An instance in the source with no crop** — a container whose children are
all conditional renders as a 0x0 box. Both `--crop` and `--highlight` skip
anything under 1px, since there is nothing on screen to ring. That is a
finding about the component, not a failure of the run: it is in the tree and
invisible.
A story logged `viewport-sized content, stopped chasing Npx` is shot back at
`--height` with its own scrollbar, and what is below the fold there is laid out
but never painted. The crops of such a story are taken at the chased height
instead, so they are not the black rectangles the page shot would give; the
`--highlight` shot still shows only what fits the viewport.
## What makes a shot reproducible
@@ -292,3 +361,6 @@ for `--ignore` when a region cannot be settled.
- **Stories behind a hover, drawer or modal** only render what their `play`
reaches. If a state is missing from the shot, the story needs the `play`, not
the script.
- **The caption's temporary file is written beside the shot**, not in the system
temp directory. `/tmp` is often a different filesystem, and the rename back
over the shot then fails with `EXDEV: cross-device link not permitted`.

View File

@@ -3321,6 +3321,53 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
fillOpacity:
$ref: '#/components/schemas/DashboardtypesFillOpacity'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesAreaChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
type: object
DashboardtypesAreaChartVisualization:
properties:
fillSpans:
type: boolean
stack:
$ref: '#/components/schemas/DashboardtypesStackMode'
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesAreaFillMode:
enum:
- solid
- gradient
type: string
DashboardtypesAxes:
properties:
isLogScale:
@@ -3552,6 +3599,11 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -4014,6 +4066,7 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -4026,6 +4079,7 @@ components:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
@@ -4037,6 +4091,7 @@ components:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
@@ -4044,6 +4099,18 @@ components:
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
kind:
enum:
- signoz/AreaChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
@@ -4374,6 +4441,12 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
@@ -11102,9 +11175,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- cloud-integration:read
- tokenizer:
- VIEWER
- cloud-integration:read
summary: Agent check-in
tags:
- cloudintegration
@@ -11154,9 +11227,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:list
- tokenizer:
- ADMIN
- cloud-integration:list
summary: List accounts
tags:
- cloudintegration
@@ -11211,9 +11284,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:create
- tokenizer:
- ADMIN
- cloud-integration:create
summary: Create account
tags:
- cloudintegration
@@ -11256,9 +11329,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:delete
- tokenizer:
- ADMIN
- cloud-integration:delete
summary: Disconnect account
tags:
- cloudintegration
@@ -11324,9 +11397,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:read
- tokenizer:
- ADMIN
- cloud-integration:read
summary: Get account
tags:
- cloudintegration
@@ -11373,9 +11446,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:update
- tokenizer:
- ADMIN
- cloud-integration:update
summary: Update account
tags:
- cloudintegration
@@ -11431,9 +11504,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:list
- tokenizer:
- ADMIN
- cloud-integration:list
summary: List account services metadata
tags:
- cloudintegration
@@ -11506,9 +11579,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:read
- tokenizer:
- ADMIN
- cloud-integration:read
summary: Get service for account
tags:
- cloudintegration
@@ -11560,9 +11633,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- cloud-integration:update
- tokenizer:
- ADMIN
- cloud-integration:update
summary: Update service
tags:
- cloudintegration
@@ -11617,9 +11690,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- cloud-integration:read
- tokenizer:
- VIEWER
- cloud-integration:read
summary: Agent check-in
tags:
- cloudintegration
@@ -11670,9 +11743,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- ingestion-key:create
- serviceaccount:create
- factor-api-key:create
- serviceaccount:attach
- role:attach
- tokenizer:
- ADMIN
- ingestion-key:create
- serviceaccount:create
- factor-api-key:create
- serviceaccount:attach
- role:attach
summary: Get connection credentials
tags:
- cloudintegration
@@ -11722,10 +11803,8 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
- api_key: []
- tokenizer: []
summary: List services metadata
tags:
- cloudintegration
@@ -11780,10 +11859,8 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
- api_key: []
- tokenizer: []
summary: Get service
tags:
- cloudintegration

View File

@@ -295,6 +295,8 @@
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
"signoz/no-dashboard-fetch-outside-root": "error",
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
"signoz/no-msw-in-story-file": "error",
// Bans msw imports in *.stories.tsx; handlers/mock data belong in the sibling .stories.mocks.tsx
"no-restricted-globals": [
"error",
{

View File

@@ -27,12 +27,22 @@ const mockAliases = [
find: /^(?:src\/)?api\/common\/logEvent$/,
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
},
{
// jest: not replaced, the suite mounts a mock store per test.
find: /^(?:src\/)?store$/,
replacement: `${srcPath}/storybook/mocks/store.mock.ts`,
},
{
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
// resolves a relative `/api/...` against `http://localhost`.
find: /^(?:src\/)?constants\/env$/,
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
},
{
// jest: not replaced, a test opens the one tooltip it is about.
find: /^@signozhq\/ui\/tooltip$/,
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
},
];
/**
@@ -55,12 +65,12 @@ const isExcluded = (plugin: PluginOption): boolean =>
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.@(ts|tsx)'],
stories: ['../src/storybook/docs/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
// `../public` carries the fonts, icons and i18n bundles the app expects at
// the root; `./public` carries the msw worker, which must not ship in a
// production build.
staticDirs: ['../public', './public'],
addons: ['@storybook/addon-a11y'],
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
core: { disableTelemetry: true },
viteFinal: async (viteConfig) => {
const plugins = (viteConfig.plugins ?? [])
@@ -77,6 +87,14 @@ const config: StorybookConfig = {
return {
...viteConfig,
build: {
...viteConfig.build,
// `vite.config.ts` sets this for the app; Storybook's builder replaces
// `build` wholesale, which leaves rolldown-vite on its default
// lightningcss. That one rejects `:global()` in a plain stylesheet, which
// the app has, and the static build dies in CSS minification.
cssMinify: 'esbuild',
},
plugins,
resolve: {
...viteConfig.resolve,

View File

@@ -6,6 +6,17 @@
-->
<link rel="stylesheet" href="storybook-fonts.css" />
<!--
Third-party frames are the one thing msw cannot answer: a cross-origin iframe
navigates outside the service worker's scope, so the YouTube embeds and the
docs pane in onboarding reach the real network. Same intent as the boot data
below, enforced by the browser instead.
-->
<meta
http-equiv="Content-Security-Policy"
content="frame-src 'self' blob: data:"
/>
<link rel="stylesheet" href="css/uPlot.min.css" />
<script>
@@ -24,3 +35,38 @@
},
};
</script>
<script>
// The wall clock every story reads. Chart windows, `4 mins ago` labels and
// trial countdowns all derive from `now`, and Chromatic does not freeze the
// clock, so a live one redraws every chart axis between two builds of the
// same code. `performance.now` and the timers keep running, so anything
// waiting on a timeout still resolves. `?storyClock=live`, or an ISO
// instant, overrides it.
//
// `new Date()` is the frozen instant, which is what the app renders from.
// `Date.now()` runs on from it instead, because it is also what code measures
// elapsed time with: `lodash.debounce` compares two `Date.now()` readings to
// decide its trailing call is due, so a frozen one re-arms its timer forever
// and every debounced input in the app (the onboarding catalogue search, the
// pipelines search, the log filter) silently stops filtering.
(() => {
const asked = new URLSearchParams(window.location.search).get('storyClock');
if (asked === 'live') return;
const frozen = Date.parse(asked || '2026-06-15T12:00:00.000Z');
if (Number.isNaN(frozen)) return;
const RealDate = Date;
const started = performance.now();
class FrozenDate extends RealDate {
constructor(...args) {
super(...(args.length ? args : [frozen]));
}
static now() {
return frozen + (performance.now() - started);
}
}
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
})();
</script>

View File

@@ -3,6 +3,8 @@ import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
import PageDocs from '../src/storybook/docs/PageDocs';
import ThemedDocsContainer from '../src/storybook/docs/ThemedDocsContainer';
import { withProviders } from '../src/storybook/decorators/withProviders';
import { globalMocks } from '../src/storybook/globals';
import { resetStoryHistory } from '../src/storybook/navigation/containment';
@@ -13,7 +15,12 @@ import {
} from '../src/storybook/runtime/resolveStory';
import { allModes } from './modes';
import '../src/ReactI18';
import i18n from '../src/ReactI18';
// `src/index.tsx` does this at boot: without it `@monaco-editor/react` falls back
// to its loader default and pulls Monaco from cdn.jsdelivr.net, which msw does
// not report because the requests look like static assets.
import '../src/lib/monaco/setup';
import '../src/styles.scss';
@@ -63,10 +70,127 @@ const { worker, ready } = (holder.__signozStorybookWorker ??=
};
})());
/**
* `t()` answers with the key until the namespace's JSON has landed, and a `play`
* that clicks as soon as the story renders is quick enough to catch it: the
* channel form's "Channel name is mandatory" arrives as `channel_name_required`.
* Every namespace under `public/locales/en` is loaded once, ahead of the first
* story.
*/
const translationsReady = i18n.loadNamespaces(
Object.keys(import.meta.glob('../public/locales/en/*.json')).map((path) =>
path.slice(path.lastIndexOf('/') + 1, -'.json'.length),
),
);
const preview: Preview = {
parameters: {
layout: 'fullscreen',
controls: { expanded: true },
// The sidebar order, mirroring the app's own side nav
// (`container/SideNav/menuItems.tsx`), so a page sits where someone would
// click it in the product. Storybook's default is the order the story files
// happen to be globbed in, which puts `src/modules` first. Anything missing
// from a level lands after the entries listed for it, in file order, so a new
// story shows up at the end of its area rather than disappearing. Stories
// inside a file are never listed, so they keep the order they are declared
// in, `Default` first. Storybook parses this out of the file, so it has to
// stay an inline literal.
options: {
storySort: {
order: [
'Docs',
'Pages',
[
'Home',
'Alerts',
[
'Rules',
'Triggered',
'Overview',
'History',
'Create',
'Edit',
'Planned Downtime',
'Routing Policies',
'Channels',
['List', 'New', 'Edit'],
],
'Dashboards',
['List', 'Detail', 'Panel Editor', 'Public'],
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details'],
'Metrics',
['Explorer'],
'Infrastructure',
[
'Overview',
'Kubernetes',
[
'Clusters',
'Nodes',
'Namespaces',
'Pods',
'Deployments',
'DaemonSets',
'StatefulSets',
'Jobs',
'Volumes',
],
],
'Integrations',
['List', 'Details', 'Cloud Account'],
'Exceptions',
['List', 'Detail'],
'External APIs',
'AI Observability',
['Overview', 'Explorer', 'Model Pricing', 'Attribute Mapping'],
'Noz',
'Metering',
['Cost Meter', 'Usage Explorer'],
'Messaging Queues',
['Overview', 'Kafka', 'Kafka Detail', 'Celery'],
'Onboarding',
['Questionnaire', 'Add Data Source'],
'Settings',
[
'Workspace',
'Account',
'Billing',
['Overview', 'Authz'],
'MCP Server',
'Roles',
'Role Details',
'Role Editor',
'Members',
'Service Accounts',
'Ingestion',
'Single Sign-on',
'Keyboard Shortcuts',
],
'Auth',
['Login', 'Sign Up', 'Forgot Password', 'Reset Password'],
'System',
[
'Status',
'Support',
'License',
'Not Found',
'Unauthorized',
'Error Fallback',
'Workspace Locked',
'Workspace Suspended',
'Workspace Access Restricted',
],
],
],
},
},
docs: { page: PageDocs, container: ThemedDocsContainer },
// One cloud snapshot per theme, for every story. A mode carries Storybook
// globals, so `theme` here is the same toolbar global the app reads out of
// localStorage. Widths are Chromatic's only real dimension, as they are
@@ -74,6 +198,9 @@ const preview: Preview = {
// one it is given.
chromatic: { modes: allModes },
},
// Every page story gets a docs page: the descriptions on the meta and on each
// story are the page's documentation, and without this they render nowhere.
tags: ['autodocs'],
globalTypes: {
theme: {
description: 'SigNoz color scheme',
@@ -119,12 +246,21 @@ const preview: Preview = {
world.apply();
world.install(worker);
await ready;
await Promise.all([ready, translationsReady]);
},
],
beforeEach: () => {
clearBlockedNavigations();
resetStoryHistory();
// The runner clears its console/network buffer before it navigates, so
// anything the outgoing story still has in flight would be reported
// against this one. Stamping the moment this story starts gives the runner
// a line to discard those by. `Date.now()` is faked for the stories, so
// this reads the one clock the runner's own timestamps share.
document.body.dataset.signozStoryStartedAt = String(
performance.timeOrigin + performance.now(),
);
},
// After `play`, which is the moment both capture stacks shoot at.
afterEach: settleForCapture,

View File

@@ -88,10 +88,16 @@ self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// msw bypasses server-sent events here, because it answers a request in one
// piece and has no stream to hand back. A story is not a live connection
// either: it wants the backlog a page renders, and one response carries that
// fine. Left bypassed, `/api/v3/logs/livetail` reaches the real network and
// the live tail story is a spinner over ERR_CONNECTION_REFUSED. Restore the
// bypass and re-check `Pages/Logs/Live Tail` if msw regenerates this file.
//
// if (accept.includes('text/event-stream')) {
// return
// }
// Bypass navigation requests.
if (request.mode === 'navigate') {

View File

@@ -25,7 +25,23 @@ const IGNORED_MESSAGES = [
/violates the following Content Security Policy directive/,
];
const messagesByPage = new WeakMap<Page, string[]>();
interface CapturedMessage {
at: number;
text: string;
}
const messagesByPage = new WeakMap<Page, CapturedMessage[]>();
/**
* When the story under test started rendering, stamped by the preview's
* `beforeEach`. Messages captured before it belong to the previous story: the
* runner clears this buffer ahead of the navigation, so whatever that story
* still had in flight lands here.
*/
const storyStartedAt = (page: Page): Promise<number> =>
page
.evaluate(() => Number(document.body.dataset.signozStoryStartedAt ?? 0))
.catch(() => 0);
/**
* Only `console.error` fails a story. `console.warn` is dev-time advice from
@@ -43,14 +59,14 @@ const config: TestRunnerConfig = {
return;
}
const messages: string[] = [];
const messages: CapturedMessage[] = [];
messagesByPage.set(page, messages);
page.on('console', (message) => {
if (
message.type() === 'error' &&
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
) {
messages.push(`[error] ${message.text()}`);
messages.push({ at: Date.now(), text: `[error] ${message.text()}` });
}
});
// The console message alone ("Failed to load resource") doesn't name the
@@ -58,12 +74,23 @@ const config: TestRunnerConfig = {
// actionable instead of just a status code.
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push(`[response] ${response.status()} ${response.url()}`);
messages.push({
at: Date.now(),
text: `[response] ${response.status()} ${response.url()}`,
});
}
});
},
async postVisit(page, context): Promise<void> {
const messages = messagesByPage.get(page) ?? [];
const captured = messagesByPage.get(page) ?? [];
if (captured.length === 0) {
return;
}
const startedAt = await storyStartedAt(page);
const messages = captured
.filter((message) => message.at >= startedAt)
.map((message) => message.text);
if (messages.length === 0) {
return;
}

View File

@@ -162,6 +162,7 @@
"@jest/globals": "30.4.1",
"@jest/types": "30.2.0",
"@storybook/addon-a11y": "10.5.9",
"@storybook/addon-docs": "10.5.9",
"@storybook/react-vite": "10.5.9",
"@storybook/test-runner": "0.24.5",
"@testing-library/dom": "8.20.0",

View File

@@ -0,0 +1,41 @@
/**
* Rule: no-msw-in-story-file
*
* A `.stories.tsx` file is the human-facing surface: it must not carry msw
* handlers or response payloads. Those belong in the sibling
* `<Page>.stories.mocks.tsx` module (and its `__story_mockdata__` builders).
*
* This rule flags any import from `msw` inside a `*.stories.tsx` file. It
* does not match `*.stories.mocks.tsx`, which is where msw imports belong.
*/
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow importing from msw inside a .stories.tsx file; move handlers/mock data to the sibling .stories.mocks.tsx module',
category: 'Storybook',
},
schema: [],
messages: {
noMsw:
'Do not import from msw in a .stories.tsx file. Move the handler and its mock data to the sibling <Page>.stories.mocks.tsx module (and __story_mockdata__ for builders).',
},
},
create(context) {
const filename = context.filename || '';
if (!filename.endsWith('.stories.tsx')) {
return {};
}
return {
ImportDeclaration(node) {
if (node.source.value === 'msw') {
context.report({ node, messageId: 'noMsw' });
}
},
};
},
};

View File

@@ -15,6 +15,7 @@ import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
import noMswInStoryFile from './rules/no-msw-in-story-file.mjs';
export default {
meta: {
@@ -31,5 +32,6 @@ export default {
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
'no-return-text-nodes': noReturnTextNodes,
'no-msw-in-story-file': noMswInStoryFile,
},
};

View File

@@ -363,6 +363,9 @@ importers:
'@storybook/addon-a11y':
specifier: 10.5.9
version: 10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/addon-docs':
specifier: 10.5.9
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/react-vite':
specifier: 10.5.9
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
@@ -2184,6 +2187,12 @@ packages:
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
'@types/react': '>=16'
react: '>=16'
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
@@ -3766,6 +3775,15 @@ packages:
peerDependencies:
storybook: ^10.5.9
'@storybook/addon-docs@10.5.9':
resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.9
peerDependenciesMeta:
'@types/react':
optional: true
'@storybook/builder-vite@10.5.9':
resolution: {integrity: sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==}
peerDependencies:
@@ -4189,6 +4207,9 @@ packages:
'@types/mdast@4.0.3':
resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==}
'@types/mdx@2.0.14':
resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==}
'@types/ms@0.7.31':
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
@@ -12199,6 +12220,12 @@ snapshots:
'@marijn/find-cluster-break@1.0.2': {}
'@mdx-js/react@3.1.1(@types/react@18.0.26)(react@18.2.0)':
dependencies:
'@types/mdx': 2.0.14
'@types/react': 18.0.26
react: 18.2.0
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
@@ -13619,6 +13646,25 @@ snapshots:
axe-core: 4.13.0
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
'@storybook/addon-docs@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@18.0.26)(react@18.2.0)
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/icons': 2.1.0(react@18.2.0)
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
ts-dedent: 2.3.0
optionalDependencies:
'@types/react': 18.0.26
transitivePeerDependencies:
- '@types/react-dom'
- esbuild
- rollup
- vite
- webpack
'@storybook/builder-vite@10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
dependencies:
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
@@ -14064,6 +14110,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.2
'@types/mdx@2.0.14': {}
'@types/ms@0.7.31': {}
'@types/node@16.18.25': {}

View File

@@ -4,7 +4,6 @@
"GET_STARTED": "SigNoz | Get Started",
"SERVICE_METRICS": "SigNoz | Service Metrics",
"SERVICE_MAP": "SigNoz | Service Map",
"TRACE": "SigNoz | Trace",
"HOME": "SigNoz | Home",
"TRACE_DETAIL": "SigNoz | Trace Detail",
"TRACES_EXPLORER": "SigNoz | Traces Explorer",
@@ -56,7 +55,6 @@
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant",
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",

View File

@@ -14,7 +14,6 @@
"GET_STARTED_AZURE_MONITORING": "SigNoz | Get Started | AZURE",
"GET_STARTED": "SigNoz | Get Started with SigNoz Cloud",
"GET_STARTED_WITH_CLOUD": "SigNoz | Get Started with SigNoz Cloud",
"TRACE": "SigNoz | Trace",
"TRACE_DETAIL": "SigNoz | Trace Detail",
"TRACES_EXPLORER": "SigNoz | Traces Explorer",
"SETTINGS": "SigNoz | Settings",
@@ -79,7 +78,6 @@
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant",
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"ROLE_CREATE": "SigNoz | Create Role",

View File

@@ -15,6 +15,8 @@ export const CONFIG_KEYS = [
'motion',
'settle',
'ignore',
'highlight',
'crop',
];
let tools;

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env node
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import path from 'node:path';
import os from 'node:os';
import {
bodyFont,
@@ -18,7 +17,8 @@ import {
/**
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
* moved, per pair, largest first.
* moved, per pair, largest first. A shot only one run has is reported too,
* labelled with the side it is missing from and counted as changed in full.
*
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
* distance from the baseline pixel is over `threshold` of the largest distance
@@ -55,7 +55,8 @@ if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
--tint <#rrggbb> override the mode's highlight colour
--no-caption do not stamp the story and the run settings on top
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
Prints "<changed pixels> <relative path>", largest first; a shot only one run
has is printed as "(missing previous)" or "(missing current)". Needs ImageMagick.`);
process.exit(opts.help ? 0 : 1);
}
@@ -325,6 +326,11 @@ const shotOf = (run, rel) =>
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
/** What the shot was shot in, falling back to the directory it sits in. */
const themeOf = (rel) =>
(shotOf(afterRun, rel) ?? shotOf(baseRun, rel))?.theme ??
rel.split(path.sep)[0];
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
const withoutCaption = (file, { width, height }, top) =>
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
@@ -369,20 +375,156 @@ const pngs = async (dir, prefix = '') => {
const results = [];
await mkdir(outDir, { recursive: true });
for (const rel of (await pngs(baseDir)).sort()) {
/** The half-built tiles, under the output directory so nothing is left elsewhere. */
const scratch = Object.fromEntries(
['body', 'diff', 'shot', 'missing'].map((name) => [
name,
path.join(outDir, `.story-shots-${process.pid}-${name}.png`),
]),
);
/** One labelled tile of a parallel montage. */
const tile = (label, file, background) => [
'(',
`label:${literal(label)}`,
file,
'-gravity',
'center',
'-append',
'-bordercolor',
background,
'-border',
'12',
')',
];
/** The tiles side by side under one caption. */
const montage = ({
tiles,
width,
background,
foreground,
caption,
target,
theme,
}) => {
magick([
'-background',
background,
'-fill',
foreground,
...bodyFont(),
'-pointsize',
// The tiles end up side by side, so they are read at the montage's width.
String(Math.round(pointsize(width * 3) * 0.62)),
...tiles.flat(),
'-gravity',
'north',
'+append',
caption.length ? scratch.body : target,
]);
if (caption.length) {
stamp({ lines: caption, from: scratch.body, to: target, theme });
}
};
/**
* A tile standing in for a shot the run does not have, sized like the one it
* does. The gutter's colours are the theme's own inverted, so they go back the
* other way here and the tile reads as a shot rather than as a hole.
*/
const placeholder = (
file,
{ width, height },
text,
{ background, foreground },
) =>
magick([
'-size',
`${width}x${height}`,
'-background',
foreground,
'-fill',
background,
'-gravity',
'center',
...bodyFont(),
'-pointsize',
String(pointsize(width * 3)),
`label:${literal(text)}`,
file,
]);
const [baseFiles, afterFiles] = await Promise.all([
pngs(baseDir),
pngs(afterDir),
]);
const inBase = new Set(baseFiles);
const inAfter = new Set(afterFiles);
for (const rel of [...new Set([...baseFiles, ...afterFiles])].sort((a, b) =>
a.localeCompare(b),
)) {
const afterFile = path.join(afterDir, rel);
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
let after;
try {
after = readRgba(afterFile, captionOf(afterRun, rel));
} catch {
console.error(`missing in after: ${rel}`);
const target = path.join(outDir, rel);
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
// A story added, removed or renamed since the baseline has nothing to
// compare against, so the side that does have it is written out under the
// label of the side that does not, and every one of its pixels counts.
if (!inBase.has(rel) || !inAfter.has(rel)) {
const gone = inAfter.has(rel) ? 'previous' : 'current';
const held = gone === 'previous' ? 'current' : 'previous';
const run = gone === 'previous' ? afterRun : baseRun;
const image = readRgba(
gone === 'previous' ? afterFile : path.join(baseDir, rel),
captionOf(run, rel),
);
const theme = themeOf(rel);
const colors = palette(theme);
const caption = captionLines(rel, [`missing ${gone}`]);
if (opts.mode.endsWith('-parallel')) {
// The montage keeps its three tiles: the run that has the shot shows it,
// and the run that does not, like the diff, says so in its place. There
// is nothing to compare, so nothing is tinted.
await writeRgba(image, scratch.shot);
placeholder(scratch.missing, image, `missing ${gone}`, colors);
const sides = {
[held]: tile(sideLabel(held, run), scratch.shot, colors.background),
[gone]: tile(
sideLabel(gone, gone === 'previous' ? baseRun : afterRun),
scratch.missing,
colors.background,
),
};
montage({
tiles: [
sides.previous,
sides.current,
tile('diff', scratch.missing, colors.background),
],
width: image.width,
...colors,
caption,
target,
theme,
});
} else {
await writeRgba(image, caption.length ? scratch.body : target);
if (caption.length) {
stamp({ lines: caption, from: scratch.body, to: target, theme });
}
}
results.push([image.width * image.height, rel, `missing ${gone}`]);
continue;
}
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
const after = readRgba(afterFile, captionOf(afterRun, rel));
const diff = diffPair(base, after, opts.mode);
const target = path.join(outDir, rel);
const parallel = opts.mode.endsWith('-parallel');
// With no tiles to label, a run's own settings go in the caption instead.
const caption = captionLines(
@@ -391,70 +533,54 @@ for (const rel of (await pngs(baseDir)).sort()) {
? []
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
);
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
// The gutter is the opposite of the theme's own background, so the tiles and
// the caption keep an edge instead of bleeding into it.
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
const theme = shot?.theme ?? rel.split(path.sep)[0];
const theme = themeOf(rel);
const { background, foreground } = palette(theme);
if (parallel) {
await writeRgba(diff, diffFile);
const tile = (label, file) => [
'(',
`label:${literal(label)}`,
file,
'-gravity',
'center',
'-append',
'-bordercolor',
await writeRgba(diff, scratch.diff);
montage({
tiles: [
tile(
sideLabel('previous', baseRun),
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
background,
),
tile(
sideLabel('current', afterRun),
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
background,
),
tile('diff', scratch.diff, background),
],
width: after.width,
background,
'-border',
'12',
')',
];
magick([
'-background',
background,
'-fill',
foreground,
...bodyFont(),
'-pointsize',
// The tiles end up side by side, so they are read at the montage's width.
String(Math.round(pointsize(after.width * 3) * 0.62)),
...tile(
sideLabel('previous', baseRun),
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
),
...tile(
sideLabel('current', afterRun),
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
),
...tile('diff', diffFile),
'-gravity',
'north',
'+append',
caption.length ? body : target,
]);
if (caption.length) {
stamp({ lines: caption, from: body, to: target, theme });
}
caption,
target,
theme,
});
} else {
await writeRgba(diff, caption.length ? body : target);
await writeRgba(diff, caption.length ? scratch.body : target);
if (caption.length) {
stamp({ lines: caption, from: body, to: target, theme });
stamp({ lines: caption, from: scratch.body, to: target, theme });
}
}
results.push([diff.changed, rel]);
}
await Promise.all(
Object.values(scratch).map((file) => rm(file, { force: true })),
);
results
.sort((a, b) => b[0] - a[0])
.forEach(([changed, rel]) =>
console.log(`${String(changed).padStart(10)} ${rel}`),
);
.forEach(([changed, rel, note]) => {
const suffix = note ? ` (${note})` : '';
console.log(`${String(changed).padStart(10)} ${rel}${suffix}`);
});
console.error(`diffs in ${outDir}`);

View File

@@ -1,15 +1,19 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { mkdir, rename, writeFile } from 'node:fs/promises';
import { renameSync, rmSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { parseArgs } from 'node:util';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import os from 'node:os';
import {
bodyFont,
CONFIG_KEYS,
hasMagick,
literal,
magick,
palette,
settingsLine,
stamp,
} from './story-shots-caption.mjs';
@@ -39,6 +43,8 @@ const { values: opts, positionals } = parseArgs({
clock: { type: 'string', default: FROZEN_CLOCK },
motion: { type: 'boolean', default: false },
ignore: { type: 'string', multiple: true, default: [] },
highlight: { type: 'string', multiple: true, default: [] },
crop: { type: 'string', multiple: true, default: [] },
flat: { type: 'boolean', default: false },
'no-caption': { type: 'boolean', default: false },
list: { type: 'boolean', default: false },
@@ -72,6 +78,10 @@ if (opts.help || (!outDir && !opts.list)) {
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
--motion keep animations and transitions running
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
--highlight <selector>
also shoot <id>--highlight.png, every match ringed in red
--crop <selector> also write one <id>--<n>.png per match under <theme>/crops,
and montage them into <theme>/crops.png
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
--no-caption do not stamp the story and the run settings on the shot
--list print the matched stories and exit
@@ -135,10 +145,17 @@ if (!stories.length) {
process.exit(1);
}
const ignoreSelectors = opts.ignore
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean);
/** A repeatable, comma-separated flag read as one CSS selector list. */
const selectorList = (values) =>
values
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean)
.join(', ');
const ignoreSelectors = selectorList(opts.ignore);
const highlightSelector = selectorList(opts.highlight);
const cropSelector = selectorList(opts.crop);
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
console.error(`--clock: not a date: ${opts.clock}`);
@@ -152,13 +169,12 @@ if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
* their bottom - is done by the preview itself, so a Chromatic build and a shot
* from here see the same page.
*/
const ignoreCss = (
ignore,
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
.map((selector) => `, ${selector}`)
.join('')} {
const ignoreCss = (ignore) => {
const extra = ignore ? `, ${ignore}` : '';
return `[data-shot-ignore], [data-chromatic='ignore']${extra} {
visibility: hidden !important;
}`;
};
/**
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
@@ -248,7 +264,9 @@ const runConfig = {
grow: opts.grow,
motion: opts.motion ? 'live' : 'still',
settle: opts.settle,
ignore: ignoreSelectors.join(', '),
ignore: ignoreSelectors,
highlight: highlightSelector,
crop: cropSelector,
};
const captioning = !opts['no-caption'] && hasMagick();
@@ -261,6 +279,8 @@ const configLine = settingsLine(runConfig, CONFIG_KEYS);
for (const theme of themes.length ? themes : [null]) {
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
const cropDir = path.join(dir, 'crops');
const crops = [];
await mkdir(dir, { recursive: true });
if (theme) {
console.log(`\n[${theme}]`);
@@ -295,6 +315,9 @@ for (const theme of themes.length ? themes : [null]) {
// the viewport, kept only to flag the story in the log.
let chasing = 0;
// What the story's line in the log says beyond ok/busy.
const notes = [];
const url = new URL(`${base}/iframe.html`);
url.searchParams.set('viewMode', 'story');
url.searchParams.set('id', story.id);
@@ -447,47 +470,160 @@ for (const theme of themes.length ? themes : [null]) {
shot = next;
}
const file = path.join(dir, `${story.id}.png`);
await writeFile(file, shot);
/**
* The band goes on the shot itself so a single screenshot says what it
* is, and its height is returned so a diff can take it back off. The
* temporary is written beside the shot rather than in the system temp
* directory: those are often separate filesystems, and a rename across
* one fails with EXDEV.
*/
const caption = (target, lines) => {
if (!captioning) {
return 0;
}
// The band goes on the shot itself so a single screenshot says what it
// is, and its height is recorded so a diff can take it back off.
let caption = 0;
if (captioning) {
const temporary = path.join(
os.tmpdir(),
`story-shots-caption-${process.pid}.png`,
path.dirname(target),
`.caption-${process.pid}.png`,
);
caption = stamp({
const rows = stamp({
lines: [
`${story.title}/${story.name}`,
[story.id, theme ?? 'default', stable ? '' : '(busy)']
.filter(Boolean)
.join(' '),
configLine,
...lines,
].filter(Boolean),
from: file,
from: target,
to: temporary,
theme: theme ?? 'dark',
});
await rename(temporary, file);
renameSync(temporary, target);
return rows;
};
const file = path.join(dir, `${story.id}.png`);
await writeFile(file, shot);
const record = (relative, caption) =>
shots.push({
file: path.posix.join(opts.flat ? '' : (theme ?? 'default'), relative),
id: story.id,
title: story.title,
name: story.name,
theme: theme ?? 'default',
status: stable ? 'ok' : 'busy',
caption,
});
record(`${story.id}.png`, caption(file, [configLine]));
// The crops are taken before anything is drawn over the page, so a
// component's own shot carries no ring and no label: the montage at the
// end of the theme is what names them.
if (cropSelector) {
await mkdir(cropDir, { recursive: true });
// A page that sizes itself in `vh` was shot back at `--height` with
// its own scrollbar, and what is below the fold there is laid out but
// never painted: cropping it gives a black rectangle. The crops alone
// are taken at the height the rounds had reached, which is where the
// page does paint.
if (chasing) {
await page.setViewportSize({
width: Number(opts.width),
height: chasing,
});
await page.waitForTimeout(Number(opts.settle));
}
const matches = page.locator(cropSelector);
let kept = 0;
for (let index = 0; index < (await matches.count()); index += 1) {
const element = matches.nth(index);
// A group whose children are all conditional renders as a 0x0 box.
// It has no counterpart on screen, so there is nothing to crop.
const box = await element.boundingBox();
if (!box || box.width < 1 || box.height < 1) {
continue;
}
kept += 1;
const relative = `${story.id}--${kept}.png`;
// The scroll that brings an element into view needs a frame before
// the crop, or the region comes back unpainted.
await element.scrollIntoViewIfNeeded({ timeout: 15_000 });
await page.waitForTimeout(250);
await element.screenshot({
path: path.join(cropDir, relative),
timeout: 15_000,
});
crops.push({
file: path.join(cropDir, relative),
label: `${story.title}/${story.name} #${kept}`,
});
record(path.posix.join('crops', relative), 0);
}
notes.push(`${kept} cropped`);
if (chasing) {
await page.setViewportSize({
width: Number(opts.width),
height: Number(opts.height),
});
await page.waitForTimeout(Number(opts.settle));
}
}
shots.push({
file: path.posix.join(
opts.flat ? '' : (theme ?? 'default'),
`${story.id}.png`,
),
id: story.id,
title: story.title,
name: story.name,
theme: theme ?? 'default',
status: stable ? 'ok' : 'busy',
caption,
});
if (highlightSelector) {
const ringed = await page.evaluate(
([selector, padding]) => {
const layer = document.createElement('div');
// The shot is viewport-sized, so the rings are placed in viewport
// coordinates and survive a page that stayed scrollable.
layer.style.cssText =
'position:fixed;inset:0;pointer-events:none;z-index:2147483647';
let drawn = 0;
for (const element of document.querySelectorAll(selector)) {
const box = element.getBoundingClientRect();
if (box.width < 1 || box.height < 1) {
continue;
}
drawn += 1;
const ring = document.createElement('div');
ring.style.cssText = `position:fixed;box-sizing:border-box;border:3px solid #ff003a;border-radius:4px;left:${
box.left - padding
}px;top:${box.top - padding}px;width:${
box.width + padding * 2
}px;height:${box.height + padding * 2}px`;
layer.append(ring);
}
document.documentElement.append(layer);
window.__storyShotsHighlight = layer;
return drawn;
},
[highlightSelector, 6],
);
const highlighted = path.join(dir, `${story.id}--highlight.png`);
await writeFile(highlighted, await page.screenshot());
record(
`${story.id}--highlight.png`,
caption(highlighted, [`${ringed} highlighted`, configLine]),
);
notes.push(`${ringed} highlighted`);
await page.evaluate(() => {
window.__storyShotsHighlight?.remove();
delete window.__storyShotsHighlight;
});
}
if (chasing) {
notes.push(`viewport-sized content, stopped chasing ${chasing}px`);
}
console.log(
` ${stable ? 'ok ' : 'busy'} ${story.id}${
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
notes.length ? ` (${notes.join(', ')})` : ''
}`,
);
} catch (error) {
@@ -497,6 +633,52 @@ for (const theme of themes.length ? themes : [null]) {
await context.close();
}
}
// One image of every crop the theme produced, labelled with the story it came
// from. A component that appears on eight pages is a survey rather than eight
// screenshots to open one after another.
if (crops.length && captioning) {
const sheet = path.join(dir, 'crops.png');
const { background, foreground } = palette(theme ?? 'dark');
// `montage -label` sizes every tile to the widest *image*, so a label
// longer than its crop runs under the next one. Each crop is composed with
// its own label first, which sizes the tile to whichever of the two is
// wider, and the sheet is then a montage of finished tiles.
const tiles = crops.map(({ file, label }, index) => {
const tile = path.join(cropDir, `.tile-${index}.png`);
magick([
'-background',
background,
'-fill',
foreground,
...bodyFont(),
'-pointsize',
'16',
file,
`label:${literal(label)}`,
'-gravity',
'center',
'-append',
tile,
]);
return tile;
});
magick([
'montage',
'-background',
background,
'-tile',
'2x',
'-geometry',
'+16+16',
...tiles,
sheet,
]);
tiles.forEach((tile) => rmSync(tile, { force: true }));
console.log(` ${crops.length} crops -> ${sheet}`);
}
}
await browser.close();

View File

@@ -28,4 +28,4 @@ until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
sleep 1
done
pnpm exec test-storybook --ci --maxWorkers=2 "$@"
pnpm exec test-storybook --ci --maxWorkers=2 --testTimeout 30000 "$@"

View File

@@ -1588,15 +1588,10 @@ describe('PrivateRoute', () => {
deniedRoles: DENIED_ROLES,
},
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
TRACE_DETAIL: {
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
TRACE_DETAIL_OLD: {
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
// route definition comes last, and both keys are authz-aware either way.
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },

View File

@@ -53,17 +53,6 @@ export const TracesFunnelDetails = Loadable(
),
);
export const TraceFilter = Loadable(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailOldRedirect" */ 'pages/TraceDetailOldRedirect/index'
),
);
export const TraceDetailV3 = Loadable(
() =>
import(

View File

@@ -47,9 +47,7 @@ import {
SomethingWentWrong,
StatusPage,
SupportPage,
TraceDetailOldRedirect,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
TracesFunnels,
@@ -132,14 +130,6 @@ const routes: AppRoutes[] = [
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
// Legacy /trace-old/:id redirects to the current /trace/:id view.
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetailOldRedirect,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
@@ -224,13 +214,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACE,
exact: true,
component: TraceFilter,
isPrivate: true,
key: 'TRACE',
},
{
path: ROUTES.TRACES_EXPLORER,
exact: true,

View File

@@ -4144,6 +4144,52 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -4221,6 +4267,11 @@ export interface DashboardtypesThresholdWithLabelDTO {
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
@@ -4233,6 +4284,27 @@ export enum DashboardtypesTimePreferenceDTO {
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -4930,29 +5002,6 @@ export enum DashboardtypesFillModeDTO {
gradient = 'gradient',
none = 'none',
}
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesTimeSeriesChartAppearanceDTO {
fillMode?: DashboardtypesFillModeDTO;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
@@ -5005,6 +5054,18 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBarChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind {
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO {
/**
* @enum signoz/AreaChartPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind;
spec: DashboardtypesAreaChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind {
'signoz/NumberPanel' = 'signoz/NumberPanel',
}
@@ -5210,6 +5271,7 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
@@ -6134,6 +6196,7 @@ export interface DashboardtypesListableDashboardViewDTO {
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
'signoz/NumberPanel' = 'signoz/NumberPanel',
'signoz/PieChartPanel' = 'signoz/PieChartPanel',
'signoz/TablePanel' = 'signoz/TablePanel',

View File

@@ -1,49 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import omitBy from 'lodash-es/omitBy';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getFilters';
const getFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const duration =
omitBy(props.other, (_, key) => !key.startsWith('duration')) || [];
const nonDuration = omitBy(props.other, (_, key) =>
key.startsWith('duration'),
);
const exclude: string[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const response = await axios.post<PayloadProps>(`/getSpanFilters`, {
start: props.start,
end: props.end,
getFilters: props.getFilters,
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getFilters;

View File

@@ -1,62 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import omitBy from 'lodash-es/omitBy';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getSpans';
const getSpans = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const updatedSelectedTags = props.selectedTags.map((e) => ({
Key: `${e.Key}.(string)`,
Operator: e.Operator,
StringValues: e.StringValues,
NumberValues: e.NumberValues,
BoolValues: e.BoolValues,
}));
const exclude: string[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const other = Object.fromEntries(props.selectedFilter);
const duration = omitBy(other, (_, key) => !key.startsWith('duration')) || [];
const nonDuration = omitBy(other, (_, key) => key.startsWith('duration'));
const response = await axios.post<PayloadProps>(
`/getFilteredSpans/aggregates`,
{
start: String(props.start),
end: String(props.end),
function: props.function,
groupBy: props.groupBy === 'none' ? '' : props.groupBy,
step: props.step,
tags: updatedSelectedTags,
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
},
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getSpans;

View File

@@ -1,65 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import omitBy from 'lodash-es/omitBy';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getSpanAggregate';
import { TraceFilterEnum } from 'types/reducer/trace';
const getSpanAggregate = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const preProps = {
start: String(props.start),
end: String(props.end),
limit: props.limit,
offset: props.offset,
order: props.order,
orderParam: props.orderParam,
};
const exclude: TraceFilterEnum[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const updatedSelectedTags = props.selectedTags.map((e) => ({
Key: `${e.Key}.(string)`,
Operator: e.Operator,
StringValues: e.StringValues,
NumberValues: e.NumberValues,
BoolValues: e.BoolValues,
}));
const other = Object.fromEntries(props.selectedFilter);
const duration = omitBy(other, (_, key) => !key.startsWith('duration')) || [];
const nonDuration = omitBy(other, (_, key) => key.startsWith('duration'));
const response = await axios.post<PayloadProps>(`/getFilteredSpans`, {
...preProps,
tags: updatedSelectedTags,
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getSpanAggregate;

View File

@@ -1,49 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { omitBy } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getTagFilters';
import { TraceFilterEnum } from 'types/reducer/trace';
const getTagFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const duration =
omitBy(props.other, (_, key) => !key.startsWith('duration')) || [];
const exclude: TraceFilterEnum[] = [];
props.isFilterExclude.forEach((value, key) => {
if (value) {
exclude.push(key);
}
});
const nonDuration = omitBy(props.other, (_, key) =>
key.startsWith('duration'),
);
const response = await axios.post<PayloadProps>(`/getTagFilters`, {
start: String(props.start),
end: String(props.end),
...nonDuration,
maxDuration: String((duration.duration || [])[0] || ''),
minDuration: String((duration.duration || [])[1] || ''),
exclude,
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getTagFilters;

View File

@@ -1,31 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/trace/getTagValue';
const getTagValue = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post<PayloadProps>(`/getTagValues`, {
start: props.start.toString(),
end: props.end.toString(),
tagKey: {
Key: props.tagKey.Key,
Type: props.tagKey.Type,
},
spanKind: props.spanKind,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getTagValue;

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import dayjs from 'dayjs';
import { screen, userEvent } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import CustomTimePicker from '../CustomTimePicker';
const minTime = dayjs('2025-01-15T11:00:00Z').valueOf() * 1_000_000;
const maxTime = dayjs('2025-01-15T12:00:00Z').valueOf() * 1_000_000;
function TimePickerFixture(): JSX.Element {
const [open, setOpen] = useState(false);
const [selectedTime, setSelectedTime] = useState('1h');
return (
<CustomTimePicker
isModalTimeSelection
items={[
{ label: 'Last 15 minutes', value: '15m' },
{ label: 'Last 1 hour', value: '1h' },
{ label: 'Last 6 hours', value: '6h' },
{ label: 'Custom', value: 'custom' },
]}
maxTime={maxTime}
minTime={minTime}
newPopover
open={open}
onCustomDateHandler={(): void => undefined}
onError={(): void => undefined}
onSelect={(value): void => setSelectedTime(value)}
onValidCustomDateChange={(): void => undefined}
selectedTime={selectedTime}
selectedValue="15 Jan 2025 11:00:00 - 15 Jan 2025 12:00:00"
setOpen={setOpen}
/>
);
}
const meta = {
title: 'Components/Custom Time Picker',
component: TimePickerFixture,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 400 })],
} satisfies Meta<typeof TimePickerFixture>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Interaction: the time-range menu is open with its relative-range choices. */
export const TimeRangeMenuOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(await screen.findByRole('textbox'));
await screen.findByText('RELATIVE TIMES');
},
};
/** Interaction: the timezone menu is reached through the real time-range footer. */
export const TimezoneMenuOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(await screen.findByRole('textbox'));
await userEvent.click(
await screen.findByRole('button', { name: 'Change Timezone' }),
);
await screen.findByPlaceholderText('Search timezones...');
},
};

View File

@@ -0,0 +1,28 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { rest } from 'msw';
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
export const fieldSuggestionsHandlers = [
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
fieldKeysResponse(['service.name', 'body'], {
signal: TelemetrytypesSignalDTO.logs,
}),
),
),
),
];
export const noFieldSuggestionsHandlers = [
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldKeysResponse([]))),
),
];

View File

@@ -0,0 +1,102 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
import {
fieldSuggestionsHandlers,
noFieldSuggestionsHandlers,
} from './FieldsSelector.stories.mocks';
const meta = {
title: 'Components/Fields Selector',
component: FieldsSelector,
tags: ['play'],
args: {
allowCustomFields: true,
defaultPosition: { x: 40, y: 40 },
fields: [
{
fieldContext: 'log',
fieldDataType: 'string',
name: 'timestamp',
signal: 'logs',
},
],
height: 560,
isOpen: true,
onClose: (): void => undefined,
onFieldsChange: (): void => undefined,
signal: DataSource.LOGS,
title: 'Edit log columns',
width: 420,
},
parameters: {
msw: {
handlers: fieldSuggestionsHandlers,
},
},
} satisfies Meta<typeof FieldsSelector>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Open: the draggable field editor shows its selected and available columns. */
export const Open: Story = {};
/** Mutation: adding a suggested field exposes the real unsaved-change footer. */
export const UnsavedChanges: Story = {
play: async (): Promise<void> => {
// One Add per suggested field, so the first row's is the one clicked.
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
await userEvent.click(addField);
await screen.findByRole('button', { name: 'Save changes' });
},
};
/** Empty: the suggestion request succeeds with no columns to add. */
export const NoResults: Story = {
parameters: {
msw: {
handlers: noFieldSuggestionsHandlers,
},
},
};
/** Limit: available columns cannot be added once the configured maximum is reached. */
export const MaximumFields: Story = {
args: {
fields: [
{
fieldContext: 'log',
fieldDataType: 'string',
name: 'timestamp',
signal: 'logs',
},
{
fieldContext: 'log',
fieldDataType: 'string',
name: 'severity_text',
signal: 'logs',
},
],
maxFields: 2,
},
};
/** Required: mandatory fields remain present without removal controls. */
export const RequiredFields: Story = {
args: {
fields: [
{
fieldContext: 'resource',
fieldDataType: 'string',
name: 'service.name',
signal: 'logs',
},
],
requiredFields: ['resource:service.name:string'],
},
};

View File

@@ -0,0 +1,156 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { GlobalMockArgs } from '@/storybook/globals';
import { CustomMultiSelect, CustomSelect } from '../index';
const options = [
{ label: 'Checkout', value: 'checkout' },
{ label: 'Frontend', value: 'frontend' },
{ label: 'Payments', value: 'payments' },
{ label: 'Search', value: 'search' },
];
const longOptions = Array.from({ length: 24 }, (_, index) => ({
label: `Service ${String(index + 1).padStart(2, '0')}`,
value: `service-${index + 1}`,
}));
const meta = {
title: 'Components/New Select',
component: CustomSelect,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 360 })],
args: {
'aria-label': 'Service',
options,
placeholder: 'Select a service',
},
} satisfies Meta<typeof CustomSelect>;
export default meta;
type Story = StoryObj<typeof meta>;
type TooltipsStory = StoryObj<GlobalMockArgs>;
/** Interaction: the body-portal menu is open for stacking and clipping review. */
export const PortalOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByRole('listbox');
},
};
/** Density: a long result list keeps the menu scrollable. */
export const LongResults: Story = {
args: { options: longOptions },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('Service 24');
},
};
/** Empty: the select reports its supported no-data state. */
export const NoResults: Story = {
args: { noDataMessage: 'No services found', options: [] },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('No services found');
},
};
/** Loading: the open menu keeps its in-progress refresh feedback visible. */
export const Loading: Story = {
args: {
loading: true,
options: [],
},
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('Refreshing values...');
},
};
/** Error: a retryable failed request remains visible in the open menu. */
export const Error: Story = {
args: {
errorMessage: 'Could not load services',
onRetry: (): void => undefined,
options: [],
},
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('Could not load services');
},
};
/** Selection: selected and unavailable options are distinguishable before choosing. */
export const SelectedDisabled: Story = {
args: {
options: [
{ label: 'Checkout', value: 'checkout' },
{ disabled: true, label: 'Legacy billing', value: 'legacy-billing' },
{ label: 'Payments', value: 'payments' },
],
value: 'checkout',
},
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByRole('option', { name: 'Legacy billing' });
},
};
/** Overflow: a multi-select preserves its selected values when its trigger is constrained. */
export const MultiValueOverflow: Story = {
render: (): JSX.Element => (
<div style={{ maxWidth: 280 }}>
<CustomMultiSelect
aria-label="Services"
maxTagCount={2}
options={longOptions}
value={['service-1', 'service-2', 'service-3', 'service-4']}
/>
</div>
),
};
const LONG_LABEL_OPTION = {
label:
'checkout-service.production-eu-central-1.svc.cluster.local:8080/v1/orders/{orderId}/payment-authorisation',
value: 'checkout-payment-authorisation',
};
/**
* Every tooltip the select renders, held open: the selected chip revealing the
* option label it was cut from. Nothing bounds that label, so the chip is given
* one long enough to need the reveal.
*/
export const Tooltips: TooltipsStory = {
args: { tooltipsOpen: true },
render: (): JSX.Element => (
<div style={{ maxWidth: 280 }}>
<CustomMultiSelect
aria-label="Services"
maxTagCount={1}
maxTagTextLength={14}
options={[LONG_LABEL_OPTION, ...options]}
value={[LONG_LABEL_OPTION.value]}
/>
</div>
),
};

View File

@@ -0,0 +1,15 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
/**
* The catch-all has no route of its own: it answers for whatever pathname the
* `Switch` ran out of routes for, and it calls nothing.
*/
export const notFoundMocks = defineStoryMocks({
controls: {},
config: () => ({ route: '/no-such-page' }),
});

View File

@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import NotFound from '../index';
import { notFoundMocks } from './NotFound.stories.mocks';
type NotFoundArgs = PageStoryArgs<typeof notFoundMocks>;
/**
* The catch-all route mounts it with no props, and its `defaultProps` is what
* keeps the component itself from typing as one that takes the story's args.
*/
function CatchAllPage(): JSX.Element {
return <NotFound />;
}
const pageStory = storyMocks(notFoundMocks, { layout: 'app' });
/**
* The shell around a pathname no route matched: the side nav stays, the content
* area carries the 404.
*
* Route: any unmatched path.
*/
const meta = {
title: 'Pages/System/Not Found',
component: CatchAllPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<NotFoundArgs>;
export default meta;
type Story = StoryObj<NotFoundArgs>;
/**
* What the app shows for a pathname no route matched, inside the shell: the
* side nav is still there, and the way back is the home button.
*/
export const Default: Story = {};

View File

@@ -6,31 +6,27 @@ import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { getMs } from 'utils/timeUtils';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
import { DurationSection } from 'pages/TracesExplorer/Filter/DurationSection';
import {
AllTraceFilterKeys,
AllTraceFilterKeyValue,
HandleRunProps,
traceFilterKeys,
unionTagFilterItems,
} from 'pages/TracesExplorer/Filter/filterUtils';
} from 'constants/traceFilterKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { clearFilterFromQuery } from '../shared/filterQuery';
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
import { DurationSection } from './DurationSection';
import { FilterType, HandleRunProps, unionTagFilterItems } from './utils';
import './Duration.styles.scss';
export type FilterType = Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>;
export type { FilterType };
function Duration({
filter,

View File

@@ -9,10 +9,12 @@ import {
} from 'react';
import { Input } from 'antd';
import { Slider } from '@signozhq/ui/slider';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { getMs } from 'utils/timeUtils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { addFilter, FilterType, traceFilterKeys } from './filterUtils';
import { traceFilterKeys } from 'constants/traceFilterKeys';
import { addFilter, FilterType } from './utils';
interface DurationProps {
selectedFilters: FilterType | undefined;

View File

@@ -0,0 +1,101 @@
import { Dispatch, SetStateAction } from 'react';
import { AllTraceFilterKeys } from 'constants/traceFilterKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
export type FilterType = Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>;
export interface HandleRunProps {
resetAll?: boolean;
clearByType?: AllTraceFilterKeys;
}
function convertToStringArr(value: string | string[] | undefined): string[] {
if (value) {
if (typeof value === 'string') {
return [value];
}
return value;
}
return [];
}
export const addFilter = (
filterType: AllTraceFilterKeys,
value: string,
setSelectedFilters: Dispatch<
SetStateAction<
| Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>
| undefined
>
>,
keys: BaseAutocompleteData,
): void => {
setSelectedFilters((prevFilters) => {
const isDuration = [
'durationNanoMax',
'durationNanoMin',
'durationNano',
].includes(filterType);
// Convert value to string array
const valueArray = convertToStringArr(value);
// If previous filters are undefined, initialize them
if (!prevFilters) {
return {
[filterType]: { values: isDuration ? value : valueArray, keys },
} as unknown as FilterType;
}
// If the filter type doesn't exist, initialize it
if (!prevFilters[filterType]?.values.length) {
return {
...prevFilters,
[filterType]: { values: isDuration ? value : valueArray, keys },
};
}
// If the value already exists, don't add it again
if (convertToStringArr(prevFilters[filterType].values).includes(value)) {
return prevFilters;
}
// Otherwise, add the value to the existing array
return {
...prevFilters,
[filterType]: {
values: isDuration
? value
: [...convertToStringArr(prevFilters[filterType].values), value],
keys,
},
};
});
};
/** Merges two filter lists; later items win on the same key + operator. */
export function unionTagFilterItems(
items1: TagFilterItem[],
items2: TagFilterItem[],
): TagFilterItem[] {
const unionMap = new Map<string, TagFilterItem>();
items1?.forEach((item) => {
const keyOp = `${item?.key?.key}_${item?.op}`;
unionMap.set(keyOp, item);
});
items2?.forEach((item) => {
const keyOp = `${item?.key?.key}_${item?.op}`;
unionMap.set(keyOp, item);
});
return Array.from(unionMap?.values());
}

View File

@@ -0,0 +1,126 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
QuickfiltertypesSourceDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, type RequestHandler } from 'msw';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { attributeValuesResponse } from '@/storybook/msw/__story_mockdata__/attributes';
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
import { FiltersType } from '../types';
const customFilters = [
{
name: 'service.name',
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
{
name: 'deployment.environment',
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
];
export const queryBuilder = {
currentQuery: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [], op: 'AND' },
queryName: 'Logs query',
},
],
},
},
lastUsedQuery: 0,
panelType: 'graph',
redirectWithQueryBuilderData: (): void => undefined,
setLastUsedQuery: (): void => undefined,
};
export const checkboxConfig = [
{
attributeKey: {
dataType: DataTypes.String,
key: 'service.name',
type: 'resource',
},
defaultOpen: true,
title: 'Service name',
type: FiltersType.CHECKBOX,
},
];
export const attributeValuesHandler = (
values: readonly string[],
): RequestHandler =>
rest.get(
'http://localhost/api/v3/autocomplete/attribute_values',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(attributeValuesResponse(values))),
);
export const handlers = [
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
quickFiltersResponse(QuickfiltertypesSourceDTO.logs, customFilters),
),
),
),
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldKeysResponse(['k8s.namespace.name']))),
),
attributeValuesHandler(['checkout', 'frontend', 'payments']),
];
export const loadingFiltersHandlers = [
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
res(ctx.delay('infinite')),
),
];
export const LONG_FILTER_VALUES = [
'checkout-service.production-eu-central-1.svc.cluster.local',
'payments-authorisation-worker.production-us-east-2.svc.cluster.local',
'catalog-availability-projector.staging-ap-south-1.svc.cluster.local',
];
export const selectedServiceQueryBuilder = {
...queryBuilder,
currentQuery: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: {
items: [
{
key: {
dataType: DataTypes.String,
key: 'service.name',
type: 'resource',
},
op: 'in',
value: ['checkout', 'payments'],
},
],
op: 'AND',
},
queryName: 'Logs query',
},
],
},
},
};

View File

@@ -0,0 +1,119 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ComponentProps, ComponentType } from 'react';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { GlobalMockArgs } from '@/storybook/globals';
import QuickFilters from '../QuickFilters';
import {
attributeValuesHandler,
checkboxConfig,
handlers,
LONG_FILTER_VALUES,
loadingFiltersHandlers,
queryBuilder,
selectedServiceQueryBuilder,
} from './QuickFilters.stories.mocks';
import { QuickFiltersSource, SignalType } from '../types';
const meta = {
title: 'Components/Quick Filters',
// `QuickFilters.defaultProps` declares `onFilterChange: null` against a prop
// typed as an optional function, so the component does not satisfy
// `ComponentType` as written. The defaults are load-bearing for the jest
// suite, hence the cast rather than a change to them.
component: QuickFilters as unknown as ComponentType<
ComponentProps<typeof QuickFilters>
>,
tags: ['play'],
// The rail the explorers give it (`Explorer.styles.scss`, `.filter`).
decorators: [withCanvas({ width: 260 })],
args: {
config: checkboxConfig,
handleFilterVisibilityChange: (): void => undefined,
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
},
parameters: {
msw: { handlers },
signoz: { queryBuilder },
},
} satisfies Meta<typeof QuickFilters>;
export default meta;
type Story = StoryObj<typeof meta>;
type TooltipsStory = StoryObj<
ComponentProps<typeof QuickFilters> & GlobalMockArgs
>;
/**
* The settings control renders disabled while its permission check is in
* flight and is swapped for the enabled one once the check answers, so it is
* looked up again on every attempt; a click on the disabled one is dropped in
* silence.
*/
const settingsControl = (canvasElement: HTMLElement): Promise<HTMLElement> =>
waitFor(() => {
const control = within(canvasElement).getByTestId('settings-icon-container');
expect(control).toBeEnabled();
return control;
});
/** Interaction: the settings panel is opened through the admin settings control. */
export const SettingsOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(await settingsControl(canvasElement));
await screen.findByText('Edit quick filters');
},
};
/** Mutation: changing the settings list reveals the fixed save and discard footer. */
export const SettingsDirtyFooter: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(await settingsControl(canvasElement));
await userEvent.click(await screen.findByRole('button', { name: 'Add' }));
await screen.findByRole('button', { name: 'Save changes' });
},
};
/** Loading: dynamic filters are intentionally left pending to display the panel skeleton. */
export const LoadingFilters: Story = {
parameters: {
msw: {
handlers: loadingFiltersHandlers,
},
},
};
/** Empty: a loaded quick-filter configuration with no filters has no result rows. */
export const NoResults: Story = {
args: { config: [], signal: undefined },
};
/** Selection: an expanded checkbox shows the actual selected service values. */
export const SelectedExpandedCheckbox: Story = {
args: { signal: undefined },
parameters: {
signoz: {
queryBuilder: selectedServiceQueryBuilder,
},
},
};
/**
* Every tooltip the panel renders, held open: the reveal on each truncated
* filter value. Nothing bounds those values, so the panel is answered with
* service names long enough to be cut. The Service name filter carries them, so
* the signal that would add the workspace's own dynamic filters is left off.
*/
export const Tooltips: TooltipsStory = {
args: { signal: undefined, tooltipsOpen: true },
parameters: {
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
},
};

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '@signozhq/ui/button';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { CustomSelect } from '../../NewSelect';
import SignozModal from '../SignozModal';
function ModalFixture(): JSX.Element {
const [open, setOpen] = useState(false);
return (
<>
<Button data-testid="open-signoz-modal" onClick={(): void => setOpen(true)}>
Open modal
</Button>
<SignozModal
footer={null}
open={open}
onCancel={(): void => setOpen(false)}
title="Create saved view"
>
<CustomSelect
aria-label="View scope"
options={[
{ label: 'This workspace', value: 'workspace' },
{ label: 'My views', value: 'personal' },
]}
placeholder="Select a scope"
/>
</SignozModal>
</>
);
}
const meta = {
title: 'Components/Signoz Modal',
component: ModalFixture,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 400 })],
} satisfies Meta<typeof ModalFixture>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Interaction: the modal and its nested body-portal select are both genuinely open. */
export const OpenWithNestedSelect: Story = {
play: async ({ canvasElement }): Promise<void> => {
const trigger = within(canvasElement).getByTestId('open-signoz-modal');
await userEvent.click(trigger);
await screen.findByRole('dialog', { name: 'Create saved view' });
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(trigger).toHaveFocus());
await userEvent.click(trigger);
await userEvent.click(
await screen.findByRole('combobox', { name: 'View scope' }),
);
await screen.findByRole('listbox');
},
};

View File

@@ -71,7 +71,7 @@ interface ITableConfig {
instance: Virtualizer<HTMLDivElement, Element>,
) => void;
}
interface ITableV3Props<T> {
export interface ITableV3Props<T> {
columns: ColumnDef<T, any>[];
data: T[];
config: ITableConfig;
@@ -201,5 +201,5 @@ export function TableV3<T>(props: ITableV3Props<T>): JSX.Element {
TableV3.defaultProps = {
customClassName: '',
virtualiserRef: null,
virtualiserRef: undefined,
};

View File

@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ColumnDef } from '@tanstack/react-table';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { ITableV3Props } from '../TableV3';
import { TableV3 } from '../TableV3';
type TraceRow = {
id: string;
traceId: string;
service: string;
duration: string;
status: string;
};
const columns: ColumnDef<TraceRow>[] = [
{ accessorKey: 'traceId', header: 'Trace ID', size: 280 },
{ accessorKey: 'service', header: 'Service', size: 220 },
{ accessorKey: 'duration', header: 'Duration', size: 140 },
{ accessorKey: 'status', header: 'Status', size: 160 },
];
const rows: TraceRow[] = Array.from({ length: 40 }, (_, index) => ({
id: `trace-${index + 1}`,
traceId: `c0ffee${String(index + 1).padStart(10, '0')}7f4a9d1c`,
service: index % 2 === 0 ? 'checkout-service' : 'catalog-service',
duration: `${80 + index * 6} ms`,
status: index % 5 === 0 ? 'Error' : 'OK',
}));
const meta = {
title: 'Components/Table V3',
component: TableV3,
decorators: [withCanvas({ height: 360, maxWidth: 640, overflow: 'auto' })],
args: {
columns,
config: { defaultColumnMinSize: 120, defaultColumnMaxSize: 400 },
data: rows,
setColumnWidths: (): void => undefined,
},
} satisfies Meta<ITableV3Props<TraceRow>>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Density and overflow: the virtualized table's wide, resizable column layout. */
export const WideVirtualizedDataset: Story = {};
/** Data: the table's native no-row layout, with headers retained for structural review. */
export const EmptyDataset: Story = {
args: { data: [] },
};

View File

@@ -0,0 +1,239 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Color } from '@signozhq/design-tokens';
import { Ellipsis } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { GroupedStatusCounts } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
import type { StatusCountItem } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8sV2/components/ValidateColumnValueWrapper';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { expect, screen, userEvent, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { GlobalMockArgs } from '@/storybook/globals';
import TanStackTable from '../index';
import type { TableColumnDef, TanStackTableProps } from '../types';
type ServiceRow = {
id: string;
service: string;
endpoint: string;
latency: string;
owner: string;
};
const rows: ServiceRow[] = [
{
id: 'checkout',
service: 'checkout-service',
endpoint: 'POST /api/v1/checkout',
latency: '184 ms',
owner: 'Payments platform',
},
{
id: 'catalog',
service: 'catalog-service',
endpoint: 'GET /api/v2/products/{productId}/availability',
latency: '96 ms',
owner: 'Storefront experience',
},
{
id: 'identity',
service: 'identity-service',
endpoint: 'POST /api/v1/session/refresh',
latency: '242 ms',
owner: 'Identity and access management',
},
];
const columns: TableColumnDef<ServiceRow>[] = [
{
id: 'service',
header: 'Service',
accessorKey: 'service',
pin: 'left',
width: { fixed: 180 },
enableSort: true,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
{
id: 'endpoint',
header: 'Endpoint',
accessorKey: 'endpoint',
width: { fixed: 320 },
cell: ({ value }): JSX.Element => (
<TanStackTable.Text title={String(value)}>
{String(value)}
</TanStackTable.Text>
),
},
{
id: 'latency',
header: 'P95 latency',
accessorKey: 'latency',
width: { fixed: 140 },
enableSort: true,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
{
id: 'owner',
header: 'Owner',
accessorKey: 'owner',
width: { fixed: 240 },
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
];
const rowActions = (): JSX.Element => (
<DropdownMenuSimple
align="end"
menu={{
items: [
{ key: 'open', label: 'Open service details' },
{ key: 'copy', label: 'Copy service link' },
],
}}
>
<Button
aria-label="Service actions"
color="secondary"
size="icon"
variant="outlined"
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
);
const meta = {
title: 'Components/TanStack Table View',
component: TanStackTable,
tags: ['play'],
decorators: [withCanvas({ height: 360, maxWidth: 640 })],
args: {
columns,
data: rows,
disableVirtualScroll: true,
getRowKey: (row): string => row.id,
},
} satisfies Meta<TanStackTableProps<ServiceRow>>;
export default meta;
type Story = StoryObj<typeof meta>;
type TooltipsStory = StoryObj<GlobalMockArgs>;
/** Data and overflow: pinned columns, clipped long cells, and a horizontal scroll surface. */
export const HorizontalOverflow: Story = {
args: { testId: 'tanstack-table' },
};
/** Data: a page-sized result with the shared pagination controls and total count. */
export const Pagination: Story = {
args: {
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
},
};
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
export const Empty: Story = {
args: { data: [], testId: 'tanstack-empty-table' },
};
/** Data: the table's real skeleton rows shown while the first page is loading. */
export const Loading: Story = {
args: { data: [], isLoading: true, skeletonRowCount: 5 },
};
/** Interaction: opens a row action menu rendered through the shared portal. */
export const RowActionsMenu: Story = {
args: { renderRowActions: rowActions, testId: 'tanstack-actions-table' },
play: async ({ canvasElement }): Promise<void> => {
const firstRow = within(canvasElement).getByTestId('tanstack-actions-table');
await userEvent.hover(firstRow.querySelector('tbody tr') as HTMLElement);
await userEvent.click(
await within(firstRow).findByLabelText('Service actions'),
);
await expect(
await screen.findByRole('menuitem', { name: 'Open service details' }),
).toBeVisible();
},
};
const RESTART_COUNTS: StatusCountItem[] = [
{
label: 'Restarts in the last 24 hours',
value: 37,
color: Color.BG_CHERRY_500,
breakdown: [
{ label: 'CrashLoopBackOff', value: 14 },
{ label: 'OOMKilled', value: 11 },
{ label: 'Liveness probe failed', value: 6 },
{ label: 'Readiness probe failed', value: 4 },
{ label: 'Image pull backoff', value: 2 },
],
},
];
const tooltipColumns: TableColumnDef<ServiceRow>[] = [
columns[0],
{
id: 'cpuRequest',
header: 'CPU request',
width: { fixed: 140 },
cell: ({ rowId }): JSX.Element => (
<ValidateColumnValueWrapper
attribute="CPU request"
entity={InfraMonitoringEntity.PODS}
rowId={rowId}
value={-1}
>
<TanStackTable.Text>0.5</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'restarts',
header: 'Restarts',
width: { fixed: 140 },
cell: ({ rowId }): JSX.Element => (
<GroupedStatusCounts items={RESTART_COUNTS} rowId={rowId} />
),
},
];
/**
* Both tooltips a hovered row carries, held open: the plain sentence explaining
* a missing value, and the status breakdown, which is elements rather than text
* and grows a row per reason. Neither is rendered until the row is hovered, so
* the play hovers the first one and the control holds what it uncovered.
*/
export const Tooltips: TooltipsStory = {
args: { tooltipsOpen: true },
render: (): JSX.Element => (
<TanStackTable
columns={tooltipColumns}
data={rows}
disableVirtualScroll
getRowKey={(row): string => row.id}
testId="tanstack-tooltips-table"
/>
),
play: async ({ canvasElement }): Promise<void> => {
const table = within(canvasElement).getByTestId('tanstack-tooltips-table');
await userEvent.hover(table.querySelector('tbody tr') as HTMLElement);
// Both tooltips are rendered by the hovered row, so this is what says the
// control has something to hold open.
await screen.findByText('Restarts in the last 24 hours');
},
};

View File

@@ -41,7 +41,6 @@ export enum LOCALSTORAGE {
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',

View File

@@ -29,6 +29,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.LIST]:
dataSource === DataSource.LOGS ? LogsPanelComponent : TracesTableComponent,
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.AREA]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.

View File

@@ -336,6 +336,7 @@ export enum PANEL_TYPES {
LIST = 'list',
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',

View File

@@ -1,4 +1,4 @@
import { OperatorValues } from 'types/reducer/trace';
import { OperatorValues } from 'hooks/useResourceAttribute/types';
export const OperatorConversions: Array<{
label: string;

View File

@@ -6,9 +6,8 @@ const ROUTES = {
SERVICE_METRICS: '/services/:servicename',
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',
SERVICE_MAP: '/service-map',
TRACE: '/trace',
TRACE_BASE: '/trace',
TRACE_DETAIL: '/trace/:id',
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',

View File

@@ -0,0 +1,124 @@
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
/** Trace attribute key -> label shown in quick filters, the options menu and APM "view traces" links. */
export const AllTraceFilterKeyValue: Record<string, string> = {
durationNanoMin: 'Duration',
durationNano: 'Duration',
duration_nano: 'Duration',
durationNanoMax: 'Duration',
'deployment.environment': 'Environment',
hasError: 'Status',
has_error: 'Status',
serviceName: 'Service Name',
'service.name': 'service.name',
name: 'Operation / Name',
rpcMethod: 'RPC Method',
'rpc.method': 'RPC Method',
responseStatusCode: 'Status Code',
response_status_code: 'Status Code',
httpHost: 'HTTP Host',
http_host: 'HTTP Host',
httpMethod: 'HTTP Method',
http_method: 'HTTP Method',
httpRoute: 'HTTP Route',
'http.route': 'HTTP Route',
httpUrl: 'HTTP URL',
[SPAN_ATTRIBUTES.HTTP_URL]: 'HTTP URL',
traceID: 'Trace ID',
trace_id: 'Trace ID',
} as const;
export type AllTraceFilterKeys = keyof typeof AllTraceFilterKeyValue;
export const traceFilterKeys: Record<AllTraceFilterKeys, BaseAutocompleteData> =
{
durationNano: {
key: 'durationNano',
dataType: DataTypes.Float64,
type: 'tag',
id: 'durationNano--float64--tag--true',
},
hasError: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
id: 'hasError--bool--tag--true',
},
serviceName: {
key: 'serviceName',
dataType: DataTypes.String,
type: 'tag',
id: 'serviceName--string--tag--true',
},
'deployment.environment': {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
id: 'deployment.environment--string--resource--false',
},
name: {
key: 'name',
dataType: DataTypes.String,
type: 'tag',
id: 'name--string--tag--true',
},
rpcMethod: {
key: 'rpcMethod',
dataType: DataTypes.String,
type: 'tag',
id: 'rpcMethod--string--tag--true',
},
responseStatusCode: {
key: 'responseStatusCode',
dataType: DataTypes.String,
type: 'tag',
id: 'responseStatusCode--string--tag--true',
},
httpHost: {
key: 'httpHost',
dataType: DataTypes.String,
type: 'tag',
id: 'httpHost--string--tag--true',
},
httpMethod: {
key: 'httpMethod',
dataType: DataTypes.String,
type: 'tag',
id: 'httpMethod--string--tag--true',
},
httpRoute: {
key: 'httpRoute',
dataType: DataTypes.String,
type: 'tag',
id: 'httpRoute--string--tag--true',
},
httpUrl: {
key: 'httpUrl',
dataType: DataTypes.String,
type: 'tag',
id: 'httpUrl--string--tag--true',
},
traceID: {
key: 'traceID',
dataType: DataTypes.String,
type: 'tag',
id: 'traceID--string--tag--true',
},
durationNanoMin: {
key: 'durationNanoMin',
dataType: DataTypes.Float64,
type: 'tag',
id: 'durationNanoMin--float64--tag--true',
},
durationNanoMax: {
key: 'durationNanoMax',
dataType: DataTypes.Float64,
type: 'tag',
id: 'durationNanoMax--float64--tag--true',
},
} as const;

View File

@@ -450,6 +450,12 @@ export default function ChatInput({
return;
}
el.style.height = 'auto';
// A hidden composer (a closed drawer, a story swapping in) measures 0.
// Leaving the height on `auto` keeps the `rows` fallback until there is
// something real to measure, instead of pinning the field shut.
if (el.scrollHeight === 0) {
return;
}
el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_HEIGHT_PX)}px`;
}, [text]);

View File

@@ -207,7 +207,12 @@ export default function CustomDomainSettings(): JSX.Element {
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="link" color="none" disabled={isFetchingHosts}>
<Button
variant="link"
color="none"
data-testid="custom-domain-menu-trigger"
disabled={isFetchingHosts}
>
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />

View File

@@ -71,6 +71,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
<DropdownMenuSimple menu={menu}>
<Button
className="download-button"
data-testid="download-menu-trigger"
loading={isLoading || isDownloading}
size="small"
type="link"

View File

@@ -1,16 +0,0 @@
.span-container {
.spanDetails {
position: absolute;
height: 50px;
padding: 8px;
min-width: 150px;
background: lightcyan;
color: black;
bottom: 24px;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}
}

View File

@@ -1,96 +0,0 @@
import { useEffect } from 'react';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { toFixed } from 'utils/toFixed';
import { SpanBorder, SpanLine, SpanText, SpanWrapper } from './styles';
import '../GantChart.styles.scss';
interface SpanLengthProps {
globalStart: number;
startTime: number;
name: string;
width: string;
leftOffset: string;
bgColor: string;
inMsCount: number;
}
function Span(props: SpanLengthProps): JSX.Element {
const { width, leftOffset, bgColor, inMsCount, startTime, name, globalStart } =
props;
const isDarkMode = useIsDarkMode();
const { time, timeUnitName } = convertTimeToRelevantUnit(inMsCount);
const { timezone } = useTimezone();
useEffect(() => {
document.documentElement.scrollTop = document.documentElement.clientHeight;
document.documentElement.scrollLeft = document.documentElement.clientWidth;
}, []);
const getContent = (): JSX.Element => {
const timeStamp = dayjs(startTime)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.TIME_UTC_MS);
const startTimeInMs = startTime - globalStart;
return (
<div>
<Typography.Text style={{ marginBottom: '8px' }}>
{' '}
Duration : {inMsCount}
</Typography.Text>
<br />
<Typography.Text style={{ marginBottom: '8px' }}>
Start Time: {startTimeInMs}ms [{timeStamp}]{' '}
</Typography.Text>
</div>
);
};
return (
<SpanWrapper className="span-container">
<SpanLine
className="spanLine"
isDarkMode={isDarkMode}
bgColor={bgColor}
leftOffset={leftOffset}
width={width}
/>
<div>
<Popover
style={{
left: `${leftOffset}%`,
}}
title={name}
content={getContent()}
trigger="hover"
placement="left"
autoAdjustOverflow
>
<SpanBorder
className="spanTrack"
isDarkMode={isDarkMode}
bgColor={bgColor}
leftOffset={leftOffset}
width={width}
/>
</Popover>
</div>
<SpanText isDarkMode={isDarkMode} leftOffset={leftOffset}>{`${toFixed(
time,
2,
)} ${timeUnitName}`}</SpanText>
</SpanWrapper>
);
}
export default Span;

View File

@@ -1,52 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
interface Props {
width: string;
leftOffset: string;
bgColor: string;
isDarkMode: boolean;
}
export const SpanLine = styled.div<Props>`
width: ${({ leftOffset }): string => `${leftOffset}%`};
height: 0px;
border-bottom: 0.1px solid
${({ isDarkMode }): string => (isDarkMode ? '#303030' : '#c0c0c0')};
top: 50%;
position: absolute;
`;
export const SpanBorder = styled.div<Props>`
background: ${({ bgColor }): string => bgColor};
border-radius: 5px;
height: 0.625rem;
width: ${({ width }): string => `${width}%`};
left: ${({ leftOffset }): string => `${leftOffset}%`};
top: 35%;
position: absolute;
`;
export const SpanWrapper = styled.div`
display: flex;
width: 100%;
flex-direction: row;
align-items: center;
position: relative;
z-index: 2;
min-height: 2rem;
`;
interface SpanTextProps extends Pick<Props, 'leftOffset'> {
isDarkMode: boolean;
}
export const SpanText = styled(Typography.Text)<SpanTextProps>`
&&& {
left: ${({ leftOffset }): string => `${leftOffset}%`};
top: 65%;
position: absolute;
width: max-content;
color: ${({ isDarkMode }): string => (isDarkMode ? '#ACACAC' : '#666')};
font-size: 0.75rem;
}
`;

View File

@@ -1,22 +0,0 @@
import { Container, Service, Span, SpanWrapper } from './styles';
function SpanNameComponent({
name,
serviceName,
}: SpanNameComponentProps): JSX.Element {
return (
<Container title={`${name} ${serviceName}`}>
<SpanWrapper>
<Span truncate={1}>{name}</Span>
<Service truncate={1}>{serviceName}</Service>
</SpanWrapper>
</Container>
);
}
interface SpanNameComponentProps {
name: string;
serviceName: string;
}
export default SpanNameComponent;

View File

@@ -1,41 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const Span = styled(Typography.Text)`
&&& {
font-size: 0.75rem;
margin: 0;
/* border-bottom: 1px solid grey; */
}
`;
export const Service = styled(Typography.Text)`
&&& {
color: #acacac;
font-size: 0.75rem;
}
`;
export const SpanWrapper = styled.div`
display: flex;
flex-direction: column;
margin-left: 0.625rem;
width: 10rem;
`;
export const SpanConnector = styled.div`
width: 37px;
border: 1px solid #303030;
height: 0;
`;
export const Container = styled.div`
display: flex;
align-items: center;
justify-content: flex-start;
`;
export const SpanName = styled.div`
width: fit-content;
border-bottom: 1px solid black;
`;

View File

@@ -1,233 +0,0 @@
import {
Dispatch,
MouseEventHandler,
SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Col } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { StyledCol, StyledRow } from 'components/Styled';
import {
IIntervalUnit,
SPAN_DETAILS_LEFT_COL_WIDTH,
} from 'container/TraceDetail/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ITraceTree } from 'types/api/trace/getTraceItem';
import { ITraceMetaData } from '..';
import Span from '../Span';
import SpanName from '../SpanName';
import { getMetaDataFromSpanTree, getTopLeftFromBody } from '../utils';
import {
CardComponent,
CardContainer,
CaretContainer,
HoverCard,
styles,
Wrapper,
} from './styles';
import { getIconStyles } from './utils';
function Trace(props: TraceProps): JSX.Element {
const {
name,
activeHoverId,
setActiveHoverId,
globalSpread,
globalStart,
serviceName,
startTime,
value,
serviceColour,
id,
setActiveSelectedId,
activeSelectedId,
level,
activeSpanPath,
isExpandAll,
intervalUnit,
children,
isMissing,
} = props;
const isDarkMode = useIsDarkMode();
const [isOpen, setOpen] = useState<boolean>(activeSpanPath[level] === id);
const localTreeExpandInteraction = useRef<boolean | 0>(0); // Boolean is for the state of the expansion whereas the number i.e. 0 is for skipping the user interaction.
useEffect(() => {
if (localTreeExpandInteraction.current !== 0) {
setOpen(localTreeExpandInteraction.current);
localTreeExpandInteraction.current = 0;
} else if (!isOpen) {
setOpen(activeSpanPath[level] === id);
}
}, [activeSpanPath, isOpen, id, level]);
useEffect(() => {
if (isExpandAll) {
setOpen(isExpandAll);
} else {
setOpen(activeSpanPath[level] === id);
}
}, [isExpandAll, activeSpanPath, id, level]);
const isOnlyChild = children.length === 1;
const [top, setTop] = useState<number>(0);
const ref = useRef<HTMLUListElement>(null);
useEffect(() => {
if (activeSelectedId === id) {
ref.current?.scrollIntoView({
block: 'nearest',
behavior: 'auto',
inline: 'nearest',
});
}
}, [activeSelectedId, id]);
const onMouseEnterHandler = (): void => {
setActiveHoverId(id);
if (ref.current) {
const { top } = getTopLeftFromBody(ref.current);
setTop(top);
}
};
const onMouseLeaveHandler = (): void => {
setActiveHoverId('');
};
const onClick = (): void => {
setActiveSelectedId(id);
};
const onClickTreeExpansion: MouseEventHandler<HTMLDivElement> = (
event,
): void => {
event.stopPropagation();
setOpen((state) => {
localTreeExpandInteraction.current = !isOpen;
return !state;
});
};
const { totalSpans } = getMetaDataFromSpanTree(props);
const inMsCount = value;
const nodeLeftOffset = ((startTime - globalStart) * 1e2) / globalSpread;
const width = (value * 1e2) / (globalSpread * 1e6);
const panelWidth = SPAN_DETAILS_LEFT_COL_WIDTH - level * (16 + 1) - 48;
const iconStyles = useMemo(() => getIconStyles(), []);
const icon = useMemo(
() =>
isOpen ? (
<ChevronDown size="md" style={iconStyles} />
) : (
<ChevronRight size="md" style={iconStyles} />
),
[isOpen, iconStyles],
);
return (
<Wrapper
onMouseEnter={onMouseEnterHandler}
onMouseLeave={onMouseLeaveHandler}
isOnlyChild={isOnlyChild}
ref={ref}
isDarkMode={isDarkMode}
>
<HoverCard
top={top}
isHovered={activeHoverId === id}
isSelected={activeSelectedId === id}
isDarkMode={isDarkMode}
/>
<CardContainer isMissing={isMissing} onClick={onClick}>
<StyledCol flex={`${panelWidth}px`} styledclass={[styles.overFlowHidden]}>
<StyledRow styledclass={[styles.flexNoWrap]}>
<Col>
{totalSpans !== 1 && (
<CardComponent
isOnlyChild={isOnlyChild}
isDarkMode={isDarkMode}
onClick={onClickTreeExpansion}
>
<Typography style={{ wordBreak: 'normal' }}>{totalSpans}</Typography>
<CaretContainer>{icon}</CaretContainer>
</CardComponent>
)}
</Col>
<Col>
<SpanName name={name} serviceName={serviceName} />
</Col>
</StyledRow>
</StyledCol>
<Col flex="1">
<Span
globalStart={globalStart}
startTime={startTime}
name={name}
leftOffset={nodeLeftOffset.toString()}
width={width.toString()}
bgColor={serviceColour}
inMsCount={inMsCount / 1e6}
/>
</Col>
</CardContainer>
{isOpen && (
<>
{children.map((child) => (
<Trace
key={child.id}
activeHoverId={activeHoverId}
setActiveHoverId={setActiveHoverId}
{...child}
globalSpread={globalSpread}
globalStart={globalStart}
setActiveSelectedId={setActiveSelectedId}
activeSelectedId={activeSelectedId}
level={level + 1}
activeSpanPath={activeSpanPath}
isExpandAll={isExpandAll}
intervalUnit={intervalUnit}
isMissing={child.isMissing}
/>
))}
</>
)}
</Wrapper>
);
}
Trace.defaultProps = {
isMissing: false,
};
interface ITraceGlobal {
globalSpread: ITraceMetaData['spread'];
globalStart: ITraceMetaData['globalStart'];
}
interface TraceProps extends ITraceTree, ITraceGlobal {
activeHoverId: string;
setActiveHoverId: Dispatch<SetStateAction<string>>;
setActiveSelectedId: Dispatch<SetStateAction<string>>;
activeSelectedId: string;
level: number;
activeSpanPath: string[];
isExpandAll: boolean;
intervalUnit: IIntervalUnit;
isMissing?: boolean;
}
export default Trace;

View File

@@ -1,113 +0,0 @@
import { volcano } from '@ant-design/colors';
import styled, {
css,
DefaultTheme,
ThemedCssFunction,
} from 'styled-components';
interface Props {
isOnlyChild: boolean;
}
export const Wrapper = styled.ul<Props>`
display: flex;
flex-direction: column;
padding-bottom: 0.5rem;
padding-top: 0.5rem;
position: relative;
z-index: 1;
ul {
border-left: ${({ isOnlyChild }): StyledCSS =>
isOnlyChild && 'none'} !important;
${({ isOnlyChild }): StyledCSS =>
isOnlyChild &&
css`
&:before {
border-left: 1px solid #434343;
display: inline-block;
content: '';
height: 54px;
position: absolute;
left: 0;
top: -35px;
}
`}
}
`;
export const CardContainer = styled.li<{ isMissing?: boolean }>`
display: flex;
width: 100%;
cursor: pointer;
border-radius: 0.25rem;
z-index: 2;
${({ isMissing }): string =>
isMissing ? `border: 1px dashed ${volcano[6]} !important;` : ''}
`;
interface Props {
isDarkMode: boolean;
}
export type StyledCSS =
| ReturnType<ThemedCssFunction<DefaultTheme>>
| string
| false
| undefined;
export const CardComponent = styled.div<Props>`
border: 1px solid
${({ isDarkMode }): StyledCSS => (isDarkMode ? '#434343' : '#333')};
box-sizing: border-box;
border-radius: 2px;
display: flex;
justify-content: center;
align-items: center;
padding: 1px 8px;
background: ${({ isDarkMode }): StyledCSS =>
isDarkMode ? '#1d1d1d' : '#ddd'};
height: 22px;
`;
export const CaretContainer = styled.span`
margin-left: 0.304rem;
`;
interface HoverCardProps {
isHovered: boolean;
isSelected: boolean;
top: number;
isDarkMode: boolean;
}
export const HoverCard = styled.div<HoverCardProps>`
display: ${({ isSelected, isHovered }): string =>
isSelected || isHovered ? 'block' : 'none'};
width: 200%;
background-color: ${({ isHovered, isDarkMode }): string => {
if (isHovered) {
return isDarkMode ? '#262626' : '#ddd';
}
return isDarkMode ? '#4f4f4f' : '#bbb';
}};
position: absolute;
top: 0;
left: -100%;
right: 0;
height: 3rem;
opacity: 0.5;
`;
const flexNoWrap = css`
flex-wrap: nowrap;
`;
const overFlowHidden = css`
overflow: hidden;
`;
export const styles = {
flexNoWrap,
overFlowHidden,
};

View File

@@ -1,3 +0,0 @@
export const getIconStyles = (): Record<string, string> => ({
color: 'var(--l1-foreground)',
});

View File

@@ -1,91 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { SquareMinus, SquarePlus } from '@signozhq/icons';
import { IIntervalUnit } from 'container/TraceDetail/utils';
import { ITraceTree } from 'types/api/trace/getTraceItem';
import { CardContainer, CardWrapper, CollapseButton } from './styles';
import Trace from './Trace';
import { getSpanPath } from './utils';
function GanttChart(props: GanttChartProps): JSX.Element {
const {
data,
traceMetaData,
activeHoverId,
setActiveHoverId,
activeSelectedId,
setActiveSelectedId,
spanId,
intervalUnit,
} = props;
const { globalStart, spread: globalSpread } = traceMetaData;
const [isExpandAll, setIsExpandAll] = useState<boolean>(false);
const [activeSpanPath, setActiveSpanPath] = useState<string[]>([]);
useEffect(() => {
setActiveSpanPath(getSpanPath(data, spanId));
}, [spanId, data]);
useEffect(() => {
setActiveSpanPath(getSpanPath(data, activeSelectedId));
}, [activeSelectedId, data]);
const handleCollapse = (): void => {
setIsExpandAll((prev) => !prev);
};
return (
<CardContainer>
<CollapseButton
onClick={handleCollapse}
title={isExpandAll ? 'Collapse All' : 'Expand All'}
>
{isExpandAll ? (
<SquareMinus size={16} style={{ color: 'var(--accent-primary)' }} />
) : (
<SquarePlus size={16} style={{ color: 'var(--accent-primary)' }} />
)}
</CollapseButton>
<CardWrapper>
<Trace
activeHoverId={activeHoverId}
activeSpanPath={activeSpanPath}
setActiveHoverId={setActiveHoverId}
key={data.id}
{...{
...data,
globalSpread,
globalStart,
setActiveSelectedId,
activeSelectedId,
}}
level={0}
isExpandAll={isExpandAll}
intervalUnit={intervalUnit}
/>
</CardWrapper>
</CardContainer>
);
}
export interface ITraceMetaData {
globalEnd: number;
globalStart: number;
levels: number;
spread: number;
totalSpans: number;
}
export interface GanttChartProps {
data: ITraceTree;
traceMetaData: ITraceMetaData;
activeSelectedId: string;
activeHoverId: string;
setActiveHoverId: Dispatch<SetStateAction<string>>;
setActiveSelectedId: Dispatch<SetStateAction<string>>;
spanId: string;
intervalUnit: IIntervalUnit;
}
export default GanttChart;

View File

@@ -1,48 +0,0 @@
import styled from 'styled-components';
export const Wrapper = styled.ul`
padding-left: 0;
position: absolute;
width: 100%;
height: 100%;
ul {
list-style: none;
border-left: 1px solid #434343;
padding-left: 1rem;
width: 100%;
margin: 0px;
}
ul li {
position: relative;
&:before {
position: absolute;
left: -1rem;
top: 10px;
content: '';
height: 1px;
width: 1rem;
background-color: #434343;
}
}
`;
export const CardWrapper = styled.div`
display: flex;
width: 100%;
margin-left: 1rem;
margin-top: 1.5rem;
`;
export const CardContainer = styled.li`
display: flex;
width: 100%;
position: relative;
`;
export const CollapseButton = styled.div`
position: absolute;
top: 0;
`;

View File

@@ -1,203 +0,0 @@
import { set } from 'lodash-es';
import { ITraceForest, ITraceTree } from 'types/api/trace/getTraceItem';
interface GetTraceMetaData {
globalStart: number;
globalEnd: number;
spread: number;
totalSpans: number;
levels: number;
}
export const getMetaDataFromSpanTree = (
treeData: ITraceTree,
): GetTraceMetaData => {
let globalStart = Number.POSITIVE_INFINITY;
let globalEnd = Number.NEGATIVE_INFINITY;
let totalSpans = 0;
let levels = 1;
const traverse = (treeNode: ITraceTree, level = 0): void => {
if (!treeNode) {
return;
}
totalSpans += 1;
levels = Math.max(levels, level);
const { startTime } = treeNode;
const endTime = startTime + treeNode.value;
globalStart = Math.min(globalStart, startTime);
globalEnd = Math.max(globalEnd, endTime);
treeNode.children.forEach((childNode) => {
traverse(childNode, level + 1);
});
};
traverse(treeData, 1);
globalStart *= 1e6;
globalEnd *= 1e6;
return {
globalStart,
globalEnd,
spread: globalEnd - globalStart,
totalSpans,
levels,
};
};
export function getTopLeftFromBody(elem: HTMLElement): {
top: number;
left: number;
} {
const box = elem.getBoundingClientRect();
const { body } = document;
const docEl = document.documentElement;
const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const top = box.top + scrollTop - clientTop;
const left = box.left + scrollLeft - clientLeft;
return { top: Math.round(top), left: Math.round(left) };
}
export const getNodeById = (
searchingId: string,
treesData: ITraceForest | undefined,
): ITraceForest => {
const newtreeData: ITraceForest = {} as ITraceForest;
const traverse = (
treeNode: ITraceTree,
setCallBack: (arg0: ITraceTree) => void,
level = 0,
): void => {
if (!treeNode) {
return;
}
if (searchingId === treeNode.id) {
setCallBack(treeNode);
}
treeNode.children.forEach((childNode) => {
traverse(childNode, setCallBack, level + 1);
});
};
const spanTreeSetCallback = (
path: (keyof ITraceForest)[],
value: ITraceTree,
): ITraceForest => set(newtreeData, path, [value]);
if (treesData?.spanTree) {
treesData.spanTree.forEach((tree) => {
traverse(tree, (value) => spanTreeSetCallback(['spanTree'], value), 1);
});
}
if (treesData?.missingSpanTree) {
treesData.missingSpanTree.forEach((tree) => {
traverse(
tree,
(value) => spanTreeSetCallback(['missingSpanTree'], value),
1,
);
});
}
return newtreeData;
};
const getSpanWithoutChildren = (
span: ITraceTree,
): Omit<ITraceTree, 'children'> => ({
id: span.id,
name: span.name,
parent: span.parent,
serviceColour: span.serviceColour,
serviceName: span.serviceName,
startTime: span.startTime,
tags: span.tags,
time: span.time,
value: span.value,
event: span.event,
hasError: span.hasError,
spanKind: span.spanKind,
statusCodeString: span.statusCodeString,
statusMessage: span.statusMessage,
});
export const isSpanPresentInSearchString = (
searchedString: string,
tree: ITraceTree,
): boolean => {
const parsedTree = getSpanWithoutChildren(tree);
const stringifyTree = JSON.stringify(parsedTree);
return stringifyTree.includes(searchedString);
};
export const isSpanPresent = (
tree: ITraceTree,
searchedKey: string,
): ITraceTree[] => {
const foundNode: ITraceTree[] = [];
const traverse = (
treeNode: ITraceTree,
level = 0,
foundNode: ITraceTree[],
): void => {
if (!treeNode) {
return;
}
const isPresent = isSpanPresentInSearchString(searchedKey, treeNode);
if (isPresent) {
foundNode.push(treeNode);
}
treeNode.children.forEach((childNode) => {
traverse(childNode, level + 1, foundNode);
});
};
traverse(tree, 1, foundNode);
return foundNode;
};
export const getSpanPath = (tree: ITraceTree, spanId: string): string[] => {
const spanPath: string[] = [];
const traverse = (treeNode: ITraceTree): boolean => {
if (!treeNode) {
return false;
}
spanPath.push(treeNode.id);
if (spanId === treeNode.id) {
return true;
}
let foundInChild = false;
treeNode.children.forEach((childNode) => {
if (traverse(childNode)) {
foundInChild = true;
}
});
if (!foundInChild) {
spanPath.pop();
}
return foundInChild;
};
traverse(tree);
return spanPath;
};

View File

@@ -28,7 +28,7 @@ import {
} from 'types/api/licensesV3/getActive';
import { ServicesList } from 'types/api/metrics/getService';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
import { Tags } from 'hooks/useResourceAttribute/types';
import { USER_ROLES } from 'types/roles';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';

View File

@@ -82,7 +82,7 @@ export function getHostMetricsQueryPayload(
start: number,
end: number,
): ReturnType<typeof getHostQueryPayload> {
return getHostQueryPayload(host.hostName, start, end, true);
return getHostQueryPayload(host.hostName, start, end);
}
export { hostWidgetInfo };

View File

@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { TextNoData } from '../../components/TextNoData';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { getMs } from 'utils/timeUtils';
import {
BlockLink,
getTraceLink,

View File

@@ -1,6 +1,7 @@
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { formUrlParams } from 'container/TraceDetail/utils';
import { formUrlParams } from 'utils/traceUtils';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export const getTraceLink = (record: Record<string, unknown>): string => {
@@ -14,7 +15,11 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
if (!traceId) {
return '';
}
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,

View File

@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { getMs } from 'utils/timeUtils';
import { useTimezone } from 'providers/Timezone';
import {

View File

@@ -10,3 +10,11 @@
display: none;
}
}
.errorState {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-8);
}

View File

@@ -1,19 +1,41 @@
import { Typography } from '@signozhq/ui/typography';
import Spinner from 'components/Spinner';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
import { useSeededDashboardV2 } from './hooks/useSeededDashboardV2';
import { useSystemDashboard } from './hooks/useSystemDashboard';
import styles from './Overview.module.scss';
function Overview(): JSX.Element {
//TODO: this is a temporary solution to get the seeded dashboard. We should fetch this json from the backend.
const { dashboard, refetch } = useSeededDashboardV2();
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
return (
<div className={styles.overview} data-testid="llm-observability-overview">
const renderContent = (): JSX.Element => {
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError || !dashboard) {
return (
<div className={styles.errorState}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>
{error?.response?.data?.error?.message ?? error?.message}
</Typography.Text>
</div>
);
}
return (
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
);
};
return (
<div className={styles.overview} data-testid="llm-observability-overview">
{renderContent()}
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { useQueryClient } from 'react-query';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import dashboardV2Json from '../json/dashboard.json';
const dashboard =
dashboardV2Json as unknown as DashboardtypesGettableDashboardV2DTO;
export interface UseSeededDashboardV2Result {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
}
const noop = (): void => {};
export function useSeededDashboardV2(): UseSeededDashboardV2Result {
const queryClient = useQueryClient();
const key = getGetDashboardV2QueryKey({ id: dashboard.id });
if (queryClient.getQueryData<GetDashboardV2200>(key) === undefined) {
queryClient.setQueryData<GetDashboardV2200>(key, {
data: dashboard,
status: 'success',
});
}
return { dashboard, refetch: noop };
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
useGetSystemDashboard,
} from 'api/generated/services/dashboard';
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
const DASHBOARD_ID = 'llm-observability-overview';
export interface UseSystemDashboardResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: boolean;
isError: boolean;
error: GetSystemDashboardQueryError | null;
refetch: () => void;
}
export function useSystemDashboard(): UseSystemDashboardResult {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
{ name: SYSTEM_DASHBOARD_NAME },
{ query: { staleTime: Infinity, refetchOnMount: false } },
);
const dashboard = useMemo(
() =>
data
? ({
...data.data,
id: DASHBOARD_ID,
} as DashboardtypesGettableDashboardV2DTO)
: undefined,
[data],
);
if (dashboard) {
queryClient.setQueryData<GetDashboardV2200>(
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
{ data: dashboard, status: 'success' },
);
}
const refetchDashboard = useCallback((): void => {
void refetch();
}, [refetch]);
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
}

View File

@@ -50,6 +50,7 @@ function ModelCostActionsMenu({
color="secondary"
size="icon"
className={styles.actionButton}
aria-label="Model cost actions"
testId={`model-cost-actions-${rule.id}`}
>
<Ellipsis size={16} />

View File

@@ -24,6 +24,35 @@ jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
}));
const SYSTEM_DASHBOARD_ENDPOINT = '*/api/v2/dashboards/system/ai-o11y-overview';
function setupSystemDashboard(): void {
server.use(
rest.get(SYSTEM_DASHBOARD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
orgId: 'org',
locked: true,
name: 'signoz---ai-o11y-overview',
schemaVersion: 'v6',
source: 'system',
tags: null,
spec: {
display: { name: 'AI Observability Overview' },
variables: [],
panels: {},
layouts: [],
},
},
}),
),
),
);
}
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
@@ -41,14 +70,17 @@ describe('LLMObservability (integration)', () => {
server.resetHandlers();
});
it('renders the overview panel and the tab bar on the overview route', () => {
it('renders the overview panel and the tab bar on the overview route', async () => {
setupSystemDashboard();
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
expect(screen.getByTestId('llm-overview-dashboard')).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId('llm-overview-dashboard')).toBeInTheDocument(),
);
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Explorer' })).toBeInTheDocument();
expect(

View File

@@ -28,7 +28,7 @@ function InfraMetrics({
dataSource = DataSource.LOGS,
}: MetricsDataProps): JSX.Element {
const [selectedView, setSelectedView] = useState<string>(() =>
podName ? VIEW_TYPES.POD : VIEW_TYPES.NODE,
nodeName || hostName ? VIEW_TYPES.NODE : VIEW_TYPES.POD,
);
const viewOptions = useMemo(() => {
@@ -60,6 +60,10 @@ function InfraMetrics({
}, [podName]);
const handleModeChange = (value: string): void => {
// single toggle-group emits '' on re-click of the pressed item
if (!value) {
return;
}
setSelectedView(value);
};

View File

@@ -4,9 +4,8 @@ import { Card, Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { ENTITY_VERSION_V5 } from 'constants/app';
import dayjs from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
@@ -61,9 +60,9 @@ function NodeMetrics({
const widgetInfo = nodeName ? nodeWidgetInfo : hostWidgetInfo;
const queries = useQueries(
queryPayloads.map((payload) => ({
queryKey: ['metrics', payload, ENTITY_VERSION_V4, 'NODE'],
queryKey: ['metrics', payload, ENTITY_VERSION_V5, 'NODE'],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
})),
);
@@ -85,7 +84,6 @@ function NodeMetrics({
);
const { timezone } = useTimezone();
const { currentQuery } = useQueryBuilder();
const options = useMemo(
() =>
@@ -103,7 +101,7 @@ function NodeMetrics({
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
query: currentQuery,
query: queryPayloads[idx].query,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
@@ -122,7 +120,7 @@ function NodeMetrics({
verticalLineTimestamp,
end,
timezone.value,
currentQuery,
queryPayloads,
],
);

View File

@@ -4,9 +4,8 @@ import { Card, Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { ENTITY_VERSION_V5 } from 'constants/app';
import dayjs from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
@@ -58,9 +57,9 @@ function PodMetrics({
);
const queries = useQueries(
queryPayloads.map((payload) => ({
queryKey: ['metrics', payload, ENTITY_VERSION_V4, 'POD'],
queryKey: ['metrics', payload, ENTITY_VERSION_V5, 'POD'],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
})),
);
@@ -74,7 +73,6 @@ function PodMetrics({
[queries],
);
const { timezone } = useTimezone();
const { currentQuery } = useQueryBuilder();
const options = useMemo(
() =>
@@ -92,7 +90,7 @@ function PodMetrics({
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
query: currentQuery,
query: queryPayloads[idx].query,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
@@ -110,7 +108,7 @@ function PodMetrics({
end,
verticalLineTimestamp,
timezone.value,
currentQuery,
queryPayloads,
],
);

View File

@@ -1,18 +1,13 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import type { Having } from 'types/api/queryBuilder/queryBuilderData';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { Having as HavingV5 } from 'types/api/v5/queryRange';
import type { Having } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
const buildSumGreaterThanZeroHaving = (
metricKey: string,
useV5HavingFormat: boolean,
): Having[] | HavingV5 =>
useV5HavingFormat
? { expression: `sum(${metricKey}) > 0` }
: [{ columnName: `SUM(${metricKey})`, op: '>', value: 0 }];
const buildSumGreaterThanZeroHaving = (metricKey: string): Having => ({
expression: `sum(${metricKey}) > 0`,
});
export const getPodQueryPayload = (
clusterName: string,
@@ -1550,7 +1545,6 @@ export const getHostQueryPayload = (
hostName: string,
start: number,
end: number,
useV5HavingFormat = false,
): GetQueryResultsProps[] => {
const hostNameKey = 'host.name';
const cpuTimeKey = 'system.cpu.time';
@@ -1813,7 +1807,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(fsUsageKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(fsUsageKey),
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
@@ -1862,7 +1856,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(fsUsageKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(fsUsageKey),
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
@@ -2088,7 +2082,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(netIoKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(netIoKey),
legend: '{{device}}::{{direction}}',
limit: 30,
orderBy: [],
@@ -2544,7 +2538,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(diskOpsKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(diskOpsKey),
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
@@ -2613,7 +2607,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(diskPendingKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(diskPendingKey),
legend: '{{device}}',
limit: null,
orderBy: [],
@@ -2689,7 +2683,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(diskOpTimeKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(diskOpTimeKey),
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],

View File

@@ -242,7 +242,7 @@ function Application(): JSX.Element {
urlParams.set(QueryParams.startTime, startTime.toString());
urlParams.set(QueryParams.endTime, endTime.toString());
urlParams.delete(QueryParams.relativeTime);
const avialableParams = routeConfig[ROUTES.TRACE];
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(

View File

@@ -10,7 +10,7 @@ import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute
import { AppState } from 'store/reducers';
import { PayloadProps } from 'types/api/metrics/getTopOperations';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
import { Tags } from 'hooks/useResourceAttribute/types';
function TopOperation(): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(

View File

@@ -10,7 +10,7 @@ import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
import { traceFilterKeys } from 'constants/traceFilterKeys';
import {
BaseAutocompleteData,
DataTypes,
@@ -21,7 +21,7 @@ import {
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { Tags } from 'types/reducer/trace';
import { Tags } from 'hooks/useResourceAttribute/types';
import { isModifierKeyPressed } from 'utils/app';
import { secondsToMilliseconds } from 'utils/timeUtils';
import { v4 as uuid } from 'uuid';
@@ -102,7 +102,7 @@ export function onViewTracePopupClick({
urlParams.set(QueryParams.startTime, startTime.toString());
urlParams.set(QueryParams.endTime, endTime.toString());
urlParams.delete(QueryParams.relativeTime);
const avialableParams = routeConfig[ROUTES.TRACE];
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(

View File

@@ -56,10 +56,6 @@ jest.mock('../Tabs/util', () => ({
}));
// Mock the resourceAttributesToTracesFilterItems function
jest.mock('container/TraceDetail/utils', () => ({
resourceAttributesToTracesFilterItems: (): any[] => [],
}));
const mockedUseResourceAttribute = useResourceAttribute as jest.MockedFunction<
typeof useResourceAttribute
>;

View File

@@ -136,6 +136,7 @@ function DashboardsAndAlertsPopover({
>
<div
className="dashboards-and-alerts-popover dashboards-popover"
data-testid="metric-dashboards-popover"
style={{ backgroundColor: `${Color.BG_SIENNA_500}33` }}
>
<Grid2X2 size={12} color={Color.BG_SIENNA_500} />
@@ -154,6 +155,7 @@ function DashboardsAndAlertsPopover({
>
<div
className="dashboards-and-alerts-popover alerts-popover"
data-testid="metric-alerts-popover"
style={{ backgroundColor: `${Color.BG_SAKURA_500}33` }}
>
<Bell size={12} color={Color.BG_SAKURA_500} />

View File

@@ -1,5 +0,0 @@
.new-explorer-cta-with-badge {
display: inline-flex;
align-items: center;
gap: 6px;
}

View File

@@ -1,8 +0,0 @@
import ROUTES from 'constants/routes';
export const buttonText: Record<string, string> = {
[ROUTES.LOGS_EXPLORER]: 'Old Explorer',
[ROUTES.TRACE]: 'New Explorer',
[ROUTES.OLD_LOGS_EXPLORER]: 'New Explorer',
[ROUTES.TRACES_EXPLORER]: 'Old Explorer',
};

View File

@@ -1,83 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Undo } from '@signozhq/icons';
import { isModifierKeyPressed } from 'utils/app';
import { buttonText } from './config';
import './NewExplorerCTA.styles.scss';
function NewExplorerCTA(): JSX.Element | null {
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const isTraceOrLogsExplorerPage = useMemo(
() =>
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACE ||
location.pathname === ROUTES.OLD_LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER,
[location.pathname],
);
const onClickHandler = useCallback(
(e?: React.MouseEvent): void => {
let targetPath: string;
if (location.pathname === ROUTES.LOGS_EXPLORER) {
targetPath = ROUTES.OLD_LOGS_EXPLORER;
} else if (location.pathname === ROUTES.TRACE) {
targetPath = ROUTES.TRACES_EXPLORER;
} else if (location.pathname === ROUTES.OLD_LOGS_EXPLORER) {
targetPath = ROUTES.LOGS_EXPLORER;
} else if (location.pathname === ROUTES.TRACES_EXPLORER) {
targetPath = ROUTES.TRACE;
} else {
return;
}
safeNavigate(targetPath, { newTab: !!e && isModifierKeyPressed(e) });
},
[location.pathname],
);
const button = useMemo(
() => (
<Button
icon={<Undo size={16} />}
onClick={(e): void => onClickHandler(e)}
data-testid="newExplorerCTA"
type="text"
className="periscope-btn link"
>
{buttonText[location.pathname]}
</Button>
),
[location.pathname, onClickHandler],
);
if (!isTraceOrLogsExplorerPage) {
return null;
}
if (location.pathname === ROUTES.TRACES_EXPLORER) {
return button;
}
if (location.pathname === ROUTES.LOGS_EXPLORER) {
return button;
}
return (
<span className="new-explorer-cta-with-badge">
{button}
<Badge color="robin" variant="default">
New
</Badge>
</span>
);
}
export default NewExplorerCTA;

View File

@@ -10,7 +10,7 @@ import useDebounce from 'hooks/useDebounce';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { has } from 'lodash-es';
import { AllTraceFilterKeyValue } from 'pages/TracesExplorer/Filter/filterUtils';
import { AllTraceFilterKeyValue } from 'constants/traceFilterKeys';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import {
QueryKeyRequestProps,

View File

@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Button, Table, TableProps } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { themeColors } from 'constants/theme';
import { StyledCSS } from 'container/GantChart/Trace/styles';
import { StyledCSS } from 'types/styledComponents';
import styled from 'styled-components';
export const FooterButton = styled(Button)`

View File

@@ -11,7 +11,7 @@ import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
import { Tags } from 'hooks/useResourceAttribute/types';
import SkipOnBoardingModal from '../SkipOnBoardModal';
import ServiceMetricsApplication from './ServiceMetricsApplication';

View File

@@ -15,7 +15,7 @@ import {
import { isUndefined } from 'lodash-es';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
import { Tags } from 'hooks/useResourceAttribute/types';
import SkipOnBoardingModal from '../SkipOnBoardModal';
import ServiceTraceTable from './ServiceTracesTable';

View File

@@ -42,9 +42,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
[ROUTES.SIGN_UP]: [QueryParams.resourceAttributes],
[ROUTES.SOMETHING_WENT_WRONG]: [QueryParams.resourceAttributes],
[ROUTES.TRACES_EXPLORER]: [QueryParams.resourceAttributes],
[ROUTES.TRACE]: [QueryParams.resourceAttributes],
[ROUTES.TRACE_DETAIL]: [QueryParams.resourceAttributes],
[ROUTES.TRACE_DETAIL_OLD]: [QueryParams.resourceAttributes],
[ROUTES.UN_AUTHORIZED]: [QueryParams.resourceAttributes],
[ROUTES.USAGE_EXPLORER]: [QueryParams.resourceAttributes],
[ROUTES.VERSION]: [QueryParams.resourceAttributes],

View File

@@ -568,7 +568,7 @@ export const getUserSettingsDropdownMenuItems = ({
This is used to highlight the correct menu item when the user navigates to a new route
**/
export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
[ROUTES.TRACE]: ROUTES.TRACES_EXPLORER,
[ROUTES.TRACE_BASE]: ROUTES.TRACES_EXPLORER,
[ROUTES.TRACE_EXPLORER]: ROUTES.TRACES_EXPLORER,
[ROUTES.LOGS_BASE]: ROUTES.LOGS_EXPLORER,
[ROUTES.METRICS_EXPLORER_BASE]: ROUTES.METRICS_EXPLORER,
@@ -580,7 +580,7 @@ export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
// `getActiveMenuKeyFromPath` strips the URL down to its first segment;
// `/ai-assistant/<id>` reduces to `/ai-assistant`, which we point back
// to the AI Assistant menu item's concrete key.
'/ai-assistant': AI_ASSISTANT_NAV_KEY,
[ROUTES.AI_ASSISTANT_BASE]: AI_ASSISTANT_NAV_KEY,
};
export default menuItems;

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import LiveLogsPauseResume from 'container/LiveLogs/LiveLogsPauseResume/LiveLogsPauseResume';
import NewExplorerCTA from 'container/NewExplorerCTA';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { noop } from 'lodash-es';
@@ -12,7 +11,6 @@ interface ToolbarProps {
showAutoRefresh: boolean;
leftActions?: JSX.Element;
rightActions?: JSX.Element;
showOldCTA?: boolean;
warningElement?: JSX.Element;
onGoLive?: () => void;
onExitLiveLogs?: () => void;
@@ -23,7 +21,6 @@ export default function Toolbar({
showAutoRefresh,
leftActions,
rightActions,
showOldCTA,
warningElement,
showLiveLogs,
onGoLive,
@@ -48,7 +45,6 @@ export default function Toolbar({
<div className="rightActions">
<div className="timeRange">
{warningElement}
{showOldCTA && <NewExplorerCTA />}
{showLiveLogs && <LiveLogsPauseResume />}
<DateTimeSelectionV2
showLiveLogs={showLiveLogs}
@@ -69,7 +65,6 @@ export default function Toolbar({
Toolbar.defaultProps = {
leftActions: <div />,
rightActions: <div />,
showOldCTA: false,
warningElement: <div />,
showLiveLogs: false,
onGoLive: (): void => noop(),

View File

@@ -20,11 +20,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('container/NewExplorerCTA', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
__esModule: true,
default: ({

View File

@@ -20,11 +20,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('container/NewExplorerCTA', () => ({
__esModule: true,
default: (): null => null,
}));
let mockOnCustomDateHandler: ((range: [unknown, unknown]) => void) | null =
null;
let mockOnValidCustomDateChange: ((data: { timeStr: string }) => void) | null =

View File

@@ -61,11 +61,6 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
),
}));
jest.mock('container/NewExplorerCTA', () => ({
__esModule: true,
default: (): null => null,
}));
function NuqsParamSetter({ paramValue }: { paramValue: string }): JSX.Element {
const [, setYAxisUnit] = useQueryState(
'yAxisUnit',

View File

@@ -15,11 +15,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('container/NewExplorerCTA', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="custom-time-picker" />,

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