Compare commits

..

133 Commits

Author SHA1 Message Date
Vinícius Lourenço
8a14ab0ad4 ci(storybook): give each story test a 30s timeout 2026-09-15 00:03:25 -03:00
Vinícius Lourenço
0c86021152 chore(oxlint): ban msw imports in .stories.tsx files 2026-09-15 00:03:24 -03:00
Vinícius Lourenço
45a7438c6f feat(storybook): add the noz global config handler 2026-09-15 00:03:24 -03:00
Vinícius Lourenço
86b5b83e0b docs(storybook): update the harness readme 2026-09-15 00:03:24 -03:00
Vinícius Lourenço
f60e2544b8 feat(storybook): add the tooltips foundations story 2026-09-15 00:03:24 -03:00
Vinícius Lourenço
c110ce9b6e feat(storybook): add the layering foundations story 2026-09-15 00:03:24 -03:00
Vinícius Lourenço
376ed6c81b feat(storybook): add the feedback foundations story 2026-09-15 00:03:24 -03:00
Vinícius Lourenço
b0ad3c8b71 feat(components): add storybook story for the tanstack table view 2026-09-15 00:03:23 -03:00
Vinícius Lourenço
6d16f0ab63 feat(components): add storybook story for the table 2026-09-15 00:03:23 -03:00
Vinícius Lourenço
85c7cf462c feat(components): add storybook story for the modal 2026-09-15 00:03:23 -03:00
Vinícius Lourenço
08187a18df feat(components): add storybook story for the quick filters 2026-09-15 00:03:23 -03:00
Vinícius Lourenço
bc59a1763c feat(components): add storybook story for the new select 2026-09-15 00:03:23 -03:00
Vinícius Lourenço
211cba57db feat(components): add storybook story for the fields selector 2026-09-15 00:03:23 -03:00
Vinícius Lourenço
be3e107817 feat(components): add storybook story for the custom time picker 2026-09-15 00:03:22 -03:00
Vinícius Lourenço
e0c46acc45 feat(storybook): add the canvas decorator for component stories 2026-09-15 00:03:22 -03:00
Vinícius Lourenço
b63f5cad5a fix(storybook): freeze the clock every story reads 2026-09-15 00:03:22 -03:00
Vinícius Lourenço
3917df2e51 fix(storybook): load every locale namespace before the first story 2026-09-15 00:03:22 -03:00
Vinícius Lourenço
208a1a23b0 feat(alerts): add stories for the google chat channel 2026-09-15 00:03:22 -03:00
Vinícius Lourenço
5a43774814 feat(alerts): add stories for the microsoft teams channel 2026-09-15 00:03:21 -03:00
Vinícius Lourenço
4c8b92dca2 feat(alerts): add story for the email channel edit page 2026-09-15 00:03:21 -03:00
Vinícius Lourenço
271bfcf314 feat(alerts): add stories for the opsgenie channel 2026-09-15 00:03:21 -03:00
Vinícius Lourenço
e6a29ac2ff feat(ai-observability): add storybook story for the explorer page 2026-09-15 00:03:21 -03:00
Vinícius Lourenço
69bd1d15b0 feat(alerts): add stories for the new channel types 2026-09-15 00:03:21 -03:00
Vinícius Lourenço
1f1aa12ca9 fix(storybook): answer the v4 licenses endpoints 2026-09-15 00:03:21 -03:00
Vinícius Lourenço
21593066c2 fix(storybook): keep third-party frames out of the story iframe 2026-09-15 00:03:20 -03:00
Vinícius Lourenço
660d01ea4c fix(storybook): answer the legacy autocomplete endpoints 2026-09-15 00:03:20 -03:00
Vinícius Lourenço
f3530c1890 feat(system): add storybook story for the workspace suspended page 2026-09-15 00:03:20 -03:00
Vinícius Lourenço
ea66350c2d feat(system): add storybook story for the workspace locked page 2026-09-15 00:03:20 -03:00
Vinícius Lourenço
b0bae39b1a feat(system): add storybook story for the workspace access restricted page 2026-09-15 00:03:20 -03:00
Vinícius Lourenço
1bf2615814 feat(system): add shared storybook mock data for the workspace states 2026-09-15 00:03:20 -03:00
Vinícius Lourenço
1a3c6d2ec2 feat(system): add storybook story for the unauthorized page 2026-09-15 00:03:19 -03:00
Vinícius Lourenço
4605f955d8 feat(traces): add storybook story for the explorer page 2026-09-15 00:03:19 -03:00
Vinícius Lourenço
4b7bec5cfd feat(traces): add storybook story for the funnel details page 2026-09-15 00:03:19 -03:00
Vinícius Lourenço
6ffff95553 feat(traces): add storybook story for the trace details page 2026-09-15 00:03:19 -03:00
Vinícius Lourenço
5953d19386 feat(traces): add storybook story for the legacy explorer page 2026-09-15 00:03:19 -03:00
Vinícius Lourenço
af7955c1fb feat(system): add storybook story for the support page 2026-09-15 00:03:18 -03:00
Vinícius Lourenço
fd0cdafb5d feat(system): add storybook story for the status page 2026-09-15 00:03:18 -03:00
Vinícius Lourenço
4b67a41a80 feat(auth): add storybook story for the sign up page 2026-09-15 00:03:18 -03:00
Vinícius Lourenço
c72e3f0d21 feat(settings): add storybook story for the workspace page 2026-09-15 00:03:18 -03:00
Vinícius Lourenço
bb23c1fccf feat(settings): add storybook story for the single sign-on page 2026-09-15 00:03:18 -03:00
Vinícius Lourenço
7000140a80 feat(settings): add storybook story for the keyboard shortcuts page 2026-09-15 00:03:18 -03:00
Vinícius Lourenço
040e6d4872 feat(settings): add storybook story for the service accounts page 2026-09-15 00:03:17 -03:00
Vinícius Lourenço
58c9a55705 feat(settings): add storybook story for the roles page 2026-09-15 00:03:17 -03:00
Vinícius Lourenço
0b263f1b9b feat(settings): add storybook story for the role editor page 2026-09-15 00:03:17 -03:00
Vinícius Lourenço
ad28224567 feat(settings): add storybook story for the role details page 2026-09-15 00:03:17 -03:00
Vinícius Lourenço
b48d75a42a feat(settings): add storybook story for the members page 2026-09-15 00:03:17 -03:00
Vinícius Lourenço
1c97474bbd feat(settings): add storybook story for the mcp server page 2026-09-15 00:03:17 -03:00
Vinícius Lourenço
101861be10 feat(settings): add storybook story for the ingestion page 2026-09-15 00:03:16 -03:00
Vinícius Lourenço
c65904cc74 feat(settings): add storybook story for the billing page 2026-09-15 00:03:16 -03:00
Vinícius Lourenço
992456ac44 feat(settings): add storybook story for the account page 2026-09-15 00:03:16 -03:00
Vinícius Lourenço
4be95b750f feat(services): add storybook story for the list page 2026-09-15 00:03:16 -03:00
Vinícius Lourenço
7f49434928 feat(services): add storybook story for the top level operations page 2026-09-15 00:03:16 -03:00
Vinícius Lourenço
09c73fbbcf feat(auth): add storybook story for the reset password page 2026-09-15 00:03:16 -03:00
Vinícius Lourenço
23372b40e5 feat(dashboards): add storybook story for the public page 2026-09-15 00:03:15 -03:00
Vinícius Lourenço
008e896ee2 feat(onboarding): add storybook story for the questionnaire page 2026-09-15 00:03:15 -03:00
Vinícius Lourenço
85b967d462 feat(onboarding): add storybook story for the add data source page 2026-09-15 00:03:15 -03:00
Vinícius Lourenço
c458064812 feat(settings): add shared storybook mock data 2026-09-15 00:03:15 -03:00
Vinícius Lourenço
25678068a8 feat(metrics): add storybook story for the explorer page 2026-09-15 00:03:15 -03:00
Vinícius Lourenço
f96b0c986f feat(services): add storybook story for the detail page 2026-09-15 00:03:15 -03:00
Vinícius Lourenço
5c3800f952 feat(metering): add storybook story for the cost meter page 2026-09-15 00:03:14 -03:00
Vinícius Lourenço
7b158eb7a2 feat(messaging-queues): add storybook story for the overview page 2026-09-15 00:03:14 -03:00
Vinícius Lourenço
0854e4546e feat(messaging-queues): add storybook story for the kafka page 2026-09-15 00:03:14 -03:00
Vinícius Lourenço
bb629f3217 feat(messaging-queues): add storybook story for the kafka detail page 2026-09-15 00:03:14 -03:00
Vinícius Lourenço
68d7010632 feat(messaging-queues): add storybook story for the celery page 2026-09-15 00:03:14 -03:00
Vinícius Lourenço
52d18a788b feat(messaging-queues): add shared storybook mock data 2026-09-15 00:03:13 -03:00
Vinícius Lourenço
7cd0f326c8 feat(logs): add storybook story for the settings page 2026-09-15 00:03:13 -03:00
Vinícius Lourenço
24f298ef41 feat(logs): add storybook story for the saved views page 2026-09-15 00:03:13 -03:00
Vinícius Lourenço
4730cd6109 feat(logs): add storybook story for the pipelines page 2026-09-15 00:03:13 -03:00
Vinícius Lourenço
a44137aade feat(logs): add storybook story for the explorer page 2026-09-15 00:03:13 -03:00
Vinícius Lourenço
975228cffa feat(logs): add storybook story for the live tail page 2026-09-15 00:03:13 -03:00
Vinícius Lourenço
b27d4117b2 feat(logs): add shared storybook mock data 2026-09-15 00:03:12 -03:00
Vinícius Lourenço
d5bf545cca feat(logs): add storybook story for the legacy explorer page 2026-09-15 00:03:12 -03:00
Vinícius Lourenço
e3be6412ce feat(auth): add storybook story for the login page 2026-09-15 00:03:12 -03:00
Vinícius Lourenço
531e729490 feat(system): add storybook story for the license page 2026-09-15 00:03:12 -03:00
Vinícius Lourenço
ea2da963a6 feat(ai-observability): add storybook story for the overview page 2026-09-15 00:03:12 -03:00
Vinícius Lourenço
9074f4011f feat(ai-observability): add storybook story for the model pricing page 2026-09-15 00:03:12 -03:00
Vinícius Lourenço
35907e60c5 feat(ai-observability): add storybook story for the attribute mapping page 2026-09-15 00:03:11 -03:00
Vinícius Lourenço
c734883bbb feat(integrations): add storybook story for the list page 2026-09-15 00:03:11 -03:00
Vinícius Lourenço
c392ff32ce feat(integrations): add storybook story for the details page 2026-09-15 00:03:11 -03:00
Vinícius Lourenço
637ac110e0 feat(integrations): add storybook story for the cloud account page 2026-09-15 00:03:11 -03:00
Vinícius Lourenço
54f8b31b6f feat(integrations): add shared storybook mock data 2026-09-15 00:03:11 -03:00
Vinícius Lourenço
0215585476 feat(infrastructure): add storybook story for the kubernetes volumes page 2026-09-15 00:03:11 -03:00
Vinícius Lourenço
29abb8bcac feat(infrastructure): add storybook story for the kubernetes statefulsets page 2026-09-15 00:03:10 -03:00
Vinícius Lourenço
d6cf1fb5fc feat(infrastructure): add storybook story for the kubernetes pods page 2026-09-15 00:03:10 -03:00
Vinícius Lourenço
49302181fc feat(infrastructure): add storybook story for the kubernetes nodes page 2026-09-15 00:03:10 -03:00
Vinícius Lourenço
074d84cb57 feat(infrastructure): add storybook story for the kubernetes namespaces page 2026-09-15 00:03:10 -03:00
Vinícius Lourenço
9655d67039 feat(infrastructure): add storybook story for the kubernetes jobs page 2026-09-15 00:03:10 -03:00
Vinícius Lourenço
ce11c4189e feat(infrastructure): add storybook story for the kubernetes deployments page 2026-09-15 00:03:10 -03:00
Vinícius Lourenço
8ab2960cc0 feat(infrastructure): add storybook story for the kubernetes daemonsets page 2026-09-15 00:03:09 -03:00
Vinícius Lourenço
ee184120e4 feat(infrastructure): add storybook story for the kubernetes clusters page 2026-09-15 00:03:09 -03:00
Vinícius Lourenço
d377705104 feat(infrastructure): add shared storybook mocks for the kubernetes pages 2026-09-15 00:03:09 -03:00
Vinícius Lourenço
af9d1996fd refactor(infrastructure): rework the overview storybook story 2026-09-15 00:03:09 -03:00
Vinícius Lourenço
23c72ffebf refactor(home): update the storybook story 2026-09-15 00:03:09 -03:00
Vinícius Lourenço
7833cebcf8 feat(auth): add storybook story for the forgot password page 2026-09-15 00:03:08 -03:00
Vinícius Lourenço
94728a97cd feat(exceptions): add storybook story for the detail page 2026-09-15 00:03:08 -03:00
Vinícius Lourenço
70f87e1409 feat(system): add storybook story for the error fallback page 2026-09-15 00:03:08 -03:00
Vinícius Lourenço
31018871e2 feat(alerts): add storybook story for the edit page 2026-09-15 00:03:08 -03:00
Vinícius Lourenço
357360d6ae feat(dashboards): add storybook story for the list page 2026-09-15 00:03:08 -03:00
Vinícius Lourenço
45ed88ab90 feat(dashboards): add storybook story for the panel editor page 2026-09-15 00:03:08 -03:00
Vinícius Lourenço
5ce99578b8 feat(dashboards): add storybook story for the detail page 2026-09-15 00:03:07 -03:00
Vinícius Lourenço
864cdbc9d7 feat(dashboards): add shared storybook mock data 2026-09-15 00:03:07 -03:00
Vinícius Lourenço
4beddae6e5 feat(alerts): add storybook story for the create page 2026-09-15 00:03:07 -03:00
Vinícius Lourenço
81a8415c2c feat(external-apis): add storybook story 2026-09-15 00:03:07 -03:00
Vinícius Lourenço
79d9aad606 feat(exceptions): add storybook story for the list page 2026-09-15 00:03:07 -03:00
Vinícius Lourenço
a2f9def426 feat(alerts): add storybook story for the triggered page 2026-09-15 00:03:07 -03:00
Vinícius Lourenço
739bb75351 feat(alerts): add storybook story for the routing policies page 2026-09-15 00:03:06 -03:00
Vinícius Lourenço
7e80b97614 feat(alerts): add storybook story for the planned downtime page 2026-09-15 00:03:06 -03:00
Vinícius Lourenço
e8e0461b79 feat(alerts): add storybook story for the channels new page 2026-09-15 00:03:06 -03:00
Vinícius Lourenço
43e2f5396f feat(alerts): add storybook story for the channels edit page 2026-09-15 00:03:06 -03:00
Vinícius Lourenço
c50249808d feat(alerts): add storybook story for the channels list page 2026-09-15 00:03:06 -03:00
Vinícius Lourenço
6bfdc4b3b9 feat(alerts): add storybook story for the rules page 2026-09-15 00:03:05 -03:00
Vinícius Lourenço
2624ad4e8e feat(alerts): add storybook story for the overview page 2026-09-15 00:03:05 -03:00
Vinícius Lourenço
340d995012 feat(alerts): add storybook story for the history page 2026-09-15 00:03:05 -03:00
Vinícius Lourenço
25a59349f2 feat(alerts): add shared storybook mock data 2026-09-15 00:03:05 -03:00
Vinícius Lourenço
4bb8560fb2 feat(noz): add storybook story 2026-09-15 00:03:05 -03:00
Vinícius Lourenço
e38fa338c3 feat(metering): add storybook story for the usage explorer page 2026-09-15 00:03:05 -03:00
Vinícius Lourenço
2a914c83dc feat(services): add storybook story for the service map page 2026-09-15 00:03:04 -03:00
Vinícius Lourenço
10e96cb6ea feat(system): add storybook story for the not found page 2026-09-15 00:03:04 -03:00
Vinícius Lourenço
33ba6d1bda fix(ai-assistant): keep the composer height on auto while hidden 2026-09-15 00:03:04 -03:00
Vinícius Lourenço
3f0ad67071 docs(storybook): update the harness readme 2026-09-15 00:03:04 -03:00
Vinícius Lourenço
538626b8a4 feat(storybook): expand the shared query_range mock data 2026-09-15 00:03:04 -03:00
Vinícius Lourenço
7ed4266ae4 feat(storybook): sort the sidebar like the side nav 2026-09-15 00:03:04 -03:00
Vinícius Lourenço
6b5279537c feat(storybook): add the docs addon 2026-09-15 00:03:03 -03:00
Vinícius Lourenço
2ffad1b8ca feat(storybook): warn when a doc comment clobbers the story mocks 2026-09-15 00:03:03 -03:00
Vinícius Lourenço
950fe256b1 feat(storybook): support an initial location state 2026-09-15 00:03:03 -03:00
Vinícius Lourenço
bb168a01d0 fix(storybook): mirror the story search onto the preview url 2026-09-15 00:03:03 -03:00
Vinícius Lourenço
496532868d fix(storybook): let msw answer server-sent events 2026-09-15 00:03:03 -03:00
Vinícius Lourenço
efb7aa9262 fix(storybook): keep esbuild as the css minifier 2026-09-15 00:03:02 -03:00
Vinícius Lourenço
25a3388131 fix(storybook): set up the monaco loader in the preview 2026-09-15 00:03:02 -03:00
Vinícius Lourenço
a59d85e2e4 fix(storybook): point the redux singleton at the story store 2026-09-15 00:03:02 -03:00
Vinícius Lourenço
cbb034dd31 docs(storybook): update the page story skill 2026-09-14 23:51:14 -03:00
Vinícius Lourenço
b6bcee073f feat(infra-monitoring): add storybook 2026-09-14 23:51:14 -03:00
Vinícius Lourenço
6c626fd5b6 feat(storybook): add the hold-tooltips-open control 2026-09-14 23:51:14 -03:00
311 changed files with 35577 additions and 2095 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

@@ -21,5 +21,4 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
- [Service](service.md) - Managed service lifecycle with `factory.Service`
- [SQL](sql.md) - Database and SQL patterns
- [SQL Compiler](sqlcompiler.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`

View File

@@ -1,84 +0,0 @@
# SQL Compiler
List pages (dashboards, alert rules) share one filter DSL in their search bars. [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go) compiles a DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. Telemetry filters are a different pipeline. They stay on querybuilder's ClickHouse visitor.
The package owns everything generic about the language. A module adopting it writes exactly one thing: a `FieldResolver` that says which keys exist and what each maps to.
## What is the DSL?
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere. The shapes that matter:
- Boolean structure: parentheses > `NOT` > `AND` > `OR`; adjacent terms with no connective are an implicit `AND`.
- Comparisons: `key OP value`, e.g. `name CONTAINS cpu`, `created_at > '2025-01-01T00:00:00Z'`, `labels.team IN ('infra', 'platform')`, `labels.env EXISTS`. See the `comparison` rule in the grammar for the full operator list.
- Free text: a bare or quoted token with no key. Quoting is the escape hatch for a phrase that looks like DSL.
- Values: bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
## What does the framework already cover?
```go
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
```
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. On top of parsing, the package handles:
- Syntax errors, collected with line/column positions instead of failing on the first one.
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
- Predicate builders the resolver calls back into:
| Builder | Handles |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
| `BuildBoolComparison` | `= true/false` |
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
- Typed value extraction (`ExtractSingleStringValue`, `ExtractStringValueList`, ...) with accumulated errors: the user sees every problem in the query at once.
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
## When do I write a FieldResolver?
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
```go
type FieldResolver interface {
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
ResolveFreeText(v *Visitor, value string) string
}
```
Rules for implementing one:
- Declare the key namespace in `pkg/types/<domain>`: `DSLKey` constants plus a `ReservedOps` map of key to allowed operators. The list API advertises these as `reservedKeywords`, so the frontend suggestions never go stale.
- Reject a disallowed operator with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all errors.
- Map a key to a column expression through `v.Formatter` (`JSONExtractString`, `LowerExpression`), never by hand, so the expression is valid on both SQLite and Postgres.
- Delegate the predicate to the `Build*` helpers above; do not hand-build SQL or manage arguments yourself.
- For a key that lives in a relation table (dashboard tags, rule labels), build an `EXISTS` subquery on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. For a negative operator, build the positive predicate and toggle `NotExists` on the outer builder.
The reference implementation is the dashboards resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go): reserved keys backed by columns and JSON paths, tag keys via `EXISTS` subqueries, free text across name, description and tags.
## How to wire it in?
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code, as in [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go):
```go
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
}
```
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
## What should I remember?
- One DSL, one compiler; a new list page adds a `FieldResolver`, not a new parser or SQL layer.
- Keys and allowed operators live in `pkg/types/<domain>` and are advertised as `reservedKeywords`.
- Column expressions go through `v.Formatter`; predicates go through the `Build*` helpers.
- Report problems with `v.AddError` and return `""`; errors accumulate.
- Relation-table keys use `EXISTS` subqueries on their own builder; negation toggles `NotExists`.
- This package is for the relational store only; telemetry filtering stays in querybuilder.

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,134 @@ 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',
'Legacy Explorer',
],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details', 'Legacy Explorer'],
'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 +205,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,7 +253,7 @@ const preview: Preview = {
world.apply();
world.install(worker);
await ready;
await Promise.all([ready, translationsReady]);
},
],
beforeEach: () => {

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

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

@@ -359,6 +359,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.8.4))(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.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
@@ -2177,6 +2180,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==}
@@ -3751,6 +3760,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:
@@ -4174,6 +4192,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==}
@@ -12148,6 +12169,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
@@ -13559,6 +13586,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.8.4))(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.8.4))(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.8.4))(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.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
@@ -14004,6 +14050,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

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

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

@@ -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,120 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ComponentProps, ComponentType } from 'react';
import removeLocalStorageKey from 'api/browser/localstorage/remove';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { screen, userEvent } 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 },
},
// The settings announcement covers the panel it points at, and it is a
// first-run state rather than the panel's own; `SettingsAnnouncement` is the
// story that keeps it.
beforeEach: (): void => {
setLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, 'false');
},
} satisfies Meta<typeof QuickFilters>;
export default meta;
type Story = StoryObj<typeof meta>;
type TooltipsStory = StoryObj<
ComponentProps<typeof QuickFilters> & GlobalMockArgs
>;
/** Interaction: the settings panel is opened through the admin settings control. */
export const SettingsOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(await screen.findByTestId('settings-icon'));
await screen.findByText('Edit quick filters');
},
};
/** Mutation: changing the settings list reveals the fixed save and discard footer. */
export const SettingsDirtyFooter: Story = {
play: async (): Promise<void> => {
await userEvent.click(await screen.findByTestId('settings-icon'));
await userEvent.click(await screen.findByRole('button', { name: 'Add' }));
await screen.findByRole('button', { name: 'Save changes' });
},
};
/** First run: the one-off announcement pointing an admin at the settings control. */
export const SettingsAnnouncement: Story = {
beforeEach: (): void => {
removeLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
},
};
/** 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

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

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

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

@@ -1,57 +0,0 @@
@use '../../../../styles/scrollbar' as *;
.container {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
// Let the flex children shrink below their content height so the series list
// scrolls within the capped legend height instead of overflowing the wrapper
// (the default min-height:auto would block the shrink).
min-height: 0;
}
.scroller {
// flex:1 + min-height:0 pins the scroller to the space left after the
// toolbar instead of growing to fit every row.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
padding-right: var(--spacing-2);
overflow-x: hidden;
overscroll-behavior: contain;
@include custom-scrollbar;
}
.gridItem {
// Or the item keeps its content width and the label never ellipsizes.
min-width: 0;
max-width: 100%;
}
.gridList {
min-width: 0;
display: grid;
grid-auto-flow: row;
// min() keeps the column inside a narrow panel, where a wider one would push
// the row's actions out of the clipped area.
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
);
gap: var(--spacing-1) var(--spacing-4);
}
.container.isRight .gridList {
grid-template-columns: 1fr;
}
.emptyState {
padding: var(--spacing-16) 0;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
text-align: center;
}

View File

@@ -0,0 +1,204 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
padding-right: 8px;
.legend-search-input {
font-size: 12px;
}
}
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
.legend-empty-state {
font-size: 12px;
color: var(--l2-foreground);
text-align: center;
padding: 12px;
padding: 2rem 0;
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
.virtuoso-grid-list {
min-width: 0;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
column-gap: 12px;
}
.virtuoso-grid-item {
min-width: 0;
}
&.legend-virtuoso-container-right {
.virtuoso-grid-list {
grid-template-columns: 1fr;
}
}
&.legend-virtuoso-container-single-row {
.virtuoso-grid-list {
grid-template-columns: repeat(
auto-fit,
minmax(var(--legend-average-width, 240px), max-content)
);
justify-content: center;
}
}
@include custom-scrollbar;
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
&.legend-item-right {
width: 100%;
}
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-item-label-trigger {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.legend-marker {
border-width: 2px;
border-style: solid;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
user-select: none;
}
.legend-copy-button {
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
// Shrink the shared icon Button (defaults to a 2rem square) to the
// compact legend row via its size tokens.
--button-height: auto;
--button-width: auto;
--button-padding: 2px;
opacity: 0;
flex-shrink: 0;
color: var(--l2-foreground);
border-radius: 4px;
transition:
opacity 0.15s ease,
color 0.15s ease;
&:hover {
color: var(--l1-foreground);
}
}
&:hover {
background: var(--l3-background);
.legend-copy-button {
opacity: 1;
}
}
}

View File

@@ -1,106 +1,139 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendAction, LegendPosition, LegendProps } from '../types';
import { LegendPosition, LegendProps } from '../types';
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
import LegendRow from './LegendRow';
import LegendToolbar from './LegendToolbar';
import { filterLegendItems, getShownSeriesState } from './utils';
import './Legend.styles.scss';
import styles from './Legend.module.scss';
export const MAX_LEGEND_WIDTH = 240;
/**
* Presentational legend, source-agnostic: the uPlot charts feed it via
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
* Presentational legend. Renders the supplied `items` (markers + labels, an
* optional copy button, and a search box for the RIGHT position) and delegates
* all interaction to the container handlers. Source-agnostic — the uPlot
* charts feed it via UPlotLegend; Pie feeds it directly.
*/
export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex,
onAction,
onClick,
onMouseMove,
onMouseLeave,
showCopy = true,
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [filterQuery, setFilterQuery] = useState('');
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
const isRightPosition = position === LegendPosition.RIGHT;
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const { width: containerWidth } = useResizeObserver(legendContainerRef);
const { visibleCount, soleShownSeriesIndex } = useMemo(
() => getShownSeriesState(items),
[items],
);
const isSingleRow = useMemo(() => {
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
return false;
}
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, items.length, position, containerWidth]);
// A bottom legend gets two rows; spending one on chrome costs more chart than
// the readout is worth.
const showToolbar = isRightPosition && items.length > 0;
const showFilter = showToolbar;
const visibleLegendItems = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return items;
}
const effectiveQuery = showFilter ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
[items, effectiveQuery],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
const isAllShown = visibleCount === items.length;
// A row that unmounts under the pointer never fires its own mouseleave.
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
const query = legendSearchQuery.trim().toLowerCase();
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<LegendRow
key={item.seriesIndex}
item={item}
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
isAllShown={isAllShown}
isFocused={focusedSeriesIndex === item.seriesIndex}
showCopy={showCopy}
onAction={onAction}
/>
),
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
(item: LegendItem): JSX.Element => {
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
return (
<div
key={item.seriesIndex}
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: markerColor }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</TooltipSimple>
{showCopy && (
<CopyButton
value={item.label ?? ''}
size={12}
className="legend-copy-button"
ariaLabel={`Copy ${item.label}`}
testId="legend-copy"
/>
)}
</div>
);
},
[focusedSeriesIndex, position, showCopy],
);
const isEmptyState = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return false;
}
return visibleLegendItems.length === 0;
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
return (
<div
ref={legendContainerRef}
className={cx(styles.container, {
[styles.isRight]: isRightPosition,
})}
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
onMouseLeave={handleMouseLeave}
data-testid="legend-container"
className="legend-container"
onClick={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
>
{showToolbar && (
<LegendToolbar
visibleCount={visibleCount}
totalCount={items.length}
showFilter={showFilter}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>
{searchEnabled && (
<div className="legend-search-container">
<Input
allowClear
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
data-testid="legend-search-input"
className="legend-search-input"
/>
</div>
)}
{isEmptyState ? (
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
<div className="legend-empty-state">
No series found matching &quot;{legendSearchQuery}&quot;
</div>
) : (
<VirtuosoGrid
className={styles.scroller}
listClassName={styles.gridList}
itemClassName={styles.gridItem}
className={cx(
'legend-virtuoso-container',
`legend-virtuoso-container-${position.toLowerCase()}`,
{ 'legend-virtuoso-container-single-row': isSingleRow },
)}
data={visibleLegendItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>

View File

@@ -1,170 +0,0 @@
.row {
// Width of the revealed actions, given up by the label on hover only.
--legend-actions-reserve: 78px;
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
height: 28px;
padding: 0 var(--spacing-3) 0 var(--spacing-4);
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
border-radius: var(--radius);
cursor: pointer;
transition: background 160ms linear;
&:hover,
&:focus-visible {
background: var(--l3-background);
}
}
.isFocused {
background: var(--l3-background);
}
.marker {
// Reads as a checkbox without being one: filled when shown, hollow when
// hidden, deliberately not a check glyph.
flex: 0 0 auto;
box-sizing: border-box;
position: relative;
// Above the actions, so a narrow row's chip never covers the series colour.
z-index: 4;
width: 12px;
height: 12px;
padding: 0;
appearance: none;
border-width: 1.5px;
border-style: solid;
border-radius: var(--radius);
cursor: pointer;
transition:
transform 200ms ease,
box-shadow 200ms ease,
background-color 160ms linear,
opacity 160ms linear;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
}
&:active {
transform: scale(0.9);
}
&:disabled {
cursor: default;
}
&:disabled:hover {
transform: none;
box-shadow: none;
}
}
// Series names run long and have no spaces to break on, so they need both a
// cap and a break rule or the tooltip becomes one panel-wide line.
.rowTooltip {
max-width: 420px;
white-space: normal;
overflow-wrap: anywhere;
}
.label {
flex: 1 1 auto;
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
user-select: none;
}
.isHidden .marker {
opacity: 0.45;
}
.isHidden .label {
color: var(--l3-foreground);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* Row actions */
.actions {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-3);
z-index: 3;
display: flex;
align-items: center;
gap: var(--spacing-2);
padding-left: var(--spacing-5, 10px);
// Sits on the row's hover background and masks the label's tail behind it.
background: var(--l3-background);
box-shadow: -8px 0 8px var(--l3-background);
opacity: 0;
transform: translateX(10px);
pointer-events: none;
transition:
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
}
// :focus-visible, not :focus-within — the latter also matches the click that
// just toggled the series, leaving the actions stuck open.
.row:hover .actions,
.row:focus-visible .actions,
.row:has(:focus-visible) .actions {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
// The cap spares rows sized to their actions, not their label.
.row:hover .label,
.row:focus-visible .label,
.row:has(:focus-visible) .label {
padding-right: min(var(--legend-actions-reserve), 50%);
}
.actionTrigger {
display: inline-flex;
}
.actionButton {
--button-height: 20px;
--button-width: 20px;
--button-padding: 0;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
flex-shrink: 0;
}
.actionButton.scopeButton {
--button-width: auto;
--button-padding: 0 var(--spacing-4);
--button-font-size: var(--font-size-xs);
--button-border-radius: calc(var(--radius) * 4);
--button-base-border-width: 1px;
// --l2-border is the actions bar's own background: one step up reads.
--button-base-border-color: var(--l3-border);
border-style: solid;
}
.actionButton.scopeButton:hover {
border-color: var(--l2-border);
}

View File

@@ -1,182 +0,0 @@
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendAction, OnLegendAction } from '../types';
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
import styles from './LegendRow.module.scss';
export interface LegendRowProps {
item: LegendItem;
/** The only series currently shown, so hiding it is refused. */
isSoleShown: boolean;
/** Nothing is hidden, so the row's action can only narrow the selection. */
isAllShown: boolean;
isFocused: boolean;
showCopy: boolean;
onAction: OnLegendAction;
}
/**
* One legend row. The marker is its own target for excluding a single series —
* the one thing the row click can't do while everything is showing. The actions
* overlay the label's tail rather than taking layout width, and their reveal is
* pure CSS.
*/
function LegendRow({
item,
isSoleShown,
isAllShown,
isFocused,
showCopy,
onAction,
}: LegendRowProps): JSX.Element {
const { seriesIndex, show } = item;
const label = item.label ?? '';
const isShowAllAction = show && !isAllShown;
const scopeActionLabel = isShowAllAction
? 'Show all series'
: 'Show only current series';
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
// a usable CSS colour for the marker.
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
/** Everything showing -> isolate; showing alone -> restore all. */
const handleRowClick = useCallback((): void => {
if (isSoleShown) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
seriesIndex,
});
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
const handleMarkerClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
onAction({ type: LegendAction.TOGGLE, seriesIndex });
},
[onAction, seriesIndex],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
// Let the row actions handle their own keys.
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleRowClick();
}
},
[handleRowClick],
);
const handleScopeClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
if (isShowAllAction) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({ type: LegendAction.SHOW_ONLY, seriesIndex });
},
[isShowAllAction, onAction, seriesIndex],
);
const handleMouseEnter = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex }),
[onAction, seriesIndex],
);
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
return (
<div
className={cx(styles.row, {
[styles.isHidden]: !show,
[styles.isFocused]: isFocused,
})}
data-legend-item-id={seriesIndex}
data-testid={`legend-item-${seriesIndex}`}
role="switch"
tabIndex={0}
aria-checked={show}
aria-label={label}
onClick={handleRowClick}
onKeyDown={handleKeyDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
className={styles.marker}
style={{
borderColor: seriesColor,
backgroundColor: show ? seriesColor : 'transparent',
}}
onClick={handleMarkerClick}
disabled={isSoleShown}
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
data-is-legend-marker={true}
data-testid={`legend-marker-${seriesIndex}`}
/>
<TooltipSimple
title={label}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
<span className={styles.label}>{label}</span>
</TooltipSimple>
<div className={styles.actions}>
<TooltipSimple
title={scopeActionLabel}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
{/* Radix's asChild merge strips the button's own data-testid. */}
<span className={styles.actionTrigger}>
<Button
variant="ghost"
color="secondary"
size="sm"
className={cx(styles.actionButton, styles.scopeButton)}
onClick={handleScopeClick}
aria-label={scopeActionLabel}
testId={`legend-scope-${seriesIndex}`}
>
{isShowAllAction ? 'All' : 'Only'}
</Button>
</span>
</TooltipSimple>
{showCopy && (
<CopyButton
value={label}
size={13}
className={styles.actionButton}
ariaLabel={`Copy ${label}`}
testId={`legend-copy-${seriesIndex}`}
/>
)}
</div>
</div>
);
}
export default memo(LegendRow);

View File

@@ -1,35 +0,0 @@
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-5, 10px);
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
flex-shrink: 0;
> * {
flex: 0 1 auto;
}
}
.status {
// Wraps rather than losing the count at its end.
min-width: 0;
font-family: var(--font-mono);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.searchContainer {
flex-shrink: 0;
width: 100%;
padding-right: var(--spacing-4);
padding-bottom: var(--spacing-5, 10px);
}
.searchInput {
font-size: var(--font-size-xs);
}
.searchIcon {
color: var(--l3-foreground);
}

View File

@@ -1,56 +0,0 @@
import { ChangeEvent, useCallback } from 'react';
import { Input } from 'antd';
import { Search } from '@signozhq/icons';
import styles from './LegendToolbar.module.scss';
export interface LegendToolbarProps {
visibleCount: number;
totalCount: number;
/** Search is intrinsic to the right-positioned legend. */
showFilter: boolean;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
}
/** Legend chrome: the series search box and the "Showing N of M" readout. */
export default function LegendToolbar({
visibleCount,
totalCount,
showFilter,
filterQuery,
onFilterQueryChange,
}: LegendToolbarProps): JSX.Element {
const handleFilterChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void =>
onFilterQueryChange(event.target.value),
[onFilterQueryChange],
);
return (
<>
{showFilter && (
<div className={styles.searchContainer}>
<Input
allowClear
prefix={<Search size={12} className={styles.searchIcon} />}
placeholder="Search..."
value={filterQuery}
onChange={handleFilterChange}
className={styles.searchInput}
data-testid="legend-search-input"
/>
</div>
)}
<div className={styles.toolbar}>
<span
className={styles.status}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
</div>
</>
);
}

View File

@@ -8,8 +8,8 @@ import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the series interactions from the
* plot context (useLegendActions), then renders the presentational Legend.
* from the chart config (useLegendsSync) and the toggle/focus interactions from
* the plot context (useLegendActions), then renders the presentational Legend.
* Must be rendered inside a PlotContextProvider.
*/
export default function UPlotLegend({
@@ -17,8 +17,13 @@ export default function UPlotLegend({
config,
averageLegendWidth,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
const onAction = useLegendActions();
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
@@ -28,7 +33,9 @@ export default function UPlotLegend({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onAction}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
);
}

View File

@@ -1,419 +0,0 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../../hooks/useLegendActions';
import UPlotLegend from '../UPlotLegend';
import { LegendAction, LegendActionPayload, LegendPosition } from '../../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
/** The payloads of one action type, in dispatch order. */
const dispatched = (
onAction: jest.Mock,
type: LegendAction,
): LegendActionPayload[] =>
onAction.mock.calls
.map(([payload]) => payload as LegendActionPayload)
.filter((payload) => payload.type === type);
describe('UPlotLegend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onAction: jest.Mock;
beforeEach(() => {
onAction = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue(onAction);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {
it('renders the search input on a RIGHT legend', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
// The row interactions are the same in both placements.
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
});
it('renders the marker with the series colour, filled only when shown', () => {
renderLegend(LegendPosition.RIGHT);
expect(
document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
),
).toHaveStyle({
'border-color': '#ff0000',
'background-color': '#ff0000',
});
// Hidden series read as an empty checkbox.
expect(
document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
),
).toHaveStyle({ 'background-color': 'transparent' });
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('status readout', () => {
it('reports how many series are showing', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 2 of 3 series',
);
});
});
describe('filter behavior', () => {
it('filters legend items based on the query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'a');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows the empty state when nothing matches', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('ignores a whitespace-only query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('row interactions', () => {
const allShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1], show: true },
2: { ...baseLegendItemsMap[2] },
};
const mockAllShown = (): void => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: allShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
};
it('isolates the series when everything is showing', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
// Nothing the user can see is there to exclude, so the click means Only.
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('toggles the series once something is already hidden', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('excludes just that series when its marker is clicked', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-marker-0'));
// The marker is the one way to exclude a single series while
// everything is showing — the row click isolates instead.
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('stops the marker offering to hide the last series showing', () => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
});
it('labels the marker with what clicking it does', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
'aria-label',
'Hide A',
);
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
'aria-label',
'Show B',
);
});
it('adds the clicked series to the selection while one is alone', async () => {
const user = userEvent.setup();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
// Series 0 is showing alone; clicking another row builds the selection
// up rather than moving the isolation.
await user.click(screen.getByText('B'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('toggles the series on Enter and Space', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
row.focus();
await user.keyboard('{Enter}');
await user.keyboard(' ');
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
});
it('reflects visibility on the row for assistive tech', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
'aria-checked',
'true',
);
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
'aria-checked',
'false',
);
});
it('restores every series from All without also toggling the row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
// Series 0 is shown while B is hidden, so its action is All.
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('isolates the series from Only on a hidden row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-1'));
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('highlights the hovered series and clears it on leave', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
await user.hover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: 0,
});
await user.unhover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: null,
});
});
});
describe('one-series state', () => {
const soleShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
};
beforeEach(() => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: soleShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
});
it('offers All on the shown row and Only on the hidden ones', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
});
it('restores everything from All', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
});
it('restores everything when the row showing alone is clicked again', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
});
});

View File

@@ -1,45 +0,0 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import { filterLegendItems, getShownSeriesState } from '../utils';
const items = (shown: boolean[]): LegendItem[] =>
shown.map((show, index) => ({
seriesIndex: index + 1,
label: `series-${index}`,
color: '#000',
show,
}));
describe('getShownSeriesState', () => {
it('counts the shown series', () => {
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
visibleCount: 2,
soleShownSeriesIndex: null,
});
});
it('names the series when exactly one is shown', () => {
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
visibleCount: 1,
soleShownSeriesIndex: 2,
});
});
it('reports nothing shown', () => {
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
visibleCount: 0,
soleShownSeriesIndex: null,
});
});
});
describe('filterLegendItems', () => {
it('matches case-insensitively on the label', () => {
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
});
it('returns every item for a blank query', () => {
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
});
});

View File

@@ -1,20 +0,0 @@
/** Widest a single legend item is allowed to get when sizing the legend grid. */
export const MAX_LEGEND_WIDTH = 240;
/**
* Enough for a row to contain its own hover actions, which a short label would
* otherwise size a column too narrow for. Little room for the label is intended.
*/
export const MIN_LEGEND_ITEM_WIDTH = 110;
/** Marker + row padding, on top of the estimated label width. */
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
/** Must match `.row`'s height and the grid's row gap, or the reserved
* rectangle clips a row. */
export const LEGEND_ROW_HEIGHT = 28;
export const LEGEND_ROW_GAP = 2;
export const LEGEND_MAX_BOTTOM_ROWS = 2;
/** Hover delay before a row's full-name tooltip opens. */
export const LEGEND_TOOLTIP_DELAY_MS = 500;

View File

@@ -1,34 +0,0 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
export interface ShownSeriesState {
visibleCount: number;
/** The series index when exactly one series is shown, else null. */
soleShownSeriesIndex: number | null;
}
/**
* Driven by what is actually shown, never a remembered isolation: hiding series
* one at a time down to a single one is the same state as "Only".
*/
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
const shown = items.filter((item) => item.show);
return {
visibleCount: shown.length,
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
};
}
export function filterLegendItems(
items: LegendItem[],
query: string,
): LegendItem[] {
const normalisedQuery = query.trim().toLowerCase();
if (!normalisedQuery) {
return items;
}
return items.filter((item) =>
item.label?.toLowerCase().includes(normalisedQuery),
);
}

View File

@@ -12,11 +12,10 @@
}
}
// Matches the legend row's marker.
.uplotTooltipItemMarker {
border-radius: var(--radius);
border-radius: 50%;
border-style: solid;
border-width: 1.5px;
border-width: 2px;
width: 12px;
height: 12px;
box-sizing: border-box;
@@ -31,23 +30,11 @@
justify-content: space-between;
}
// The legend's mono type; the container's Inter stays for the header.
.uplotTooltipItemLabel,
.uplotTooltipItemValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.uplotTooltipItemLabel {
white-space: normal;
overflow-wrap: anywhere;
}
.uplotTooltipItemValue {
white-space: nowrap;
}
.uplotTooltipItemContentSeparator {
flex: 1;
border-width: 0.5px;

View File

@@ -25,7 +25,7 @@ export default function TooltipItem({
>
<div
className={Styles.uplotTooltipItemMarker}
style={{ borderColor: item.color, backgroundColor: item.color }}
style={{ borderColor: item.color }}
data-is-legend-marker={true}
data-testid={markerTestId}
/>
@@ -39,7 +39,7 @@ export default function TooltipItem({
className={Styles.uplotTooltipItemContentSeparator}
style={{ borderColor: item.color }}
/>
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
<span>{item.tooltipValue}</span>
</div>
</div>
);

View File

@@ -0,0 +1,216 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import UPlotLegend from '../Legend/UPlotLegend';
import { LegendPosition } from '../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
describe('UPlotLegend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onLegendClick: jest.Mock;
let onLegendMouseMove: jest.Mock;
let onLegendMouseLeave: jest.Mock;
let onFocusSeries: jest.Mock;
beforeEach(() => {
onLegendClick = jest.fn();
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue({
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onFocusSeries,
});
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {
it('renders search input when legend position is RIGHT', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('does not render search input when legend position is BOTTOM (default)', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
});
it('renders the marker with the correct border color', () => {
renderLegend(LegendPosition.RIGHT);
const legendMarker = document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
) as HTMLElement;
expect(legendMarker).toHaveStyle({
'border-color': '#ff0000',
});
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('search behavior (RIGHT position)', () => {
it('filters legend items based on search query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'A');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows empty state when no legend items match the search query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('legend actions', () => {
it('calls onLegendClick when a legend item is clicked', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(onLegendClick).toHaveBeenCalledTimes(1);
});
it('calls mouseMove when the mouse moves over a legend item', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const legendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
await user.hover(legendItem);
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
});
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const container = document.querySelector('.legend-container') as HTMLElement;
await user.hover(container);
await user.unhover(container);
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { MouseEventHandler, ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
@@ -115,39 +115,26 @@ export enum LegendPosition {
export interface LegendConfig {
position: LegendPosition;
}
export enum LegendAction {
TOGGLE = 'toggle',
SHOW_ONLY = 'showOnly',
SHOW_ALL = 'showAll',
HOVER = 'hover',
}
/** Everything the legend can ask of its container, as one dispatch. */
export type LegendActionPayload =
/** Row click / Space / Enter / marker click: hide or show that one series. */
| { type: LegendAction.TOGGLE; seriesIndex: number }
/** Show that series alone. */
| { type: LegendAction.SHOW_ONLY; seriesIndex: number }
/** Leave the narrowed selection and show every series. */
| { type: LegendAction.SHOW_ALL }
/** Row hover, for the chart-side highlight; null on leave. */
| { type: LegendAction.HOVER; seriesIndex: number | null };
export type OnLegendAction = (payload: LegendActionPayload) => void;
/**
* Presentational legend props. Source-agnostic: it renders whatever `items`
* it's given and delegates interaction to the container handlers, so it serves
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
*/
export interface LegendProps {
items: LegendItem[];
/** Legend placement; always supplied by the container. */
position: LegendPosition;
averageLegendWidth?: number;
/** Series index highlighted by the chart cursor. */
/** Series index to highlight (hovered/focused). */
focusedSeriesIndex: number | null;
onAction: OnLegendAction;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick: MouseEventHandler<HTMLDivElement>;
onMouseMove: MouseEventHandler<HTMLDivElement>;
onMouseLeave: () => void;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
}

View File

@@ -6,11 +6,6 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
/** Stroke-width multiplier applied to the series highlighted from the legend. */
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,

View File

@@ -8,10 +8,6 @@ import {
useMemo,
useRef,
} from 'react';
import {
LEGEND_HIGHLIGHT_DIM_ALPHA,
LEGEND_HIGHLIGHT_WIDTH_RATIO,
} from 'lib/uPlotV2/constants';
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type uPlot from 'uplot';
@@ -24,26 +20,12 @@ export interface IPlotContext {
setPlotContextInitialState: (state: PlotContextInitialState) => void;
onToggleSeriesVisibility: (seriesIndex: number) => void;
onToggleSeriesOnOff: (seriesIndex: number) => void;
/** Show this series alone. */
onShowOnlySeries: (seriesIndex: number) => void;
/** Show every series again. */
onShowAllSeries: () => void;
onFocusSeries: (seriesIndex: number | null) => void;
/** Lift one series above the rest (dim + thicken) without changing visibility. */
onHighlightSeries: (seriesIndex: number | null) => void;
syncSeriesVisibilityToLocalStorage: () => void;
}
export const PlotContext = createContext<IPlotContext | null>(null);
/** Data series (index 0 is the x-axis) currently drawn. */
const countShownSeries = (plot: uPlot): number =>
plot.series.reduce(
(count, series, index) =>
index > 0 && series.show !== false ? count + 1 : count,
0,
);
export const PlotContextProvider = ({
children,
}: PropsWithChildren): JSX.Element => {
@@ -51,9 +33,6 @@ export const PlotContextProvider = ({
const activeSeriesIndex = useRef<number | undefined>(undefined);
const idRef = useRef<string | undefined>(undefined);
const shouldSavePreferencesRef = useRef<boolean>(false);
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
const highlightedSeriesIndexRef = useRef<number | null>(null);
const setPlotContextInitialState = useCallback(
({
@@ -64,8 +43,6 @@ export const PlotContextProvider = ({
uPlotInstanceRef.current = uPlotInstance;
idRef.current = id;
activeSeriesIndex.current = undefined;
baseSeriesWidthsRef.current = new Map();
highlightedSeriesIndexRef.current = null;
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
},
[],
@@ -87,54 +64,6 @@ export const PlotContextProvider = ({
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
}, []);
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
highlightedSeriesIndexRef.current = seriesIndex;
plot.series.forEach((series, index) => {
if (index === 0) {
return;
}
if (!baseSeriesWidthsRef.current.has(index)) {
baseSeriesWidthsRef.current.set(index, series.width);
}
const baseWidth = baseSeriesWidthsRef.current.get(index);
const isHighlighted = index === seriesIndex;
/* eslint-disable no-param-reassign */
series.alpha =
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
series.width =
isHighlighted && baseWidth !== undefined
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
: baseWidth;
/* eslint-enable no-param-reassign */
});
// Only the stroke style changed, so the cached paths stay valid.
plot.redraw(false);
}, []);
/**
* Leaving the dim on a hidden series leaves every other one faded, which
* reads as an isolation rather than as one series being excluded.
*/
const clearHighlightIfHidden = useCallback((): void => {
const plot = uPlotInstanceRef.current;
const highlightedIndex = highlightedSeriesIndexRef.current;
if (!plot || highlightedIndex === null) {
return;
}
if (plot.series[highlightedIndex]?.show === false) {
onHighlightSeries(null);
}
}, [onHighlightSeries]);
const onToggleSeriesVisibility = useCallback(
(seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
@@ -174,61 +103,14 @@ export const PlotContextProvider = ({
if (!series) {
return;
}
// An empty chart is never worth reaching.
const isHiding = series.show !== false;
if (isHiding && countShownSeries(plot) <= 1) {
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
[syncSeriesVisibilityToLocalStorage],
);
/** Applies `resolveShow` to every data series in one batch, then persists. */
const setSeriesVisibility = useCallback(
(resolveShow: (seriesIndex: number) => boolean): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
activeSeriesIndex.current = undefined;
plot.batch(() => {
plot.series.forEach((_, index) => {
if (index === 0) {
return;
}
plot.setSeries(index, { show: resolveShow(index) });
});
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
});
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
);
const onShowOnlySeries = useCallback(
(seriesIndex: number): void => {
setSeriesVisibility((index) => index === seriesIndex);
},
[setSeriesVisibility],
);
const onShowAllSeries = useCallback((): void => {
setSeriesVisibility(() => true);
}, [setSeriesVisibility]);
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
@@ -249,20 +131,14 @@ export const PlotContextProvider = ({
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
}),
[
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
],
);

View File

@@ -26,7 +26,6 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
series,
batch: jest.fn((fn: () => void) => fn()),
setSeries: jest.fn(),
redraw: jest.fn(),
}) as unknown as uPlot;
interface TestComponentProps {
@@ -45,10 +44,7 @@ const TestComponent = ({
syncSeriesVisibilityToLocalStorage,
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
} = usePlotContext();
const handleInit = (): void => {
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
@@ -88,13 +84,6 @@ const TestComponent = ({
>
Toggle on/off 1
</button>
<button
type="button"
data-testid="toggle-on-off-2"
onClick={(): void => onToggleSeriesOnOff(2)}
>
Toggle on/off 2
</button>
<button
type="button"
data-testid="toggle-on-off-5"
@@ -109,34 +98,6 @@ const TestComponent = ({
>
Focus series
</button>
<button
type="button"
data-testid="show-only-1"
onClick={(): void => onShowOnlySeries(1)}
>
Show only 1
</button>
<button
type="button"
data-testid="show-all"
onClick={(): void => onShowAllSeries()}
>
Show all
</button>
<button
type="button"
data-testid="highlight-1"
onClick={(): void => onHighlightSeries(1)}
>
Highlight 1
</button>
<button
type="button"
data-testid="clear-highlight"
onClick={(): void => onHighlightSeries(null)}
>
Clear highlight
</button>
</div>
);
};
@@ -312,7 +273,6 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -364,7 +324,6 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -384,48 +343,6 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('refuses to hide the last series showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// An empty chart is never a state worth reaching.
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('still shows a hidden series when only one is left showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: false },
{ label: 'Memory', show: true },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
});
});
describe('onFocusSeries', () => {
@@ -464,193 +381,4 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
});
});
describe('onShowOnlySeries', () => {
const renderWithSeries = (
series: MockSeries[],
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
const user = userEvent.setup();
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
return { plot, user };
};
it('hides every other series, leaving the x-axis alone', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
});
it('keeps isolating the series that is already the only one shown', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
});
});
describe('onShowAllSeries', () => {
it('shows every hidden series again', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-all'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
});
});
describe('onHighlightSeries', () => {
const series = (): MockSeries[] => [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true, width: 2 },
{ label: 'Memory', show: true, width: 2 },
];
it('dims the other series and thickens the highlighted one', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(3.2);
expect(plot.series[2].alpha).toBe(0.16);
expect(plot.series[2].width).toBe(2);
// Only the stroke changed, so the cached paths are reused.
expect(plot.redraw).toHaveBeenCalledWith(false);
});
it('restores every series when the highlight is cleared', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('clear-highlight'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(2);
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('drops the dim when the highlighted series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// Otherwise every remaining series stays faded and the panel reads as
// an isolation instead of one series being excluded.
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('keeps the dim when a different series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-2'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[2].alpha).toBe(0.16);
});
it('leaves visibility untouched', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,4 @@
import { renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
@@ -12,12 +11,10 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
describe('useLegendActions', () => {
let onToggleSeriesVisibility: jest.Mock;
let onToggleSeriesOnOff: jest.Mock;
let onShowOnlySeries: jest.Mock;
let onShowAllSeries: jest.Mock;
let onFocusSeries: jest.Mock;
let onHighlightSeries: jest.Mock;
let onFocusSeriesPlot: jest.Mock;
let setPlotContextInitialState: jest.Mock;
let syncSeriesVisibilityToLocalStorage: jest.Mock;
let setFocusedSeriesIndexMock: jest.Mock;
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
beforeAll(() => {
@@ -40,20 +37,15 @@ describe('useLegendActions', () => {
beforeEach(() => {
onToggleSeriesVisibility = jest.fn();
onToggleSeriesOnOff = jest.fn();
onShowOnlySeries = jest.fn();
onShowAllSeries = jest.fn();
onFocusSeries = jest.fn();
onHighlightSeries = jest.fn();
onFocusSeriesPlot = jest.fn();
setPlotContextInitialState = jest.fn();
syncSeriesVisibilityToLocalStorage = jest.fn();
setFocusedSeriesIndexMock = jest.fn();
mockUsePlotContext.mockReturnValue({
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
onFocusSeries: onFocusSeriesPlot,
setPlotContextInitialState,
syncSeriesVisibilityToLocalStorage,
});
@@ -61,65 +53,149 @@ describe('useLegendActions', () => {
cancelAnimationFrameSpy.mockClear();
});
describe('visibility actions', () => {
it('toggles a single series on row click', () => {
const { result } = renderHook(() => useLegendActions());
const createMouseEvent = (options: {
legendItemId?: number;
isMarker?: boolean;
}): any => {
const { legendItemId, isMarker = false } = options;
result.current({ type: LegendAction.TOGGLE, seriesIndex: 2 });
return {
target: {
dataset: {
...(isMarker ? { isLegendMarker: 'true' } : {}),
},
closest: jest.fn(() =>
legendItemId !== undefined
? { dataset: { legendItemId: String(legendItemId) } }
: null,
),
},
};
};
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
// The row must never isolate — that is what "Only" is for.
describe('onLegendClick', () => {
it('toggles series visibility when clicking on legend label', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
});
it('toggles series on/off when clicking on marker', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(
createMouseEvent({ legendItemId: 0, isMarker: true }),
);
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
it('forwards the Only and All actions to the plot', () => {
const { result } = renderHook(() => useLegendActions());
it('does nothing when click target is not inside a legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current({ type: LegendAction.SHOW_ONLY, seriesIndex: 1 });
result.current({ type: LegendAction.SHOW_ALL });
result.current.onLegendClick(createMouseEvent({}));
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
expect(onShowAllSeries).toHaveBeenCalled();
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
});
describe('hover highlight', () => {
it('highlights the hovered series', () => {
const { result } = renderHook(() => useLegendActions());
describe('onFocusSeries', () => {
it('schedules focus update and calls plot focus handler via mouse move', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
expect(onHighlightSeries).toHaveBeenCalledWith(2);
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
});
it('clears the highlight on leave', () => {
const { result } = renderHook(() => useLegendActions());
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current({ type: LegendAction.HOVER, seriesIndex: null });
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(onHighlightSeries).toHaveBeenCalledWith(null);
});
it('coalesces rapid hovers into one frame', () => {
const { result } = renderHook(() => useLegendActions());
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
// Each new hover cancels the frame the previous one queued.
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
});
});
it('cancels a pending highlight frame on unmount', () => {
jest
.spyOn(global, 'requestAnimationFrame')
.mockImplementation((): number => 7);
describe('onLegendMouseMove', () => {
it('focuses new series when hovering over different legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 0,
}),
);
const { result, unmount } = renderHook(() => useLegendActions());
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
unmount();
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
});
it('does nothing when hovering over already focused series', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 1,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
});
});
describe('onLegendMouseLeave', () => {
it('cancels pending animation frame and clears focus state', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseLeave();
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -1,66 +1,117 @@
import { useCallback, useEffect, useRef } from 'react';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from '../components/types';
/**
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
* to one chart redraw per frame.
*/
export function useLegendActions(): OnLegendAction {
export function useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
}: {
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
focusedSeriesIndex: number | null;
}): {
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onFocusSeries: (seriesIndex: number | null) => void;
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
} {
const {
onFocusSeries: onFocusSeriesPlot,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onHighlightSeries,
onToggleSeriesVisibility,
} = usePlotContext();
const rafIdRef = useRef<number | null>(null);
const rafId = useRef<number | null>(null); // requestAnimationFrame id
const cancelPendingHighlight = useCallback((): void => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
const getLegendItemIdFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
const target = e.target as HTMLElement | null;
if (!target) {
return undefined;
}
const legendItemElement = target.closest<HTMLElement>(
'[data-legend-item-id]',
);
return legendItemElement?.dataset.legendItemId;
},
[],
);
const onLegendClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
if (!legendItemId) {
return;
}
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const seriesIndex = Number(legendItemId);
if (isLegendMarker) {
onToggleSeriesOnOff(seriesIndex);
return;
}
onToggleSeriesVisibility(seriesIndex);
},
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
);
const onFocusSeries = useCallback(
(seriesIndex: number | null): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(() => {
setFocusedSeriesIndex(seriesIndex);
onFocusSeriesPlot(seriesIndex);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeriesPlot],
);
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
const seriesIndex = legendItemId ? Number(legendItemId) : null;
if (seriesIndex === focusedSeriesIndex) {
return;
}
}, []);
onFocusSeries(seriesIndex);
};
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
const onLegendMouseLeave = useCallback(
(): void => {
// Cancel any pending RAF from handleFocusSeries to prevent race condition
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
setFocusedSeriesIndex(null);
onFocusSeries(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeries],
);
return useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
onToggleSeriesOnOff(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
onShowOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
onShowAllSeries();
break;
case LegendAction.HOVER: {
const { seriesIndex } = payload;
cancelPendingHighlight();
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
onHighlightSeries(seriesIndex);
});
break;
}
default:
break;
// Cleanup pending animation frames on unmount
useEffect(
() => (): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
},
[
cancelPendingHighlight,
onHighlightSeries,
onShowAllSeries,
onShowOnlySeries,
onToggleSeriesOnOff,
],
[],
);
return {
onLegendClick,
onFocusSeries,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -45,7 +45,9 @@ export default function Pie({
visibleData,
legendItems,
focusedSeriesIndex,
onLegendAction,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = usePieInteractions(data, id);
const {
@@ -225,7 +227,9 @@ export default function Pie({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
</div>
</div>

View File

@@ -100,29 +100,17 @@ describe('Pie', () => {
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
});
it('isolates a slice when its legend row is clicked with everything showing', () => {
it('hides a slice when its legend marker is clicked', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(3);
fireEvent.click(screen.getByTestId('legend-item-1'));
// Nothing visible to exclude, so the click isolates: one arc left.
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('excludes a slice when its legend row is clicked with others already hidden', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
// Isolate, then add a second slice back, so nothing is isolated any more.
fireEvent.click(screen.getByTestId('legend-item-1'));
fireEvent.click(screen.getByTestId('legend-item-0'));
expect(svg.querySelectorAll('path')).toHaveLength(2);
fireEvent.click(screen.getByTestId('legend-item-0'));
const marker = document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
) as HTMLElement;
fireEvent.click(marker);
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(1);
expect(svg.querySelectorAll('path')).toHaveLength(2);
});
});

View File

@@ -1,9 +1,6 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
calculateAverageLegendWidth,
calculateChartDimensions,
} from 'lib/visualization/charts/utils';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
@@ -52,104 +49,63 @@ describe('calculateChartDimensions', () => {
expect(dims.width).toBe(784);
});
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
it('RIGHT: never shrinks the column below the 150px floor', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(3, 3),
});
expect(dims.legendWidth).toBe(190);
expect(dims.width).toBe(810);
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(850);
});
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
const dims = calculateChartDimensions({
containerWidth: 300,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40% is 120px, too narrow for the column's own toolbar.
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(150);
expect(dims.legendWidth).toBe(120);
expect(dims.width).toBe(180);
});
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
const dims = calculateChartDimensions({
containerWidth: 200,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(100);
expect(dims.width).toBe(100);
});
it('BOTTOM: items that fit one row reserve exactly one row', () => {
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One 28px row + the wrapper's 12px bottom padding.
// One row = line height (28) + padding (12).
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: more items than one row reserve exactly two rows', () => {
it('BOTTOM: many items cap at two rows on a tall container', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
// clipped third row, and none left over.
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(430);
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
});
it('BOTTOM: items one past a row still reserve two rows', () => {
// 1000px wide fits 5 of these per row, so 6 items need a second row.
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(6),
});
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 120,
containerHeight: 160,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// A whole row goes rather than a clipped one being reserved.
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(80);
});
});
describe('calculateAverageLegendWidth', () => {
it('scales with the label length', () => {
// 16px of chrome + 30 chars at 8px.
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
});
it('never drops below what a row needs to contain its hover actions', () => {
// Short or unnamed series would otherwise size a column the actions
// escape, spilling over the item beside it.
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
expect(calculateAverageLegendWidth([''])).toBe(110);
});
it('keeps the default estimate when there are no labels to measure', () => {
expect(calculateAverageLegendWidth([])).toBe(120);
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
});
});

View File

@@ -1,10 +1,4 @@
import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
MAX_LEGEND_WIDTH,
} from 'lib/uPlotV2/components/Legend/constants';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
export interface ChartDimensions {
width: number;
@@ -19,31 +13,22 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
// Two rows are worth having, but not at the cost of half the panel.
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
const LEGEND_LINE_HEIGHT = 28;
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
const MAX_RIGHT_LEGEND_WIDTH = 320;
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
// Column padding + copy button, not covered by the text-length estimate.
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
const MIN_RIGHT_LEGEND_WIDTH = 190;
// Past this the split inverts and the chart becomes the smaller half.
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
/**
* Calculates the average width of the legend items based on the labels of the series.
* Never returns less than a legend row needs to hold its own hover actions.
* @param legends - The labels of the series.
* @returns The average width of the legend items.
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
);
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
}
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
@@ -51,10 +36,7 @@ export function calculateAverageLegendWidth(legends: string[]): number {
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
const percentileLength = lengths[Math.max(0, index)];
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
);
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
}
/**
@@ -70,9 +52,7 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
* the rectangle never clips a row or reserves space for half of one. Two
* rows that would take half a short panel fall back to one row.
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
@@ -120,14 +100,9 @@ export function calculateChartDimensions({
MAX_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
);
// The column's chrome outranks the 40% share on a narrow panel.
const floorWidth = Math.min(
MIN_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
);
const rightLegendWidth = Math.min(
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
Math.max(floorWidth, maxRightLegendWidth),
Math.max(150, desiredLegendWidth),
maxRightLegendWidth,
);
return {
@@ -140,6 +115,8 @@ export function calculateChartDimensions({
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
@@ -148,30 +125,30 @@ export function calculateChartDimensions({
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
);
// The wrapper's bottom padding is inside this height (border-box).
const heightForRows = (rowCount: number): number =>
rowCount * LEGEND_ROW_HEIGHT +
(rowCount - 1) * LEGEND_ROW_GAP +
LEGEND_PADDING;
const neededRowCount = Math.max(
1,
Math.min(
LEGEND_MAX_BOTTOM_ROWS,
Math.ceil(legendItemCount / legendItemsPerRow),
),
const legendRowCount = Math.min(
2,
Math.ceil(legendItemCount / legendItemsPerRow),
);
// Without this, short grid panels hand most of their area to the legend and
// the chart — the pie donut especially — collapses to a sliver. Dropping a
// whole row beats clipping one.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
? 1
: neededRowCount;
const idealBottomLegendHeight =
legendRowCount > 1
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const bottomLegendHeight = heightForRows(legendRowCount);
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,
maxAllowedLegendHeight,
);
return {
width: containerWidth,

View File

@@ -1,9 +1,9 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from 'lib/visualization/charts/types';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
@@ -24,6 +24,22 @@ const DATA: PieSlice[] = [
{ label: 'checkout', value: 40, color: '#c' },
];
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
function legendEvent(
index: number | null,
isMarker = false,
): MouseEvent<HTMLDivElement> {
const itemEl =
index == null ? null : { dataset: { legendItemId: String(index) } };
return {
target: {
closest: (): unknown => itemEl,
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
},
} as unknown as MouseEvent<HTMLDivElement>;
}
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
@@ -43,16 +59,11 @@ describe('usePieInteractions', () => {
expect(result.current.active).toBeNull();
});
describe('row toggle', () => {
describe('marker click (toggle one)', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
@@ -62,50 +73,18 @@ describe('usePieInteractions', () => {
{ label: 'checkout', show: true },
]);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('the last slice showing', () => {
it('cannot be hidden', () => {
describe('label click (isolate / reset)', () => {
it('isolates the clicked slice, then resets on a second click', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
// An empty donut is never a state worth reaching.
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
});
});
describe('Only', () => {
it('isolates the slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
@@ -113,39 +92,8 @@ describe('usePieInteractions', () => {
false,
false,
]);
});
it('switches the isolation to another slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
});
});
describe('All', () => {
it('brings every hidden slice back', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual(DATA);
});
@@ -155,37 +103,11 @@ describe('usePieInteractions', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
);
act(() => result.current.onLegendMouseMove(legendEvent(2)));
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused slice is hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
// Otherwise every remaining arc stays dimmed and the donut reads as an
// isolation instead of one slice being excluded.
act(() => result.current.onLegendMouseLeave());
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
@@ -193,15 +115,8 @@ describe('usePieInteractions', () => {
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
act(() => result.current.onLegendMouseMove(legendEvent(1)));
expect(result.current.active).toBeNull();
});
@@ -210,12 +125,7 @@ describe('usePieInteractions', () => {
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendClick(legendEvent(0, true)));
expect(mockUpdateStored).not.toHaveBeenCalled();
});

View File

@@ -1,11 +1,6 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from 'lib/uPlotV2/components/types';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getStoredSeriesVisibility,
@@ -23,15 +18,27 @@ export interface UsePieInteractionsResult {
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
/** Every legend interaction, dispatched by type. */
onLegendAction: OnLegendAction;
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
// event target (the shared Legend tags each item with its seriesIndex).
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = el?.dataset.legendItemId;
return id != null ? Number(id) : null;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
* shared legend's actions, and persistence of the hidden set to localStorage
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
* slices, legend items, focus index, and the legend action dispatch.
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
* uPlot legend — marker toggles one, label isolates), and persistence of the
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
* reloads. Returns the visible slices, legend items, focus index, and the
* legend container handlers.
*/
export function usePieInteractions(
data: PieSlice[],
@@ -41,6 +48,7 @@ export function usePieInteractions(
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const isolatedIndexRef = useRef<number | null>(null);
const legendItems = useMemo<LegendItem[]>(
() =>
@@ -96,88 +104,65 @@ export function usePieInteractions(
[id, data],
);
const hoverSeries = useCallback(
(sliceIndex: number | null): void => {
const onLegendMouseMove = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(
sliceIndex != null && !hiddenIndices.has(sliceIndex)
? data[sliceIndex]
: null,
);
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
},
[data, hiddenIndices],
);
const toggleSeries = useCallback(
(sliceIndex: number): void => {
const next = new Set(hiddenIndices);
if (next.has(sliceIndex)) {
next.delete(sliceIndex);
} else {
// An empty donut is never worth reaching.
if (data.length - next.size <= 1) {
return;
}
next.add(sliceIndex);
// Marker click toggles just that slice on/off; label click isolates it
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
const onLegendClick = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
if (index == null) {
return;
}
applyHidden(next);
},
[data.length, hiddenIndices, applyHidden],
);
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const showOnlySeries = useCallback(
(sliceIndex: number): void => {
const next = new Set<number>();
data.forEach((_, index) => {
if (index !== sliceIndex) {
if (isMarker) {
const next = new Set(hiddenIndices);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
applyHidden(next);
return;
}
const isReset = isolatedIndexRef.current === index;
isolatedIndexRef.current = isReset ? null : index;
if (isReset) {
applyHidden(new Set());
return;
}
const next = new Set<number>();
data.forEach((_, i) => {
if (i !== index) {
next.add(i);
}
});
applyHidden(next);
},
[data, applyHidden],
[data, hiddenIndices, applyHidden],
);
const showAllSeries = useCallback(
(): void => applyHidden(new Set()),
[applyHidden],
);
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
const onLegendAction = useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
toggleSeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
showOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
showAllSeries();
break;
case LegendAction.HOVER:
hoverSeries(payload.seriesIndex);
break;
default:
break;
}
},
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
);
const activeIndex = active ? data.indexOf(active) : -1;
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
// isolation rather than as one slice being excluded.
const effectiveActive =
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
const focusedIndex = effectiveActive ? activeIndex : -1;
const focusedIndex = active ? data.indexOf(active) : -1;
return {
active: effectiveActive,
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendAction,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -29,6 +29,7 @@
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding: 0 12px 12px 12px;
padding-left: 12px;
padding-bottom: 12px;
}
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';

View File

@@ -0,0 +1,131 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { encode } from 'js-base64';
import type { Tags } from 'types/reducer/trace';
import {
choiceControl,
countControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
attributeKeysFor,
attributeKeysResponse,
attributeValuesFor,
attributeValuesResponse,
dependencyGraphResponse,
MAX_DEPENDENCIES,
RESOURCE_FILTERS,
type ResourceFilter,
resourceFilterQueries,
SERVICE_HEALTH,
type ServiceHealth,
} from './__story_mockdata__/serviceMap';
const GRAPH = 'Service map · graph';
const FILTERS = 'Service map · filters';
interface DependencyGraphBody {
tags?: Tags[];
}
const serviceMapRoute = (filters: readonly ResourceFilter[]): string => {
if (filters.length === 0) {
return ROUTES.SERVICE_MAP;
}
const params = new URLSearchParams({
[QueryParams.resourceAttributes]: encode(
JSON.stringify(resourceFilterQueries(filters)),
),
});
return `${ROUTES.SERVICE_MAP}?${params.toString()}`;
};
export const serviceMapMocks = defineStoryMocks({
controls: {
services: countControl('Dependencies', {
group: GRAPH,
description:
'Call edges the endpoint answers with. Every one of them is a link and its two nodes; 0 is the "No Service Found" card.',
value: MAX_DEPENDENCIES,
max: MAX_DEPENDENCIES,
}),
health: choiceControl<ServiceHealth>('Service health', {
group: GRAPH,
description:
'Error rate on the calls into a service, which is what turns its node red.',
options: SERVICE_HEALTH,
value: 'degraded',
}),
filters: multiChoiceControl<ResourceFilter>('Applied filters', {
group: FILTERS,
description:
'Resource attributes the page opens with, as the environment selector and a chip. The graph narrows to what they match.',
options: RESOURCE_FILTERS,
value: [],
}),
environments: countControl('Environments', {
group: FILTERS,
description: 'Values the environment selector offers.',
value: 3,
max: 5,
}),
resourceAttributes: toggleControl('Resource attributes ingested', {
group: FILTERS,
description:
'Off answers both autocomplete endpoints with nothing, which is what the filter reports as no resource attributes available.',
value: true,
}),
},
handlers: (values, response) => [
rest.post(
'http://localhost/api/v1/dependency_graph',
response.json(async (req) => {
const body = (await req.json()) as DependencyGraphBody;
return dependencyGraphResponse({
count: values.services,
health: values.health,
tags: body.tags,
});
}),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) =>
attributeKeysResponse(
values.resourceAttributes
? attributeKeysFor(req.url.searchParams.get('searchText'))
: [],
),
),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_values',
response.json((req) =>
attributeValuesResponse(
values.resourceAttributes
? attributeValuesFor(
req.url.searchParams.get('attributeKey'),
values.environments,
)
: [],
),
),
),
],
config: (values) => ({ route: serviceMapRoute(values.filters) }),
});

View File

@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { serviceMapMocks } from './ServiceMap.stories.mocks';
import ServiceMapContainer from '../index';
type ServiceMapArgs = PageStoryArgs<typeof serviceMapMocks>;
const pageStory = storyMocks(serviceMapMocks, { layout: 'app' });
/**
* Service to service calls as a force graph over `/api/v1/dependency_graph`,
* nodes sized by request rate and coloured by error rate, with link details on
* hover.
*
* Route: `/service-map`.
*/
const meta = {
title: 'Pages/Services/Service Map',
tags: ['beta', 'play'],
component: ServiceMapContainer,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ServiceMapArgs>;
export default meta;
type Story = StoryObj<ServiceMapArgs>;
/** The keys are only fetched once the select opens, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The whole topology: one node per service, sized by how many calls it takes,
* red where those calls are failing, and a link per dependency carrying the
* latency and error rate its tooltip reports.
*/
export const Default: Story = {};
/** A topology without service errors, preserving the healthy node treatment. */
export const HealthyTopology: Story = {
args: { health: 'healthy' },
};
/**
* The map narrowed to one environment and one cluster: the environment selector
* carries the first, a chip carries the second, and the graph is what is left.
*/
export const Filtered: Story = {
args: { filters: ['environment', 'cluster'] },
};
/** A workspace with no dependencies recorded in the selected time range. */
export const NoServices: Story = {
args: { services: 0 },
};
/** The filter's real empty branch when no resource attributes have been ingested. */
export const NoResourceAttributes: Story = {
args: { resourceAttributes: false },
play: async ({ canvasElement }): Promise<void> => {
const filter = await within(canvasElement).findByTestId(
'resource-attributes-filter',
undefined,
untilLoaded,
);
await userEvent.click(within(filter).getByRole('combobox'));
await screen.findByText(
/No resource attributes available to filter/i,
undefined,
untilLoaded,
);
},
};
/**
* The attribute filter open: of everything the endpoint returns, the map only
* offers the three keys it can send to `/dependency_graph`.
*/
export const FilterAttributes: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const filter = await canvas.findByTestId(
'resource-attributes-filter',
undefined,
untilLoaded,
);
// The select opens on a press inside it: a click on the wrapper the test id
// sits on never reaches the handler that opens the list.
await userEvent.click(within(filter).getByRole('combobox'));
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
},
};

View File

@@ -0,0 +1,352 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { IResourceAttribute } from 'hooks/useResourceAttribute/types';
import { getResourceDeploymentKeys } from 'hooks/useResourceAttribute/utils';
import type { ServicesMapItem } from 'store/actions/serviceMap';
import type {
TagKeysPayloadProps,
TagValuesPayloadProps,
} from 'types/api/metrics/getResourceAttributes';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { Tags } from 'types/reducer/trace';
export const SERVICE_HEALTH = ['healthy', 'degraded', 'failing'] as const;
export type ServiceHealth = (typeof SERVICE_HEALTH)[number];
interface Dependency {
parent: string;
child: string;
callCount: number;
callRate: number;
/** Nanoseconds: the link tooltip divides by 1e6 to show milliseconds. */
p99: number;
environment: string;
cluster: string;
}
/**
* One call edge per entry, parents before children, so slicing the head of the
* list keeps the graph connected instead of leaving orphaned nodes behind.
*/
const DEPENDENCIES: Dependency[] = [
{
parent: 'gateway',
child: 'frontend',
callCount: 41200,
callRate: 68.4,
p99: 184_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'frontend',
child: 'auth',
callCount: 12800,
callRate: 21.3,
p99: 46_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'frontend',
child: 'catalogue',
callCount: 18600,
callRate: 31,
p99: 92_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'frontend',
child: 'cart',
callCount: 9400,
callRate: 15.6,
p99: 58_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'cart',
child: 'redis',
callCount: 7300,
callRate: 12.1,
p99: 4_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'catalogue',
child: 'mysql',
callCount: 15200,
callRate: 25.3,
p99: 31_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'auth',
child: 'mysql',
callCount: 8100,
callRate: 13.5,
p99: 27_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'frontend',
child: 'checkout',
callCount: 6200,
callRate: 10.3,
p99: 210_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'checkout',
child: 'payments',
callCount: 5900,
callRate: 9.8,
p99: 340_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'checkout',
child: 'shipping',
callCount: 5400,
callRate: 9,
p99: 120_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'payments',
child: 'stripe-proxy',
callCount: 5100,
callRate: 8.5,
p99: 290_000_000,
environment: 'production',
cluster: 'prod-us-east',
},
{
parent: 'shipping',
child: 'geo-service',
callCount: 4700,
callRate: 7.8,
p99: 76_000_000,
environment: 'production',
cluster: 'prod-eu-west',
},
{
parent: 'geo-service',
child: 'redis',
callCount: 4300,
callRate: 7.1,
p99: 3_000_000,
environment: 'production',
cluster: 'prod-eu-west',
},
{
parent: 'catalogue',
child: 'recommendations',
callCount: 3800,
callRate: 6.3,
p99: 150_000_000,
environment: 'staging',
cluster: 'staging-eu',
},
{
parent: 'recommendations',
child: 'ml-inference',
callCount: 3500,
callRate: 5.8,
p99: 480_000_000,
environment: 'staging',
cluster: 'staging-eu',
},
{
parent: 'notifications',
child: 'email-relay',
callCount: 900,
callRate: 1.5,
p99: 65_000_000,
environment: 'staging',
cluster: 'staging-eu',
},
];
export const MAX_DEPENDENCIES = DEPENDENCIES.length;
const DEGRADED_SERVICES = ['payments', 'redis'];
const ERROR_RATES = [1.2, 3.4, 0.8, 6.1, 2.5];
const errorRateFor = (
child: string,
health: ServiceHealth,
index: number,
): number => {
if (health === 'healthy') {
return 0;
}
if (health === 'degraded' && !DEGRADED_SERVICES.includes(child)) {
return 0;
}
return ERROR_RATES[index % ERROR_RATES.length];
};
const ATTRIBUTE_BY_TAG_KEY: Record<string, 'environment' | 'cluster'> = {
'deployment.environment': 'environment',
'k8s.cluster.name': 'cluster',
};
/**
* The page sends its resource-attribute chips as trace tags, so the response has
* to narrow with them: a filter that changed nothing would look broken.
*/
const matchesTag = (dependency: Dependency, tag: Tags): boolean => {
const attribute = ATTRIBUTE_BY_TAG_KEY[tag.Key];
if (!attribute) {
return true;
}
const matched = tag.StringValues.includes(dependency[attribute]);
return tag.Operator === 'NotIn' ? !matched : matched;
};
interface DependencyGraphOptions {
count: number;
health: ServiceHealth;
tags?: Tags[];
}
export const dependencyGraphResponse = ({
count,
health,
tags = [],
}: DependencyGraphOptions): ServicesMapItem[] =>
DEPENDENCIES.slice(0, count)
.filter((dependency) => tags.every((tag) => matchesTag(dependency, tag)))
.map(({ parent, child, callCount, callRate, p99 }, index) => ({
parent,
child,
callCount,
callRate,
p99,
errorRate: errorRateFor(child, health, index),
}));
const ENVIRONMENT_KEY = 'resource_deployment_environment';
const CLUSTER_KEY = 'resource_k8s_cluster_name';
const NAMESPACE_KEY = 'resource_k8s_cluster_namespace';
/**
* `service.name` and `host.name` are not in the service-map whitelist, so they
* are here to be dropped: the page filters the keys it offers down to the three
* it can send to `/dependency_graph`.
*/
const ATTRIBUTE_KEYS = [
ENVIRONMENT_KEY,
CLUSTER_KEY,
NAMESPACE_KEY,
'resource_service_name',
'resource_host_name',
];
const ENVIRONMENTS = [
'production',
'staging',
'development',
'canary',
'load-test',
];
const CLUSTERS = ['prod-us-east', 'prod-eu-west', 'staging-eu'];
const NAMESPACES = ['default', 'checkout', 'ingest'];
/**
* The environment selector asks the same endpoint as the attribute filter, and
* the deployment key it matches on is the only thing telling the two apart.
*/
export const attributeKeysFor = (searchText: string | null): string[] =>
searchText === getResourceDeploymentKeys()
? [getResourceDeploymentKeys()]
: ATTRIBUTE_KEYS;
export const attributeValuesFor = (
attributeKey: string | null,
environments: number,
): string[] => {
if (
attributeKey === getResourceDeploymentKeys() ||
attributeKey === ENVIRONMENT_KEY
) {
return ENVIRONMENTS.slice(0, environments);
}
if (attributeKey === CLUSTER_KEY) {
return CLUSTERS;
}
return attributeKey === NAMESPACE_KEY ? NAMESPACES : [];
};
export const attributeKeysResponse = (
keys: readonly string[],
): TagKeysPayloadProps & { status: string } => ({
status: 'success',
data: {
attributeKeys: keys.map((key) => ({
key,
type: 'resource',
dataType: DataTypes.String,
})),
},
});
export const attributeValuesResponse = (
values: readonly string[],
): TagValuesPayloadProps & { status: string } => ({
status: 'success',
data: {
boolAttributeValues: null,
numberAttributeValues: null,
stringAttributeValues: [...values],
},
});
export const RESOURCE_FILTERS = ['environment', 'cluster'] as const;
export type ResourceFilter = (typeof RESOURCE_FILTERS)[number];
/**
* The environment query has to carry the deployment key the app derives, since
* that is what routes it into the environment selector instead of a chip.
*/
const FILTER_QUERIES: Record<ResourceFilter, IResourceAttribute> = {
environment: {
id: 'storybook-environment',
tagKey: getResourceDeploymentKeys(),
operator: 'IN',
tagValue: ['production'],
},
cluster: {
id: 'storybook-cluster',
tagKey: CLUSTER_KEY,
operator: 'IN',
tagValue: ['prod-us-east'],
},
};
export const resourceFilterQueries = (
filters: readonly ResourceFilter[],
): IResourceAttribute[] => filters.map((filter) => FILTER_QUERIES[filter]);

View File

@@ -0,0 +1,47 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
usageResponse,
usageServicesResponse,
} from './__story_mockdata__/usage';
const SPANS = 'Usage · spans';
export const usageMocks = defineStoryMocks({
controls: {
spansPerBucket: countControl('Spans per bucket, in thousands', {
group: SPANS,
description:
'What each bar carries, which the total above the chart is the sum of. Zero is a workspace sending nothing.',
value: 240,
max: 2000,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/usage',
response.json((req) =>
usageResponse(
Number(req.url.searchParams.get('start') ?? 0),
Number(req.url.searchParams.get('end') ?? 0),
Number(req.url.searchParams.get('step') ?? 3600),
values.spansPerBucket * 1000,
),
),
),
rest.post(
'http://localhost/api/v2/services',
response.json(() => usageServicesResponse()),
),
],
config: () => ({ route: ROUTES.USAGE_EXPLORER }),
});

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import UsageExplorerContainer from '../index';
import { usageMocks } from './Usage.stories.mocks';
type UsageArgs = PageStoryArgs<typeof usageMocks>;
const pageStory = storyMocks(usageMocks, { layout: 'app' });
/**
* Spans ingested per service over a period, the usage view that predates Cost
* Meter.
*
* Route: `/usage-explorer`.
*/
const meta = {
title: 'Pages/Metering/Usage Explorer',
component: UsageExplorerContainer,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<UsageArgs>;
export default meta;
type Story = StoryObj<UsageArgs>;
/** Spans ingested over the window, and the total they add up to. */
export const Default: Story = {};
/** A workspace that has not sent anything yet. */
export const NoSpans: Story = {
args: { spansPerBucket: 0 },
};

View File

@@ -0,0 +1,54 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { UsageDataItem } from 'store/actions';
import type { ServicesList } from 'types/api/metrics/getService';
export const USAGE_SERVICES = [
'frontend',
'checkout',
'cart',
'payment',
'shipping',
];
/** The select is the only thing this page reads a service for. */
export const usageServicesResponse = (): {
status: string;
data: ServicesList[];
} => ({
status: 'success',
data: USAGE_SERVICES.map((serviceName, index) => ({
serviceName,
p99: 120_000_000 + index * 9_000_000,
avgDuration: 40_000_000,
numCalls: 12_000 + index * 3_100,
callRate: 6.4 + index,
numErrors: 0,
errorRate: 0,
})),
});
/**
* The page asks for its window in nanoseconds and steps through it in seconds,
* so the buckets are derived from the request rather than pinned: whichever
* range and interval the selects are on, the bars fill it.
*/
export const usageResponse = (
startInNanoseconds: number,
endInNanoseconds: number,
stepInSeconds: number,
spansPerBucket: number,
): UsageDataItem[] => {
const start = Math.floor(startInNanoseconds / 1e9);
const end = Math.floor(endInNanoseconds / 1e9);
const step = Math.max(stepInSeconds, 1);
const buckets = Math.min(Math.max(Math.floor((end - start) / step), 0), 1000);
return Array.from({ length: buckets }, (_unused, index) => ({
timestamp: (start + index * step) * 1_000_000_000,
count: Math.round(spansPerBucket * (0.7 + ((index * 37) % 60) / 100)),
}));
};

View File

@@ -0,0 +1,272 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { rest, type RequestHandler } from 'msw';
import {
choiceControl,
countControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { globalConfigResponse } from '@/storybook/msw/__story_mockdata__/appShell';
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
import type { MockResolver } from '@/storybook/msw/types';
import {
AGENT_STATES,
type AgentState,
answeredBlocks,
chipsResponse,
EXECUTION_ID,
executionEvents,
newConversation,
NEW_THREAD_ID,
openConversation,
THREAD_ID,
streamingState,
THREAD_PARTS,
type ThreadPart,
threadDetailResponse,
threadListResponse,
} from './__story_mockdata__/aiAssistant';
const THREAD = 'AI assistant · thread';
const AGENT = 'AI assistant · agent';
const CONVERSATIONS = 'AI assistant · conversations';
/**
* The assistant talks to its own backend, whose host comes from the global
* config rather than being the SigNoz API. Pointing it at the same origin is
* what puts its calls in front of the story's handlers.
*/
const AI_BACKEND_URL = 'http://localhost';
/** The analysis thread, without the turns that carry an interactive block. */
const ANALYSIS: ThreadPart[] = [
'prose',
'table',
'code',
'activity',
'actions',
'voted',
];
/** Suggestions the `@` picker offers under Dashboards. */
const CONTEXT_DASHBOARDS = [
'Checkout overview',
'Payments upstream',
'Ingestion health',
];
/**
* `useIsAIAssistantEnabled` pushes the assistant's host into the axios instance
* during render, and pushes `null` for as long as the global config query is in
* flight. Every call that leaves in that window goes out against an empty base
* and lands on the page's own origin, so each endpoint answers on both paths
* rather than the story showing the 404 the app puts there. Reported as an app
* bug.
*/
const onBothBases = (
method: 'get' | 'post' | 'patch',
path: string,
resolver: MockResolver,
): RequestHandler[] => [
rest[method](`${AI_BACKEND_URL}${AI_API_PATH}${path}`, resolver),
rest[method](path, resolver),
];
const ok: MockResolver = (_req, res, ctx) => res(ctx.status(200), ctx.json({}));
const startedExecution: MockResolver = (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ executionId: EXECUTION_ID }));
export const aiAssistantMocks = defineStoryMocks({
controls: {
conversation: toggleControl('Open conversation', {
group: THREAD,
description:
'Off is the empty thread a first visit lands on, with the suggested prompts instead of an exchange.',
value: true,
}),
contents: multiChoiceControl<ThreadPart>('Thread contents', {
group: THREAD,
description:
'What the open thread holds. Each interactive block arrives as its own turn, and `voted` is a rating already on the last answer.',
options: THREAD_PARTS,
value: ANALYSIS,
}),
answered: toggleControl('Interactive blocks answered', {
group: THREAD,
description:
'The question, confirm and action blocks after the user has picked. The choice lives in the store, keyed by message, so it survives a remount.',
value: false,
}),
agent: choiceControl<AgentState>('Agent', {
group: AGENT,
description:
'What the agent is doing when the thread opens. Both waiting states block the composer until the user answers.',
options: AGENT_STATES,
value: 'idle',
}),
history: countControl('Past conversations', {
group: CONVERSATIONS,
description:
'Threads the sidebar lists, the first being the open one, so an open conversation holds the count at one. Their ages spread across every date group.',
value: 6,
max: 12,
}),
archived: countControl('Archived conversations', {
group: CONVERSATIONS,
description: 'Threads under the archived group at the foot of the sidebar.',
value: 2,
max: 6,
}),
},
handlers: (values, response) => [
rest.get('http://localhost/api/v1/global/config', (_req, res, ctx) =>
res(
ctx.json({
...globalConfigResponse,
data: {
...globalConfigResponse.data,
ai_assistant_url: AI_BACKEND_URL,
},
}),
),
),
...onBothBases(
'get',
'/threads',
response.json((req) =>
req.url.searchParams.get('archived') === 'true'
? threadListResponse(values.archived, true)
: // A thread the sidebar does not list is one the server does not
// know, and the store drops those, so an open conversation is
// always the first row.
threadListResponse(
values.conversation ? Math.max(1, values.history) : values.history,
false,
),
),
),
...onBothBases(
'get',
'/threads/:threadId',
response.json(() => threadDetailResponse(values.contents, values.agent)),
),
...onBothBases(
'get',
'/empty-state/chips',
response.json(() => chipsResponse()),
),
// Everything the user can set off from the page. They answer plainly rather
// than through `response`, so a click still lands while the Data control
// holds the page's own endpoints on loading or error.
// The first send of a new conversation mints a thread, and the page puts the
// id it gets back in the pathname: the overlay reports that as a navigation
// the story cannot follow, with the answer still streaming underneath.
...onBothBases('post', '/threads', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ threadId: THREAD_ID })),
),
...onBothBases('patch', '/threads/:threadId', ok),
...onBothBases('post', '/threads/:threadId/messages', startedExecution),
...onBothBases('post', '/messages/:messageId/regenerate', startedExecution),
...onBothBases('post', '/messages/:messageId/feedback', ok),
...onBothBases('post', '/approve', startedExecution),
...onBothBases('post', '/clarify', startedExecution),
...onBothBases('post', '/reject', ok),
...onBothBases('post', '/cancel', ok),
...onBothBases('post', '/undo', ok),
...onBothBases('post', '/revert', ok),
...onBothBases('post', '/restore', ok),
...onBothBases('get', '/executions/:executionId/events', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'text/event-stream'),
ctx.body(executionEvents()),
),
),
// The composer's `@` picker. Alert rules and services are answered by the
// shared handlers already; the dashboard list is not.
rest.get(
'http://localhost/api/v2/users/me/dashboards',
response.json(() => dashboardsForUserResponse(CONTEXT_DASHBOARDS)),
),
],
config: (values) => ({
// The bare `/ai-assistant` always rewrites itself to the thread it opens,
// so a story starts on the thread rather than on the redirect.
route: ROUTES.AI_ASSISTANT.replace(
':conversationId',
values.conversation ? THREAD_ID : NEW_THREAD_ID,
),
}),
effect: (values) => {
// The layout fetches the thread list in its mount effect, before the global
// config query it takes the assistant's host from has answered, so the
// first call would go out against an empty base. Setting it here is what
// the config response does, one render earlier.
setAIBackendUrl(AI_BACKEND_URL);
// The store is a zustand singleton and persists the answered blocks and the
// active thread, so a story's state is put there before the tree mounts
// rather than inherited from whichever story ran last.
const conversation = values.conversation
? openConversation()
: newConversation();
useAIAssistantStore.setState({
conversations: { [conversation.id]: conversation },
activeConversationId: conversation.id,
isLoadingThread: false,
isLoadingThreads: false,
answeredBlocks: values.answered ? answeredBlocks() : {},
// A stream is client state with no response behind it: the events the
// reducer folds into it only exist while the SSE connection is open.
streams:
values.agent === 'streaming' ? { [conversation.id]: streamingState() } : {},
});
},
});
/**
* An action chip's tooltip is the one text on the page the backend writes, and
* the thread fixture keeps it to a line. This answers with the same turns and a
* chip whose tooltip runs long, so the Thread contents, Interactive blocks
* answered and Agent controls do not reach the story that uses it. Both bases
* are covered because the assistant's host is empty while the global config
* query is in flight, as `onBothBases` above explains.
*/
const LONG_ACTION_TOOLTIP =
'Opens the logs explorer on payment.svc.cluster.local:8080, filtered to the 429s it answered between 14:00 and 15:00, with the retry attempt and the upstream quota window already on the table.';
export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
'get',
'/threads/:threadId',
(_req, res, ctx) => {
const thread = threadDetailResponse(aiAssistantMocks.args.contents, 'idle');
return res(
ctx.status(200),
ctx.json({
...thread,
messages: thread.messages?.map((message) => ({
...message,
actions: message.actions?.map((action) =>
action.tooltip ? { ...action, tooltip: LONG_ACTION_TOOLTIP } : action,
),
})),
}),
);
},
);

View File

@@ -0,0 +1,245 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import AIAssistantPage from '../AIAssistantPage';
import {
aiAssistantMocks,
longActionTooltipHandlers,
} from './AIAssistantPage.stories.mocks';
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
type AIAssistantArgs = PageStoryArgs<typeof aiAssistantMocks>;
const pageStory = storyMocks(aiAssistantMocks, { layout: 'app' });
/**
* Noz, the assistant: a thread of messages over the workspace's telemetry, tool
* calls and the artefacts they produce rendered inline, and the thread list beside
* it. The answer arrives as SSE, so it streams inside the story.
*
* Route: `/ai-assistant/:conversationId`.
*/
const meta = {
title: 'Pages/Noz',
tags: ['play'],
component: AIAssistantPage,
// The conversation id is in the pathname, so the page renders under its own
// route rather than being mounted on its own.
render: (): JSX.Element => (
<Route
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
component={AIAssistantPage}
/>
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<AIAssistantArgs>;
export default meta;
type Story = StoryObj<AIAssistantArgs>;
/** The thread list resolves before the thread does, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* Click something, and keep clicking until what it opens is on screen. The
* message list remounts its items while it measures a freshly loaded thread, so
* a single click can land on a row that is about to be replaced, taking the
* state it just set with it.
*/
const clickUntil = async (
find: () => Promise<HTMLElement>,
opens: RegExp,
): Promise<void> => {
await waitFor(async () => {
await userEvent.click(await find());
await screen.findByText(opens, undefined, { timeout: 1_000 });
}, untilLoaded);
};
/** The blocks the agent renders as cards the user answers in place. */
const INTERACTIVE: ThreadPart[] = [
'question',
'checkboxes',
'confirm',
'suggested-action',
];
const QUESTIONS: ThreadPart[] = ['question', 'checkboxes'];
const COMMITMENTS: ThreadPart[] = ['confirm', 'suggested-action'];
/**
* Two short exchanges and nothing else, so the state the story is about sits in
* the first screen rather than under a scroll.
*/
const BRIEF: ThreadPart[] = [];
/** A thread mid-investigation, with the earlier ones beside it. */
export const Default: Story = {};
/** The first visit: the suggested prompts and nothing asked yet. */
export const NewConversation: Story = {
args: { conversation: false, history: 0, archived: 0 },
};
/**
* The cards the agent puts in the thread when it needs the user to pick: one
* answer, or several.
*/
export const QuestionBlocks: Story = {
args: { contents: QUESTIONS },
};
/**
* The cards that ask the user to commit: a confirmation the agent acts on, and
* a page action it will apply here.
*/
export const ActionBlocks: Story = {
args: { contents: COMMITMENTS },
};
/** All four cards once the user has answered them, which the store remembers. */
export const AnsweredBlocks: Story = {
args: { contents: INTERACTIVE, answered: true },
};
/**
* Mid-answer: a step already done, the text so far, and a step still running
* with the elapsed clock on it. The composer waits its turn.
*/
export const Streaming: Story = {
args: { agent: 'streaming', contents: BRIEF },
};
/** A change the agent will not make until the user reads the diff and approves. */
export const AwaitingApproval: Story = {
args: { agent: 'awaiting-approval', contents: BRIEF },
};
/** The agent asking for the details it needs, one field per detail. */
export const AwaitingClarification: Story = {
args: { agent: 'awaiting-clarification', contents: BRIEF },
};
/** Reopening the page on a thread that has not come back yet. */
export const LoadingThread: Story = {
args: { dataState: 'loading' },
};
/**
* The steps behind an answer: what the agent thought, and each tool it called
* with what went in and what came back.
*/
export const ActivityExpanded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await clickUntil(
() => canvas.findByText(/worked through/i, undefined, untilLoaded),
/compared checkout p99/i,
);
await clickUntil(
() => canvas.findByText(/compared checkout p99/i, undefined, untilLoaded),
/^Output$/,
);
},
};
/** Opens the approval card's diff dialog. */
const openApprovalDiff: NonNullable<Story['play']> = async ({
canvasElement,
}) => {
await clickUntil(
() =>
within(canvasElement).findByLabelText(
/expand diff/i,
undefined,
untilLoaded,
),
/approval diff/i,
);
};
/** The approval diff at full size, before against after. */
export const ApprovalDiff: Story = {
args: { agent: 'awaiting-approval', contents: BRIEF },
play: openApprovalDiff,
};
/** The comment box a thumbs down opens, which a thumbs up does not. */
export const NegativeFeedback: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
// Every assistant message carries the bar; only the last one shows it
// without a hover.
const bars = await within(canvasElement).findAllByLabelText(
/bad response/i,
undefined,
untilLoaded,
);
return bars[bars.length - 1];
}, /what went wrong/i);
},
};
/** What a conversation row offers: rename, a link to it, and archiving. */
export const ConversationActions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
const [actions] = await within(canvasElement).findAllByLabelText(
/conversation actions/i,
undefined,
untilLoaded,
);
return actions;
}, /copy link/i);
},
};
/**
* The composer's context picker: the dashboards, alerts and services a question
* can be pinned to.
*/
export const AddContext: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(
() =>
within(canvasElement).findByRole(
'button',
{ name: /add context/i },
untilLoaded,
),
/checkout overview/i,
);
},
};
/**
* Every tooltip the thread carries, held open at once: the composer's voice and
* send buttons, the sidebar's new conversation, the copy chip under each user
* message, the copy, rate and regenerate bar under each answer, the code block's
* copy, and the action chip's own description.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
parameters: { msw: { handlers: longActionTooltipHandlers } },
};
/**
* The approval diff dialog's copy tooltips, one per side of the split view, with
* the card's own expand held open behind it. Switching the dialog to the unified
* view replaces the pair with a single Copy diff.
*/
export const TooltipsInApprovalDiff: Story = {
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
play: openApprovalDiff,
};

View File

@@ -0,0 +1,731 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type {
ApprovalSummaryDTO,
ChipsResponseDTO,
ClarificationSummaryDTO,
MessageActionDTO,
MessageSummaryDTO,
ThreadDetailResponseDTO,
ThreadListResponseDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
ApprovalActionTypeDTO,
ApprovalStateDTO,
ClarificationFieldTypeDTO,
ClarificationStateDTO,
FeedbackRatingDTO,
MessageActionKindDTO,
MessageContentTypeDTO,
MessageRoleDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type {
Conversation,
ConversationStreamState,
MessageBlock,
} from 'container/AIAssistant/types';
export const THREAD_ID = 'thread-checkout-latency';
/** The thread a first visit mints, which the page opens with nothing in it. */
export const NEW_THREAD_ID = 'thread-new';
export const THREAD_TITLE = 'Checkout p99 regression after 14:00';
/** What the open thread contains, one entry per turn the builder can add. */
export const THREAD_PARTS = [
'prose',
'table',
'code',
'activity',
'actions',
'question',
'checkboxes',
'confirm',
'suggested-action',
'voted',
] as const;
export type ThreadPart = (typeof THREAD_PARTS)[number];
/** What the agent is doing when the thread opens. */
export const AGENT_STATES = [
'idle',
'streaming',
'awaiting-approval',
'awaiting-clarification',
] as const;
export type AgentState = (typeof AGENT_STATES)[number];
/**
* Every interactive block reads `answeredBlocks[messageId]`, so a message holds
* at most one of them: answering either block of a pair would otherwise mark
* both. Reported as an app bug.
*/
export const MESSAGE_IDS = {
analysis: 'message-analysis',
question: 'message-question',
checkboxes: 'message-checkboxes',
confirm: 'message-confirm',
suggestedAction: 'message-suggested-action',
final: 'message-final',
} as const;
export const EXECUTION_ID = 'execution-checkout-latency';
// ---------------------------------------------------------------------------
// Assistant prose
// ---------------------------------------------------------------------------
const PROSE = `### What changed
\`checkout\` p99 went from **180 ms to 640 ms** at 14:05, and the whole increase sits in the
\`payment.authorize\` span. That span started retrying against a rate-limited upstream, so the
extra time is retry wait rather than compute.
- 12% of calls to \`payment.svc.cluster.local:8080\` answered \`429\`, up from none before 14:00
- retries are capped at three, which matches the 3x jump in span duration
- no deploy landed in the window, so this is upstream capacity and not a regression you shipped
> The upstream quota window resets at 14:00 UTC, which is exactly where the 429s begin.
The escalation path is in the [payment rate limit runbook](https://signoz.io/docs/userguide/payment-rate-limits/).`;
const TABLE = `| Service | p99 before | p99 after | Change | Error rate | Slowest span |
| --- | --- | --- | --- | --- | --- |
| checkout | 180 ms | 640 ms | +256% | 0.4% -> 2.1% | payment.authorize |
| payment | 95 ms | 410 ms | +331% | 0.1% -> 12.0% | upstream.authorize.retry |
| cart | 62 ms | 66 ms | +6% | 0.0% | redis.get |
| catalogue | 44 ms | 45 ms | +2% | 0.0% | postgres.query.products |
| notifications | 210 ms | 214 ms | +2% | 0.2% | kafka.publish |`;
/** The first line runs past the chat column, which is what makes the block scroll. */
const CODE = `Here is the query that isolates the retries:
\`\`\`sql
SELECT toStartOfMinute(timestamp) AS minute, quantile(0.99)(duration_nano / 1e6) AS p99_ms, countIf(status_code = 429) AS rate_limited, count() AS calls
FROM signoz_traces.distributed_signoz_index_v3
WHERE service_name = 'payment'
AND name = 'upstream.authorize.retry'
AND timestamp >= now() - INTERVAL 2 HOUR
GROUP BY minute
ORDER BY minute ASC
\`\`\``;
const proseFor = (parts: readonly ThreadPart[]): string =>
[
parts.includes('prose') ? PROSE : '',
parts.includes('table') ? TABLE : '',
parts.includes('code') ? CODE : '',
]
.filter(Boolean)
.join('\n\n') || 'The whole increase sits in the `payment.authorize` span.';
// ---------------------------------------------------------------------------
// Blocks
//
// `MessageSummaryDTO.blocks` is `unknown[]`, so the builders type against the
// union the renderer narrows to and cast once on the way into the payload.
// ---------------------------------------------------------------------------
const asBlocks = (blocks: MessageBlock[]): MessageSummaryDTO['blocks'] =>
blocks as unknown as MessageSummaryDTO['blocks'];
const activityBlocks = (text: string): MessageBlock[] => [
{
type: 'thinking',
content:
'The jump is sharp rather than gradual, so a capacity change is more likely than a slow leak. Comparing the span breakdown either side of 14:00 should say which span carries it, and the logs for that span should say why.',
},
{
type: 'tool_call',
toolCallId: 'call-service-metrics',
toolName: 'signoz_query_service_metrics',
displayText: 'Compared checkout p99 either side of 14:00',
toolInput: {
service: 'checkout',
metrics: ['p99', 'error_rate'],
window: { from: '2026-08-28T13:30:00Z', to: '2026-08-28T14:30:00Z' },
groupBy: ['span_name'],
},
result: {
p99_before_ms: 180.4,
p99_after_ms: 640.2,
top_span: 'payment.authorize',
contribution: 0.97,
},
success: true,
},
{
type: 'tool_call',
toolCallId: 'call-search-logs',
toolName: 'signoz_search_logs',
toolInput: {
expression:
"service.name = 'payment' AND severity_text = 'WARN' AND body CONTAINS 'rate limit'",
limit: 200,
},
result:
'187 of 200 matching lines read: upstream rate limit hit for tenant=acme quota=payment.authorize window=60s retry_after=1.5s remaining=0 endpoint=payment.svc.cluster.local:8080 request_id=01J9Z4Q0R7X2N8M4K6H1F3D5B7',
success: true,
},
{ type: 'text', content: text },
{
type: 'tool_call',
toolCallId: 'call-quota',
toolName: 'signoz_get_upstream_quota',
displayText: 'Read the upstream quota window',
toolInput: { endpoint: 'payment.svc.cluster.local:8080' },
result: { quota: 600, window_seconds: 60, resets_at: '14:00:00Z' },
success: true,
},
];
const ACTIONS: MessageActionDTO[] = [
{
kind: MessageActionKindDTO.follow_up,
label: 'Show the retry spans',
input: {
intent: 'Show me the payment.authorize retry spans between 14:00 and 15:00.',
},
},
{
kind: MessageActionKindDTO.apply_filter,
label: 'Filter logs to the 429s',
signal: ApplyFilterSignalDTO.logs,
tooltip: 'Opens the logs explorer with the rate-limit filter applied',
query: {
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
filter: {
expression: "service.name = 'payment' AND http.status_code = 429",
},
},
},
],
},
},
},
{
kind: MessageActionKindDTO.open_resource,
label: 'Open the Checkout dashboard',
resourceType: 'dashboard',
resourceId: 'storybook-dashboard-1',
},
{
kind: MessageActionKindDTO.open_docs,
label: 'Rate limit runbook',
url: 'https://signoz.io/docs/userguide/payment-rate-limits/',
},
{
kind: MessageActionKindDTO.undo,
label: 'Undo the threshold change',
actionMetadataId: 'action-threshold-change',
resourceType: 'alert',
resourceId: 'alert-checkout-p99',
state: 'applied',
},
{
kind: MessageActionKindDTO.revert,
label: 'Revert the dashboard panel',
actionMetadataId: 'action-dashboard-panel',
resourceType: 'dashboard',
resourceId: 'storybook-dashboard-1',
},
{
kind: MessageActionKindDTO.restore,
label: 'Restore the archived view',
actionMetadataId: 'action-archived-view',
resourceType: 'saved_view',
resourceId: 'view-payment-retries',
},
];
// ---------------------------------------------------------------------------
// Interactive blocks. The agent emits these as fenced `ai-<type>` code blocks,
// which `RichCodeBlock` resolves against the block registry.
// ---------------------------------------------------------------------------
const fence = (type: string, data: unknown): string =>
['```ai-'.concat(type), JSON.stringify(data, null, 2), '```'].join('\n');
const QUESTION_BLOCK = fence('question', {
question: 'Which signal should the alert watch?',
type: 'radio',
options: [
{ value: 'p99', label: 'Trace p99 on checkout' },
{ value: 'errors', label: 'Error rate on payment' },
{ value: 'rate-limited', label: 'Upstream 429 count' },
],
});
const CHECKBOX_BLOCK = fence('question', {
question: 'Who should the alert notify?',
type: 'checkbox',
options: [
'#checkout-oncall',
'payments-team@signoz.io',
'PagerDuty: payments',
],
});
const CONFIRM_BLOCK = fence('confirm', {
// The block renders its message as plain text, so markdown would show as
// literal asterisks.
message:
"I'll create the alert Checkout p99 > 500 ms, evaluated every minute over a 5 minute window, notifying #checkout-oncall and PagerDuty.",
acceptLabel: 'Create the alert',
rejectLabel: 'Not now',
acceptText: 'Yes, create it.',
rejectText: 'No, leave it for now.',
});
const ACTION_BLOCK = fence('action', {
actionId: 'logs.applyFilter',
description: 'Filter the logs explorer to the failing payment retries.',
parameters: {
signal: 'logs',
expression: "service.name = 'payment' AND http.status_code = 429",
from: '2026-08-28T14:00:00Z',
to: '2026-08-28T15:00:00Z',
},
});
// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------
type Turn = Omit<MessageSummaryDTO, 'createdAt' | 'updatedAt'>;
const user = (id: string, content: string): Turn => ({
messageId: id,
role: MessageRoleDTO.user,
contentType: MessageContentTypeDTO.markdown,
content,
});
const assistant = (
id: string,
content: string,
extra: Partial<Turn> = {},
): Turn => ({
messageId: id,
role: MessageRoleDTO.assistant,
contentType: MessageContentTypeDTO.markdown,
content,
complete: true,
...extra,
});
const turnsFor = (parts: readonly ThreadPart[]): Turn[] => {
const prose = proseFor(parts);
const turns: Turn[] = [
user(
'message-opening',
'Why did checkout get slower after 14:00? Deploys look clean.',
),
assistant(MESSAGE_IDS.analysis, prose, {
blocks: parts.includes('activity')
? asBlocks(activityBlocks(prose))
: undefined,
actions: parts.includes('actions') ? ACTIONS : undefined,
}),
];
if (parts.includes('question')) {
turns.push(
user('message-alert-ask', 'Can you set up an alert so we catch it sooner?'),
assistant(
MESSAGE_IDS.question,
`Before I create it, one choice.\n\n${QUESTION_BLOCK}`,
),
);
}
if (parts.includes('checkboxes')) {
turns.push(
user('message-signal-pick', 'Trace p99 on checkout.'),
assistant(MESSAGE_IDS.checkboxes, `Got it. One more.\n\n${CHECKBOX_BLOCK}`),
);
}
if (parts.includes('confirm')) {
turns.push(
user('message-notify-pick', '#checkout-oncall and PagerDuty.'),
assistant(MESSAGE_IDS.confirm, CONFIRM_BLOCK),
);
}
if (parts.includes('suggested-action')) {
turns.push(
user('message-logs-ask', 'Show me the failing retries in the logs.'),
assistant(
MESSAGE_IDS.suggestedAction,
`The retries are all on one endpoint, so a single filter covers them.\n\n${ACTION_BLOCK}`,
),
);
}
turns.push(
user('message-upstream-ask', 'Which upstream is rate-limiting us?'),
assistant(
MESSAGE_IDS.final,
'`payment.svc.cluster.local:8080`. It answered `429` on 12% of calls between 14:00 and 15:00, and none in the hour before. The quota is 600 requests per minute and checkout alone asked for 780.',
{
feedbackRating: parts.includes('voted')
? FeedbackRatingDTO.positive
: undefined,
},
),
);
return turns;
};
/** Chronological, ending a few minutes ago so the feedback bar reads fresh. */
const stamped = (turns: Turn[]): MessageSummaryDTO[] => {
const last = Date.now() - 4 * 60_000;
const step = 40_000;
const first = last - (turns.length - 1) * step;
return turns.map((turn, index) => {
const at = new Date(first + index * step).toISOString();
return { ...turn, createdAt: at, updatedAt: at };
});
};
// ---------------------------------------------------------------------------
// Pending user input
// ---------------------------------------------------------------------------
const pendingApproval = (): ApprovalSummaryDTO => ({
approvalId: 'approval-checkout-alert',
executionId: EXECUTION_ID,
sourceMessageId: MESSAGE_IDS.final,
state: ApprovalStateDTO.pending,
actionType: ApprovalActionTypeDTO.modify,
resourceType: 'alert',
summary:
'Raise the Checkout p99 alert threshold to 500 ms and add the upstream 429 count as a second condition.',
diff: {
before: {
alert: 'Checkout p99',
condition: {
target: 800,
op: '>',
matchType: 'atleastOnce',
evalWindow: '5m0s',
},
labels: { severity: 'warning', team: 'checkout' },
preferredChannels: ['#checkout-oncall'],
},
after: {
alert: 'Checkout p99',
condition: {
target: 500,
op: '>',
matchType: 'allTheTimes',
evalWindow: '5m0s',
secondary: { metric: 'upstream_429_total', target: 50, op: '>' },
},
labels: {
severity: 'critical',
team: 'checkout',
runbook: 'payment-rate-limits',
},
preferredChannels: ['#checkout-oncall', 'PagerDuty: payments'],
},
},
createdAt: new Date(Date.now() - 30_000).toISOString(),
});
const pendingClarification = (): ClarificationSummaryDTO => ({
clarificationId: 'clarification-alert-scope',
executionId: EXECUTION_ID,
sourceMessageId: MESSAGE_IDS.final,
state: ClarificationStateDTO.pending,
message:
'I can create the alert, but a few details change what it watches and who hears about it.',
fields: [
{
id: 'service',
type: ClarificationFieldTypeDTO.select,
label: 'Service to watch',
required: true,
options: ['checkout', 'payment', 'cart'],
default: 'checkout',
},
{
id: 'window',
type: ClarificationFieldTypeDTO.number,
label: 'Evaluation window (minutes)',
required: true,
default: '5',
},
{
id: 'severity',
type: ClarificationFieldTypeDTO.select,
label: 'Severity',
options: ['critical', 'warning', 'info'],
allowCustom: true,
default: 'warning',
},
{
id: 'channels',
type: ClarificationFieldTypeDTO.multi_select,
label: 'Notify',
required: true,
options: [
'#checkout-oncall',
'payments-team@signoz.io',
'PagerDuty: payments',
],
allowCustom: true,
default: ['#checkout-oncall'],
},
{
id: 'includeTraces',
type: ClarificationFieldTypeDTO.boolean,
label: 'Attach example traces to the notification',
default: 'true',
},
{
id: 'note',
type: ClarificationFieldTypeDTO.text,
label: 'Anything else I should know?',
},
],
createdAt: new Date(Date.now() - 30_000).toISOString(),
});
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export const threadDetailResponse = (
parts: readonly ThreadPart[],
agent: AgentState,
): ThreadDetailResponseDTO => {
const messages = stamped(turnsFor(parts));
return {
threadId: THREAD_ID,
title: THREAD_TITLE,
archived: false,
createdAt: messages[0].createdAt,
updatedAt: messages[messages.length - 1].createdAt,
messages,
// `activeExecutionId` would reconnect the stream and overwrite the seeded
// one, so the streaming state is left to the store.
activeExecutionId: null,
pendingApproval: agent === 'awaiting-approval' ? pendingApproval() : null,
pendingClarification:
agent === 'awaiting-clarification' ? pendingClarification() : null,
};
};
/**
* Ages that put the list across every bucket `groupByDate` builds: today,
* yesterday, last 7 days, last 30 days and older.
*/
const AGES_IN_MINUTES = [
4, 95, 1_700, 4_400, 15_000, 65_000, 30, 300, 2_000, 6_000, 20_000, 90_000,
];
const TITLES = [
THREAD_TITLE,
'Which endpoints are burning the most ingestion quota?',
'Kafka consumer lag on the notifications topic',
'Why are the ingestion workers restarting every twenty minutes on the production cluster?',
'Trace sampling rate for the cart service',
'Postgres connection pool saturation last Friday',
'Cost per service for August',
'Set up an alert for 5xx on the public API',
'Missing spans between gateway and auth',
'Log volume spike from the batch importer',
'Dashboard for the payments team',
'Retention on the debug log pipeline',
];
const ARCHIVED_TITLES = [
'Migrating the old APM dashboards',
'Alert noise from the staging cluster',
'Instrumenting the Go workers',
'Trace comparison for release 1.42',
'Cost meter setup',
'Old runbook questions',
];
export const threadListResponse = (
count: number,
archived: boolean,
): ThreadListResponseDTO => {
const titles = archived ? ARCHIVED_TITLES : TITLES;
return {
threads: Array.from({ length: count }, (_unused, index) => {
const at = new Date(
Date.now() - AGES_IN_MINUTES[index % AGES_IN_MINUTES.length] * 60_000,
).toISOString();
return {
threadId:
!archived && index === 0
? THREAD_ID
: `${archived ? 'thread-archived' : 'thread'}-${index}`,
title: titles[index % titles.length],
createdAt: at,
updatedAt: at,
archived,
};
}),
hasMore: false,
};
};
/** The prompts the empty conversation offers before anything is typed. */
export const chipsResponse = (): ChipsResponseDTO => ({
chips: [
{ id: 'top-errors', text: 'Show me the top errors in the last hour' },
{ id: 'slowest-services', text: 'What services have the highest latency?' },
{ id: 'slow-queries', text: 'Find slow database queries' },
{ id: 'health-overview', text: 'Give me an overview of system health' },
],
});
/**
* One SSE execution, delivered in a single body: msw answers a mocked `fetch`
* with the whole stream at once, and the reader splits it back into events. The
* text delta still animates word by word, so a send in a story looks like a
* send in the app.
*/
export const executionEvents = (): string =>
[
{ type: 'status', state: 'running' },
{
type: 'thinking',
content:
'The thread already has the span breakdown, so the remaining question is whether the quota is per tenant or per endpoint.',
},
{
type: 'tool_call',
toolName: 'signoz_get_upstream_quota',
displayText: 'Read the upstream quota window',
toolInput: { endpoint: 'payment.svc.cluster.local:8080' },
},
{
type: 'tool_result',
toolName: 'signoz_get_upstream_quota',
result: { quota: 600, window_seconds: 60, scope: 'per_tenant' },
},
{
type: 'message',
messageId: 'message-streamed',
delta:
'The quota is per tenant: 600 requests a minute across every endpoint, and checkout alone asked for 780 between 14:00 and 15:00. Raising the retry budget would make it worse, so the fix is either a quota increase or a client-side limiter in front of `payment.authorize`.',
done: false,
},
{ type: 'message', messageId: 'message-streamed', done: true },
{ type: 'done' },
]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join('');
// ---------------------------------------------------------------------------
// Store state
// ---------------------------------------------------------------------------
/**
* The entry the page resumes on. Empty and hydrating is what the app restores
* from its persisted active thread, and what makes `fetchThreads` follow up with
* the thread detail the handlers answer.
*/
export const openConversation = (): Conversation => ({
id: THREAD_ID,
threadId: THREAD_ID,
title: THREAD_TITLE,
messages: [],
createdAt: Date.now() - 20 * 60_000,
updatedAt: Date.now() - 4 * 60_000,
isHydrating: true,
});
export const newConversation = (): Conversation => ({
id: NEW_THREAD_ID,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
});
/**
* A stream caught mid-answer: a finished step, some text, and a step still
* running, which is the trailing group the elapsed timer ticks on.
*/
export const streamingState = (): ConversationStreamState => ({
isStreaming: true,
streamingStatus: 'running',
streamingMessageId: 'message-streaming',
streamingActions: null,
pendingApproval: null,
pendingClarification: null,
streamingContent:
'The quota is per tenant rather than per endpoint, so every service shares the same 600 requests a minute.',
streamingEvents: [
{
kind: 'thinking',
content:
'The span breakdown is already in the thread, so what is left is whether the quota is scoped to the tenant or to the endpoint.',
},
{
kind: 'tool',
toolCall: {
toolName: 'signoz_get_upstream_quota',
displayText: 'Read the upstream quota window',
input: { endpoint: 'payment.svc.cluster.local:8080' },
result: { quota: 600, window_seconds: 60, scope: 'per_tenant' },
done: true,
},
},
{
kind: 'text',
content:
'The quota is per tenant rather than per endpoint, so every service shares the same 600 requests a minute.',
},
{
kind: 'thinking',
content: 'Checking how much of that budget checkout asked for on its own.',
},
{
kind: 'tool',
toolCall: {
toolName: 'signoz_query_service_metrics',
displayText: 'Counting checkout calls per minute',
input: { service: 'checkout', metric: 'upstream_calls_total' },
done: false,
},
},
],
});
/**
* What each interactive block stores once the user has picked. The shape is
* per block: a question keeps the answer text, a confirm the choice, an action
* its outcome.
*/
export const answeredBlocks = (): Record<string, string> => ({
[MESSAGE_IDS.question]: 'Trace p99 on checkout',
[MESSAGE_IDS.checkboxes]: '#checkout-oncall, PagerDuty: payments',
[MESSAGE_IDS.confirm]: 'accepted',
[MESSAGE_IDS.suggestedAction]:
'applied:Filtered the logs explorer to 429s on payment.',
});

View File

@@ -0,0 +1,158 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
ruleHistoryFilterKeysResponse,
ruleHistoryFilterValuesResponse,
ruleHistoryOverallStatusResponse,
ruleHistoryStatsResponse,
ruleHistoryTimelineResponse,
ruleHistoryTopContributorsResponse,
TIMELINE_MAX,
TOP_CONTRIBUTOR_MAX,
type HistoryWindow,
} from './__story_mockdata__/alertHistory';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
CHANNEL_MAX,
FIRST_RULE_NAME,
type AlertSchema,
} from '../../stories/__story_mockdata__/alerts';
const STORY_RULE_ID = 'rule-1';
const STORY_RELATIVE_TIME = '6h';
const STATISTICS = 'Alert history · statistics';
const TIMELINE = 'Alert history · timeline';
/** Every history endpoint is asked for the same window the page resolved. */
const windowOf = (req: { url: URL }): HistoryWindow => {
const end = Number(req.url.searchParams.get('end'));
const start = Number(req.url.searchParams.get('start'));
return { start, end };
};
export const alertHistoryMocks = defineStoryMocks({
controls: {
triggers: countControl('Times triggered', {
group: STATISTICS,
description:
'Drives the Total Triggered card, the trigger sparkline and the counts the top contributors add up to. Zero is the card that says nothing fired.',
value: 48,
max: 200,
}),
resolutionMinutes: countControl('Avg. resolution, minutes', {
group: STATISTICS,
description: 'Zero is the card that says nothing was resolved.',
value: 22,
max: 180,
}),
topContributors: countControl('Top contributors', {
group: STATISTICS,
description: 'The label sets that fired most often in the window.',
value: 5,
max: TOP_CONTRIBUTOR_MAX,
}),
timelineEntries: countControl('Timeline entries', {
group: TIMELINE,
description: `The table pages at ${TIMELINE_TABLE_PAGE_SIZE}, so anything past that is a second page.`,
value: 26,
max: TIMELINE_MAX,
}),
statusWindows: countControl('Status bands', {
group: TIMELINE,
description: 'How finely the graph above the table slices the window.',
value: 30,
max: 60,
}),
alertSchema: choiceControl<AlertSchema>('Alert schema', {
group: TIMELINE,
description:
'Which form the Overview tab opens the rule in. The history tab only shows it in the breadcrumb and the header.',
options: ALERT_SCHEMAS,
value: 'v2',
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/rules/:id/history/stats',
response.json((req) =>
ruleHistoryStatsResponse(
windowOf(req),
values.triggers,
values.resolutionMinutes,
),
),
),
rest.get(
'http://localhost/api/v2/rules/:id/history/top_contributors',
response.json(() =>
ruleHistoryTopContributorsResponse(values.topContributors, values.triggers),
),
),
rest.get(
'http://localhost/api/v2/rules/:id/history/overall_status',
response.json((req) =>
ruleHistoryOverallStatusResponse(windowOf(req), values.statusWindows),
),
),
rest.get(
'http://localhost/api/v2/rules/:id/history/timeline',
response.json((req) =>
ruleHistoryTimelineResponse({
total: values.timelineEntries,
limit: TIMELINE_TABLE_PAGE_SIZE,
end: windowOf(req).end,
ruleId: String(req.params.id),
ruleName: FIRST_RULE_NAME,
}),
),
),
rest.get(
'http://localhost/api/v2/rules/:id/history/filter_keys',
response.json(() => ruleHistoryFilterKeysResponse()),
),
rest.get(
'http://localhost/api/v2/rules/:id/history/filter_values',
response.json((req) =>
ruleHistoryFilterValuesResponse(req.url.searchParams.get('key') ?? ''),
),
),
rest.get(
'http://localhost/api/v2/rules/:id',
response.json((req) =>
alertRuleByIdResponse(String(req.params.id), {
severity: 'mixed',
state: 'mixed',
schema: values.alertSchema,
}),
),
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(CHANNEL_MAX)),
),
],
config: () => ({
route: `/alerts/history?ruleId=${STORY_RULE_ID}&relativeTime=${STORY_RELATIVE_TIME}`,
}),
});

View File

@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { alertHistoryMocks } from './AlertHistory.stories.mocks';
import AlertList from '../../index';
type AlertHistoryArgs = PageStoryArgs<typeof alertHistoryMocks>;
const pageStory = storyMocks(alertHistoryMocks, { layout: 'app' });
/**
* One rule's firing history: the timeline of state changes, the overall status for
* the period, and the series contributing most to it.
*
* Route: `/alerts/history?ruleId=...`.
*/
const meta = {
title: 'Pages/Alerts/History',
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<AlertHistoryArgs>;
export default meta;
type Story = StoryObj<AlertHistoryArgs>;
/**
* How one rule behaved over the selected window: how often it fired, how long
* it took to resolve, what contributed most, and every state change in order.
*/
export const Default: Story = {};
/** A rule that never fired in the window: both cards say so and the table is empty. */
export const NeverTriggered: Story = {
args: {
triggers: 0,
resolutionMinutes: 0,
topContributors: 0,
timelineEntries: 0,
statusWindows: 0,
},
};
/** A rule firing constantly, where the timeline pages rather than fits. */
export const Noisy: Story = {
args: { triggers: 184, timelineEntries: 40, topContributors: 8 },
};

View File

@@ -0,0 +1,216 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
RuletypesAlertStateDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
type GetRuleHistoryFilterKeys200,
type GetRuleHistoryFilterValues200,
type GetRuleHistoryOverallStatus200,
type GetRuleHistoryStats200,
type GetRuleHistoryTimeline200,
type GetRuleHistoryTopContributors200,
type Querybuildertypesv5LabelDTO,
type Querybuildertypesv5TimeSeriesDTO,
type RulestatehistorytypesGettableRuleStateHistoryDTO,
} from 'api/generated/services/sigNoz.schemas';
const MINUTE = 60 * 1000;
/** The labels a rule's history is broken down by, both in the table and the filters. */
export const HISTORY_LABEL_VALUES: Record<string, string[]> = {
'service.name': ['checkout', 'payments', 'auth', 'search'],
'deployment.environment': ['prod', 'staging'],
'host.name': ['ip-10-0-1-14', 'ip-10-0-2-31', 'ip-10-0-3-77'],
severity: ['critical', 'error', 'warning'],
};
const HISTORY_LABEL_KEYS = Object.keys(HISTORY_LABEL_VALUES);
const labelsFor = (index: number): Querybuildertypesv5LabelDTO[] =>
HISTORY_LABEL_KEYS.map((name) => {
const values = HISTORY_LABEL_VALUES[name];
return { key: { name }, value: values[index % values.length] };
});
/** Points spread evenly across the window, derived from the index so a re-render redraws the same line. */
const series = (
start: number,
end: number,
points: number,
base: number,
amplitude: number,
): Querybuildertypesv5TimeSeriesDTO => {
const step = (end - start) / Math.max(points - 1, 1);
return {
labels: [],
values: Array.from({ length: points }, (_unused, index) => ({
timestamp: Math.round(start + index * step),
value: Math.max(
0,
Math.round(base + amplitude * Math.sin(index / 2.5) + amplitude * 0.4),
),
})),
};
};
export interface HistoryWindow {
start: number;
end: number;
}
/** `currentAvgResolutionTime` is seconds: `formatTime` picks the unit it prints. */
export const ruleHistoryStatsResponse = (
{ start, end }: HistoryWindow,
triggers: number,
avgResolutionMinutes: number,
): GetRuleHistoryStats200 => {
const current = avgResolutionMinutes * 60;
const past = Math.round(current * 1.35);
return {
status: 'success',
data: {
totalCurrentTriggers: triggers,
totalPastTriggers: Math.round(triggers * 0.7),
currentAvgResolutionTime: current,
pastAvgResolutionTime: past,
currentTriggersSeries: series(start, end, 24, triggers / 12, triggers / 8),
pastTriggersSeries: series(start, end, 24, triggers / 16, triggers / 10),
currentAvgResolutionTimeSeries: series(start, end, 24, current, current / 3),
pastAvgResolutionTimeSeries: series(start, end, 24, past, past / 3),
},
};
};
export const TOP_CONTRIBUTOR_MAX = 8;
export const ruleHistoryTopContributorsResponse = (
count: number,
totalTriggers: number,
): GetRuleHistoryTopContributors200 => ({
status: 'success',
data: Array.from({ length: count }, (_unused, index) => ({
fingerprint: 100_000 + index,
count: Math.max(1, Math.round(totalTriggers / (index + 2))),
labels: labelsFor(index),
relatedLogsLink: 'http://localhost/logs/logs-explorer',
relatedTracesLink: 'http://localhost/traces-explorer',
})),
});
/**
* The graph draws one band per window, so the windows have to tile the range
* end to end: a gap reads as a hole in the timeline rather than a quiet period.
*/
export const ruleHistoryOverallStatusResponse = (
{ start, end }: HistoryWindow,
windows: number,
): GetRuleHistoryOverallStatus200 => {
const step = (end - start) / Math.max(windows, 1);
return {
status: 'success',
data: Array.from({ length: windows }, (_unused, index) => ({
start: Math.round(start + index * step),
end: Math.round(start + (index + 1) * step),
state:
index % 5 === 0
? RuletypesAlertStateDTO.firing
: RuletypesAlertStateDTO.inactive,
})),
};
};
export const TIMELINE_MAX = 40;
export interface TimelineShape {
total: number;
limit: number;
end: number;
state?: RuletypesAlertStateDTO;
ruleId: string;
ruleName: string;
}
const timelineItem = (
index: number,
shape: TimelineShape,
): RulestatehistorytypesGettableRuleStateHistoryDTO => {
const state =
shape.state ??
(index % 2 === 0
? RuletypesAlertStateDTO.firing
: RuletypesAlertStateDTO.inactive);
return {
ruleId: shape.ruleId,
ruleName: shape.ruleName,
fingerprint: 100_000 + (index % TOP_CONTRIBUTOR_MAX),
labels: labelsFor(index),
overallState: state,
overallStateChanged: index % 3 === 0,
state,
stateChanged: index % 2 === 0,
unixMilli: shape.end - index * 7 * MINUTE,
value: Number((60 + (index % 9) * 4.5).toFixed(2)),
relatedLogsLink: 'http://localhost/logs/logs-explorer',
relatedTracesLink: 'http://localhost/traces-explorer',
};
};
export const ruleHistoryTimelineResponse = (
shape: TimelineShape,
): GetRuleHistoryTimeline200 => {
const size = Math.min(shape.limit, shape.total);
return {
status: 'success',
data: {
total: shape.total,
nextCursor: shape.total > size ? 'next-page-cursor' : '',
items: Array.from({ length: size }, (_unused, index) =>
timelineItem(index, shape),
),
},
};
};
export const ruleHistoryFilterKeysResponse =
(): GetRuleHistoryFilterKeys200 => ({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
HISTORY_LABEL_KEYS.map((name) => [
name,
[
{
name,
signal: TelemetrytypesSignalDTO.traces,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
],
]),
),
},
});
export const ruleHistoryFilterValuesResponse = (
key: string,
): GetRuleHistoryFilterValues200 => {
const values = HISTORY_LABEL_VALUES[key] ?? [];
return {
status: 'success',
data: {
complete: true,
values: { stringValues: values, relatedValues: values },
},
};
};

View File

@@ -0,0 +1,148 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
CHANNEL_MAX,
RULE_STATE_CHOICES,
SEVERITY_CHOICES,
type AlertSchema,
type RuleStateChoice,
type SeverityChoice,
} from '../../stories/__story_mockdata__/alerts';
import {
alertFieldKeysResponse,
alertFieldValuesResponse,
alertMetricMetadataResponse,
alertMetricsResponse,
alertPreviewSeries,
} from '../../stories/__story_mockdata__/alertQuery';
const STORY_RULE_ID = 'rule-1';
const STORY_RELATIVE_TIME = '6h';
const RULE = 'Alert overview · rule';
const PREVIEW = 'Alert overview · preview';
export const alertOverviewMocks = defineStoryMocks({
controls: {
alertSchema: choiceControl<AlertSchema>('Alert schema', {
group: RULE,
description:
'`v2` opens the stepper the new alert form uses; `classic` is the single-form page rules written before it still open in.',
options: ALERT_SCHEMAS,
value: 'v2',
}),
ruleState: choiceControl<RuleStateChoice>('State', {
group: RULE,
description: 'The badge next to the rule name in the header.',
options: RULE_STATE_CHOICES,
value: 'firing',
}),
ruleSeverity: choiceControl<SeverityChoice>('Severity', {
group: RULE,
options: SEVERITY_CHOICES,
value: 'critical',
}),
channels: countControl('Notification channels', {
group: RULE,
description: 'What the thresholds can be routed to.',
value: 5,
max: CHANNEL_MAX,
}),
previewSeries: countControl('Preview series', {
group: PREVIEW,
description:
'Lines the chart above the condition draws. Zero is the preview with nothing to plot.',
value: 3,
max: 6,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/rules/:id',
response.json((req) =>
alertRuleByIdResponse(String(req.params.id), {
severity: values.ruleSeverity,
state: values.ruleState,
schema: values.alertSchema,
}),
),
),
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.patch('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.delete('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
}),
),
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
),
rest.get(
'http://localhost/api/v2/metrics',
response.json((req) =>
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v2/metrics/metadata',
response.json((req) =>
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/keys',
response.json((req) =>
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
alertFieldValuesResponse(
req.url.searchParams.get('name') ?? '',
req.url.searchParams.get('searchText') ?? '',
),
),
),
],
config: () => ({
route: `/alerts/overview?ruleId=${STORY_RULE_ID}&relativeTime=${STORY_RELATIVE_TIME}`,
}),
});

View File

@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { alertOverviewMocks } from './AlertOverview.stories.mocks';
import AlertList from '../../index';
type AlertOverviewArgs = PageStoryArgs<typeof alertOverviewMocks>;
const pageStory = storyMocks(alertOverviewMocks, { layout: 'app' });
/**
* One rule read only: its condition, the series it evaluates against, its state
* and the channels it notifies.
*
* Route: `/alerts/overview?ruleId=...`.
*/
const meta = {
title: 'Pages/Alerts/Overview',
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<AlertOverviewArgs>;
export default meta;
type Story = StoryObj<AlertOverviewArgs>;
/**
* One alert rule opened up: the query it watches, the condition it fires on and
* where the notification goes.
*/
export const Default: Story = {};
/** A rule written before the current schema, which opens in the classic form. */
export const ClassicSchema: Story = {
args: { alertSchema: 'classic' },
};
/** A rule someone turned off: the toggle in the header is what turns it back on. */
export const Disabled: Story = {
args: { ruleState: 'disabled' },
};
/** A rule with no matching series in the window, so the preview has nothing to draw. */
export const NoPreviewData: Story = {
args: { previewSeries: 0 },
};
/** The rule id in the URL does not resolve, which is where the page gives up. */
export const RuleNotFound: Story = {
args: { dataState: 'error' },
// The mocked rule request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
};

View File

@@ -0,0 +1,57 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertRulesResponse,
RULE_MAX,
RULE_STATE_CHOICES,
SEVERITY_CHOICES,
type RuleStateChoice,
type SeverityChoice,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListTabs } from '../../types';
const LIST = 'Alert rules · list';
export const alertRulesMocks = defineStoryMocks({
controls: {
rules: countControl('Alert rules', {
group: LIST,
value: 8,
max: RULE_MAX,
}),
ruleSeverity: choiceControl<SeverityChoice>('Severity', {
group: LIST,
description:
'The severity label every rule carries. `mixed` leaves each rule with its own.',
options: SEVERITY_CHOICES,
value: 'mixed',
}),
ruleState: choiceControl<RuleStateChoice>('State', {
group: LIST,
description:
'The evaluation state the Status column shows. `disabled` also switches the row action to Enable.',
options: RULE_STATE_CHOICES,
value: 'mixed',
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/rules',
response.json(() =>
alertRulesResponse(values.rules, {
severity: values.ruleSeverity,
state: values.ruleState,
}),
),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.ALERT_RULES}` }),
});

View File

@@ -0,0 +1,135 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { alertRulesMocks } from './AlertRules.stories.mocks';
import AlertList from '../../index';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
/**
* The rule list tab: every rule with its severity, state and channels. Creating
* and editing follow the legacy editor role.
*
* Route: `/alerts?tab=AlertRules`.
*/
const meta = {
title: 'Pages/Alerts/Rules',
tags: ['role-gated', 'play'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<AlertRulesArgs>;
export default meta;
type Story = StoryObj<AlertRulesArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* Every alert rule the org has configured, with the state each one evaluated to
* on its last run and the severity it fires at.
*/
export const Default: Story = {};
/** A workspace with no rule yet, which is where the tab explains itself. */
export const NoRules: Story = {
args: { rules: 0 },
};
/** Search: an unmatched query retains the filters and renders the no-results branch. */
export const SearchNoResults: Story = {
parameters: {
signoz: { route: '/alerts?tab=AlertRules&search=no-matching-alert-rule' },
},
};
/** Data: the list remains mounted while its initial request is pending. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/** Data: the table's retryable error state after the rule request fails. */
export const LoadError: Story = {
args: { dataState: 'error' },
// The mocked rule request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
};
/** Density: a second page of rules with the shared pagination controls visible. */
export const Paginated: Story = {
args: { rules: RULE_MAX },
parameters: {
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
},
};
/**
* A viewer: the row actions and the New Alert button are gone, so the tab is
* read-only.
*/
export const Viewer: Story = {
args: { access: 'viewer' },
};
/** The per-rule actions: enable or disable, edit, clone and delete. */
export const RowActions: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
untilLoaded,
);
await userEvent.click(actions);
await screen.findByText(/clone/i);
},
};
/** Interaction: a disabled rule exposes Enable in its real row-action menu. */
export const RowActionsDisabledRule: Story = {
args: { ruleState: 'disabled' },
play: async ({ canvasElement }): Promise<void> => {
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
untilLoaded,
);
await userEvent.click(actions);
await screen.findByText(/^enable$/i);
},
};
/** The columns the table can show, including the audit ones it hides by default. */
export const ColumnPicker: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'alert-columns-button',
undefined,
untilLoaded,
),
);
await screen.findByText(/toggle columns/i);
},
};
/**
* Every label badge in the Labels column, held open: each one repeats its own
* `key: value` with a copy button. The rules carry two labels apiece, which fit
* the column, so the overflow chip and its list are on the Triggered tab
* instead.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};

View File

@@ -0,0 +1,35 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
CHANNEL_MAX,
channelsResponse,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListTabs } from '../../types';
const LIST = 'Channels · list';
export const channelsMocks = defineStoryMocks({
controls: {
channels: countControl('Notification channels', {
group: LIST,
description: 'One per channel type, in the order the seeds declare them.',
value: 5,
max: CHANNEL_MAX,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.CHANNELS}` }),
});

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { channelsMocks } from './Channels.stories.mocks';
import AlertList from '../../index';
type ChannelsArgs = PageStoryArgs<typeof channelsMocks>;
const pageStory = storyMocks(channelsMocks, { layout: 'app' });
/**
* Notification channels tab: what a rule can notify, one row per channel.
*
* Route: `/alerts?tab=Channels`.
*/
const meta = {
title: 'Pages/Alerts/Channels/List',
tags: ['role-gated'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ChannelsArgs>;
export default meta;
type Story = StoryObj<ChannelsArgs>;
/**
* Where notifications go: every configured channel with the integration it
* sends through.
*/
export const Default: Story = {};
/** A workspace with nowhere to send an alert yet. */
export const NoChannels: Story = {
args: { channels: 0 },
};
/** Data: the channel list's existing loading spinner. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/** Data: the channel list's retryable request-error branch. */
export const LoadError: Story = {
args: { dataState: 'error' },
// The mocked channels request intentionally fails; the resulting console error
// is the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
};
/**
* A viewer: the Action column and the New Alert Channel button are gone, and
* the button explains who to ask.
*/
export const Viewer: Story = {
args: { access: 'viewer' },
};

View File

@@ -0,0 +1,75 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockResolver } from '@/storybook/msw/types';
import {
CHANNEL_ACTION_OUTCOMES,
channelActionError,
CHANNEL_TYPES,
channelResponse,
type ChannelActionOutcome,
type ChannelType,
} from '../../stories/__story_mockdata__/alerts';
const STORY_CHANNEL_ID = '1';
const CHANNEL = 'Channel · integration';
const ACTIONS = 'Channel · actions';
const resolveChannelActionSuccess: MockResolver = (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null }));
const rejectChannelAction: MockResolver = (_req, res, ctx) =>
res(ctx.status(500), ctx.json(channelActionError()));
export const channelsEditMocks = defineStoryMocks({
controls: {
channelType: choiceControl<ChannelType>('Channel type', {
group: CHANNEL,
description:
'The integration the saved channel uses, which decides every field below the type picker.',
options: CHANNEL_TYPES,
value: 'slack',
}),
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
group: ACTIONS,
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
testOutcome: choiceControl<ChannelActionOutcome>('Testing the channel', {
group: ACTIONS,
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/channels/:id',
response.json((req) =>
channelResponse(String(req.params.id), values.channelType),
),
),
rest.put(
'http://localhost/api/v1/channels/:id',
values.saveOutcome === 'fails'
? rejectChannelAction
: resolveChannelActionSuccess,
),
rest.post(
'http://localhost/api/v1/testChannel',
values.testOutcome === 'fails'
? rejectChannelAction
: resolveChannelActionSuccess,
),
],
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
});

View File

@@ -0,0 +1,161 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { channelsEditMocks } from './ChannelsEdit.stories.mocks';
import AlertList from '../../index';
type ChannelsEditArgs = PageStoryArgs<typeof channelsEditMocks>;
const pageStory = storyMocks(channelsEditMocks, { layout: 'app' });
/**
* One channel's settings, with the fields its type asks for and the test call the
* form makes before saving.
*
* Route: `/alerts/channels/edit/:id`.
*/
const meta = {
title: 'Pages/Alerts/Channels/Edit',
tags: ['play'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ChannelsEditArgs>;
export default meta;
/** The page loads the saved channel before it renders the form. */
const untilLoaded = { timeout: 15_000 };
type Story = StoryObj<ChannelsEditArgs>;
/**
* A saved notification channel opened for editing: the name and the type are
* fixed, and the integration's own settings are filled from what was stored.
*/
export const Default: Story = {};
/** Mutation: clearing the required webhook URL surfaces the form's validation feedback. */
export const InvalidRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const webhookUrl = await canvas.findByTestId(
'webhook-url-textbox',
undefined,
untilLoaded,
);
await userEvent.clear(webhookUrl);
await userEvent.click(
await canvas.findByTestId('save-channel-button', undefined, untilLoaded),
);
await screen.findByText('Webhook URL is mandatory');
},
};
/** Mutation: a failed test request keeps the edit form open and renders its error feedback. */
export const TestChannelFailure: Story = {
args: { testOutcome: 'fails' },
// The mocked test request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'test-channel-button',
undefined,
untilLoaded,
),
);
await screen.findByText('Storybook forced channel failure');
},
};
/** Mutation: a failed save leaves the saved channel editable and surfaces the request error. */
export const SaveFailure: Story = {
args: { saveOutcome: 'fails' },
// The mocked save request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
untilLoaded,
),
);
await screen.findByText('Storybook forced channel failure');
},
};
/**
* The PagerDuty channel, whose form carries the routing key and the extra
* details sent with the incident.
*/
export const PagerDuty: Story = {
args: { channelType: 'pagerduty' },
};
/** The webhook channel, saved with basic auth on the outgoing request. */
export const Webhook: Story = {
args: { channelType: 'webhook' },
};
/**
* The Opsgenie channel, whose form carries the integration API key and the
* priority the alert is raised at.
*/
export const Opsgenie: Story = {
args: { channelType: 'opsgenie' },
};
/**
* The email channel, whose only editable field is the comma-separated recipient
* list: the form keeps the HTML body and the headers it was saved with.
*/
export const Email: Story = {
args: { channelType: 'email' },
};
/**
* The Microsoft Teams channel, stored under `msteamsv2_configs` and filled from
* the channel's incoming webhook.
*/
export const MicrosoftTeams: Story = {
args: { channelType: 'msteams' },
};
/** The Google Chat channel, filled from the space's incoming webhook. */
export const GoogleChat: Story = {
args: { channelType: 'googlechat' },
};
/**
* The Jira channel, which files an issue: the site and project it files into,
* the transitions that resolve and reopen it, and the API token behind the
* Atlassian account, which the form reads off the basic auth block.
*/
export const Jira: Story = {
args: { channelType: 'jira' },
};
/**
* The Jira Service Management Ops channel, whose tags are stored as one
* comma-separated string and come back as chips.
*/
export const JiraServiceManagementOps: Story = {
args: { channelType: 'jsmops' },
};
/**
* The incident.io channel, pointed at one alert source's events URL with the
* token for it and the metadata merged over the alert's labels.
*/
export const IncidentIO: Story = {
args: { channelType: 'incidentio' },
};

View File

@@ -0,0 +1,59 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockResolver } from '@/storybook/msw/types';
import {
CHANNEL_ACTION_OUTCOMES,
channelActionError,
type ChannelActionOutcome,
} from '../../stories/__story_mockdata__/alerts';
const ACTIONS = 'Channel · actions';
const resolveChannelCreated: MockResolver = (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null }));
const resolveChannelTested: MockResolver = (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null }));
const rejectChannelAction: MockResolver = (_req, res, ctx) =>
res(ctx.status(500), ctx.json(channelActionError()));
/**
* The form holds the channel type in component state, so the type itself is
* stories with a `play` that picks one rather than a control; saving and
* testing the channel are.
*/
export const channelsNewMocks = defineStoryMocks({
controls: {
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
group: ACTIONS,
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
testOutcome: choiceControl<ChannelActionOutcome>('Testing the channel', {
group: ACTIONS,
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
},
handlers: (values) => [
rest.post(
'http://localhost/api/v1/channels',
values.saveOutcome === 'fails' ? rejectChannelAction : resolveChannelCreated,
),
rest.post(
'http://localhost/api/v1/testChannel',
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
),
],
config: () => ({ route: '/alerts/channels/new' }),
});

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { channelsNewMocks } from './ChannelsNew.stories.mocks';
import AlertList from '../../index';
type ChannelsNewArgs = PageStoryArgs<typeof channelsNewMocks>;
const pageStory = storyMocks(channelsNewMocks, { layout: 'app' });
/**
* The new channel form: pick a type, fill its fields, test it, save.
*
* Route: `/alerts/channels/new`.
*/
const meta = {
title: 'Pages/Alerts/Channels/New',
tags: ['play'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ChannelsNewArgs>;
export default meta;
type Story = StoryObj<ChannelsNewArgs>;
/**
* The type is an antd Select: clicking the element carrying the test id does
* nothing, the combobox inside it is what opens the list.
*/
const selectChannelType = async (
canvasElement: HTMLElement,
label: RegExp,
): Promise<void> => {
const canvas = within(canvasElement);
const select = await canvas.findByTestId('channel-type-select');
await userEvent.click(within(select).getByRole('combobox'));
await userEvent.click(await screen.findByTitle(label));
// The form under the Select swaps a render after the option is taken, so the
// Select's own value is what says the story is on the type it names.
await within(select).findByTitle(label);
};
/** A new notification channel, on the Slack form the page opens with. */
export const Default: Story = {};
/** Interaction: the channel-type Select opens its real portal-backed option list. */
export const ChannelTypeSelectOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
const select = await within(canvasElement).findByTestId(
'channel-type-select',
);
await userEvent.click(within(select).getByRole('combobox'));
await screen.findByRole('listbox');
},
};
/** Mutation: saving without a channel name shows the form's required-field feedback. */
export const InvalidRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('save-channel-button'),
);
await screen.findByText('Channel name is mandatory');
},
};
/** Mutation: a failed test request opens the application's error feedback. */
export const TestChannelFailure: Story = {
args: { testOutcome: 'fails' },
// The mocked test request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('test-channel-button'),
);
await screen.findByText('Storybook forced channel failure');
},
};
/** Mutation: a failed create request leaves the form visible with error feedback. */
export const SaveFailure: Story = {
args: { saveOutcome: 'fails' },
// The mocked save request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox'),
'Storybook channel',
);
await userEvent.type(
await canvas.findByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/storybook',
);
await userEvent.click(await canvas.findByTestId('save-channel-button'));
await screen.findByText('Storybook forced channel failure');
},
};
/** The webhook form: the URL to post to and the auth to send with it. */
export const Webhook: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Webhook$/);
},
};
/** The PagerDuty form: routing key, severity and the incident details. */
export const PagerDuty: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Pagerduty$/);
},
};
/** The Opsgenie form: the integration API key, the alert body and its priority. */
export const Opsgenie: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Opsgenie$/);
},
};
/** The email form: the recipients and the HTML body the alert is sent as. */
export const Email: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Email$/);
},
};
/** The Microsoft Teams form: the channel's incoming webhook and the card text. */
export const MicrosoftTeams: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Microsoft Teams$/);
},
};
/**
* The Google Chat form, whose webhook URL is rejected unless it is an https URL
* on `chat.googleapis.com`.
*/
export const GoogleChat: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Google Chat$/);
},
};
/**
* The Jira form: where the issue is filed, the transitions that close and
* reopen it, and the Atlassian account the API token belongs to.
*/
export const Jira: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Jira$/);
},
};
/** The Jira Service Management Ops form: the API key, priority and tags. */
export const JiraServiceManagementOps: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Jira Service Management Ops$/);
},
};
/** The incident.io form: the alert source's events URL and its token. */
export const IncidentIO: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^incident\.io$/);
},
};

View File

@@ -0,0 +1,103 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
DOWNTIME_KINDS,
DOWNTIME_MAX,
downtimeSchedulesResponse,
type DowntimeKind,
} from './__story_mockdata__/plannedDowntime';
import {
alertRulesResponse,
RULE_MAX,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
const LIST = 'Planned downtime · list';
const REQUEST = 'Planned downtime · requests';
const REQUEST_STATES = ['loaded', 'error'] as const;
type RequestState = (typeof REQUEST_STATES)[number];
export const plannedDowntimeMocks = defineStoryMocks({
controls: {
schedules: countControl('Planned downtimes', {
group: LIST,
value: 4,
max: DOWNTIME_MAX,
}),
downtimeKind: choiceControl<DowntimeKind>('Kind', {
group: LIST,
description:
'A recurring downtime carries a repeat rule instead of an end time, which is what the Repeats row shows.',
options: DOWNTIME_KINDS,
value: 'mixed',
}),
silencedRules: countControl('Alert rules to silence', {
group: LIST,
description:
'The rules the form offers, and the names a downtime resolves its silenced ids to.',
value: 8,
max: RULE_MAX,
}),
schedulesState: choiceControl<RequestState>('Schedules request', {
group: REQUEST,
options: REQUEST_STATES,
value: 'loaded',
}),
rulesState: choiceControl<RequestState>('Alert rules request', {
group: REQUEST,
options: REQUEST_STATES,
value: 'loaded',
}),
},
handlers: (values, _response) => [
rest.get('http://localhost/api/v1/downtime_schedules', (_req, res, ctx) =>
values.schedulesState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(
ctx.json(
downtimeSchedulesResponse(values.schedules, values.downtimeKind),
),
),
),
rest.post('http://localhost/api/v1/downtime_schedules', (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
),
rest.put('http://localhost/api/v1/downtime_schedules/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.delete(
'http://localhost/api/v1/downtime_schedules/:id',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
values.rulesState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(
ctx.json(
alertRulesResponse(values.silencedRules, {
severity: 'mixed',
state: 'mixed',
}),
),
),
),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
}),
});

View File

@@ -0,0 +1,157 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { plannedDowntimeMocks } from './PlannedDowntime.stories.mocks';
import { FIRST_DOWNTIME_NAME } from './__story_mockdata__/plannedDowntime';
import AlertList from '../../index';
type PlannedDowntimeArgs = PageStoryArgs<typeof plannedDowntimeMocks>;
const pageStory = storyMocks(plannedDowntimeMocks, { layout: 'app' });
/**
* Windows that silence rules on a schedule, one off or recurring, with the rules
* each window covers.
*
* Route: `/alerts?tab=Configuration&subTab=PlannedDowntime`.
*/
const meta = {
title: 'Pages/Alerts/Planned Downtime',
tags: ['role-gated', 'play'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<PlannedDowntimeArgs>;
export default meta;
type Story = StoryObj<PlannedDowntimeArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The windows where alerting is held back: what is running now, what is
* scheduled, and which rules each one silences.
*/
export const Default: Story = {};
/** A workspace that has never scheduled a downtime. */
export const NoDowntimes: Story = {
args: { schedules: 0 },
};
/**
* A viewer: the edit and delete actions on a row and the New downtime button
* are gone.
*/
export const Viewer: Story = {
args: { access: 'viewer' },
};
/** A downtime opened up: who scheduled it, the window, and what it silences. */
export const Expanded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(FIRST_DOWNTIME_NAME, undefined, untilLoaded),
);
await canvas.findByText(/alerts silenced/i);
},
};
/** The form a downtime is scheduled in: the window, the repeat and the rules. */
export const NewDowntime: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/new downtime/i,
undefined,
untilLoaded,
),
);
await screen.findByText(/new planned downtime/i);
},
};
/** The deletion confirmation opened from the first schedule's real row action. */
export const DeleteDowntimeConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const action = (
await within(canvasElement).findByText(
FIRST_DOWNTIME_NAME,
undefined,
untilLoaded,
)
)
.closest('.header-content')
// The row action holds edit then delete, neither of them labelled.
?.querySelectorAll('.action-btn svg')[1];
if (!action) {
throw new Error('Downtime delete action did not render');
}
await userEvent.click(action);
// The modal titles itself and its confirm button the same.
await screen.findByRole('button', { name: 'Delete Schedule' });
},
};
/** A client-side search with no matching downtime schedule. */
export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const search = await canvas.findByPlaceholderText(
'Search for a planned downtime...',
undefined,
untilLoaded,
);
await userEvent.type(search, 'no matching downtime');
await canvas.findByRole('table');
},
};
/** The schedule list request failed. */
export const LoadError: Story = {
args: { schedulesState: 'error' },
// The mocked schedules request intentionally fails; the resulting console error
// is the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
};
/** The new-downtime form with its alert-rules request failed. */
export const RulesLoadError: Story = {
args: { rulesState: 'error' },
// The mocked rules request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: NewDowntime.play,
};
/** A recurring schedule exposes its recurrence and duration treatment. */
export const RecurringSchedule: Story = {
args: { downtimeKind: 'recurring' },
};
/** A schedule currently in effect. */
export const ActiveNow: Story = {
args: { schedules: 1 },
};
/** Native form validation after attempting to save an empty downtime. */
export const FormValidationError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await NewDowntime.play?.({ canvasElement } as never);
await userEvent.click(
await screen.findByRole('button', { name: 'Add downtime schedule' }),
);
await screen.findByText('Please enter Name');
},
};

View File

@@ -0,0 +1,164 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
AlertmanagertypesMaintenanceKindDTO,
AlertmanagertypesMaintenanceStatusDTO,
AlertmanagertypesRepeatOnDTO,
AlertmanagertypesRepeatTypeDTO,
type AlertmanagertypesPlannedMaintenanceDTO,
type ListDowntimeSchedules200,
} from 'api/generated/services/sigNoz.schemas';
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const at = (offsetMs: number): string =>
new Date(Date.now() + offsetMs).toISOString();
export const DOWNTIME_KINDS = ['mixed', 'fixed', 'recurring'] as const;
export type DowntimeKind = (typeof DOWNTIME_KINDS)[number];
interface DowntimeSeed {
name: string;
description: string;
kind: AlertmanagertypesMaintenanceKindDTO;
status: AlertmanagertypesMaintenanceStatusDTO;
timezone: string;
/** Relative to now, so a story always has a live, an upcoming and a past one. */
startsInMs: number;
lastsMs: number;
repeatType?: AlertmanagertypesRepeatTypeDTO;
repeatOn?: AlertmanagertypesRepeatOnDTO[];
/** Rule ids from the shared alert seeds; empty silences every rule. */
alertIds: string[];
}
const SEEDS: DowntimeSeed[] = [
{
name: 'Postgres major version upgrade',
description: 'Primary and replicas are cycled one at a time.',
kind: AlertmanagertypesMaintenanceKindDTO.fixed,
status: AlertmanagertypesMaintenanceStatusDTO.active,
timezone: 'UTC',
startsInMs: -2 * HOUR,
lastsMs: 6 * HOUR,
alertIds: ['rule-5', 'rule-3'],
},
{
name: 'Nightly ETL window',
description:
'The warehouse load runs every night and saturates the ingesters.',
kind: AlertmanagertypesMaintenanceKindDTO.recurring,
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
timezone: 'Europe/Berlin',
startsInMs: 8 * HOUR,
lastsMs: 3 * HOUR,
repeatType: AlertmanagertypesRepeatTypeDTO.daily,
alertIds: ['rule-12'],
},
{
name: 'Weekend cluster drain',
description: 'Nodes are drained for kernel patching.',
kind: AlertmanagertypesMaintenanceKindDTO.recurring,
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
timezone: 'America/New_York',
startsInMs: 3 * DAY,
lastsMs: 4 * HOUR,
repeatType: AlertmanagertypesRepeatTypeDTO.weekly,
repeatOn: [
AlertmanagertypesRepeatOnDTO.saturday,
AlertmanagertypesRepeatOnDTO.sunday,
],
alertIds: [],
},
{
name: 'Checkout release freeze',
description: 'Deploy window for the checkout rewrite.',
kind: AlertmanagertypesMaintenanceKindDTO.fixed,
status: AlertmanagertypesMaintenanceStatusDTO.expired,
timezone: 'UTC',
startsInMs: -9 * DAY,
lastsMs: 2 * HOUR,
alertIds: ['rule-1', 'rule-2'],
},
{
name: 'Kafka broker rebalance',
description: 'Partitions move between brokers, lag spikes are expected.',
kind: AlertmanagertypesMaintenanceKindDTO.fixed,
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
timezone: 'Asia/Kolkata',
startsInMs: 26 * HOUR,
lastsMs: 90 * MINUTE,
alertIds: ['rule-4'],
},
{
name: 'Monthly billing reconciliation',
description: 'Batch jobs run long on the first of the month.',
kind: AlertmanagertypesMaintenanceKindDTO.recurring,
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
timezone: 'UTC',
startsInMs: 5 * DAY,
lastsMs: 12 * HOUR,
repeatType: AlertmanagertypesRepeatTypeDTO.monthly,
alertIds: ['rule-9'],
},
];
export const DOWNTIME_MAX = SEEDS.length;
/** The list sorts by last update, and the seeds are built newest first. */
export const FIRST_DOWNTIME_NAME = SEEDS[0].name;
const durationLabel = (ms: number): string =>
ms % HOUR === 0 ? `${ms / HOUR}h0m0s` : `${Math.round(ms / MINUTE)}m0s`;
const buildSchedule = (
index: number,
kind: DowntimeKind,
): AlertmanagertypesPlannedMaintenanceDTO => {
const seed = SEEDS[index % SEEDS.length];
const resolvedKind =
kind === 'mixed' ? seed.kind : (kind as AlertmanagertypesMaintenanceKindDTO);
const isRecurring =
resolvedKind === AlertmanagertypesMaintenanceKindDTO.recurring;
return {
id: `downtime-${index + 1}`,
name: seed.name,
description: seed.description,
kind: resolvedKind,
status: seed.status,
alertIds: seed.alertIds,
createdAt: at(-(index + 4) * DAY),
createdBy: 'ada@signoz.io',
updatedAt: at(-(index + 1) * DAY),
updatedBy: 'grace@signoz.io',
schedule: {
timezone: seed.timezone,
startTime: at(seed.startsInMs),
endTime: isRecurring ? undefined : at(seed.startsInMs + seed.lastsMs),
recurrence: isRecurring
? {
duration: durationLabel(seed.lastsMs),
repeatType: seed.repeatType ?? AlertmanagertypesRepeatTypeDTO.daily,
repeatOn: seed.repeatOn ?? null,
}
: undefined,
},
};
};
export const downtimeSchedulesResponse = (
count: number,
kind: DowntimeKind,
): ListDowntimeSchedules200 => ({
status: 'success',
data: Array.from({ length: count }, (_unused, index) =>
buildSchedule(index, kind),
),
});

View File

@@ -0,0 +1,87 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
ROUTING_POLICY_MAX,
routingPoliciesResponse,
} from './__story_mockdata__/routingPolicies';
import {
CHANNEL_MAX,
channelNames,
channelsResponse,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
const LIST = 'Routing policies · list';
const REQUEST = 'Routing policies · requests';
const REQUEST_STATES = ['loaded', 'error'] as const;
type RequestState = (typeof REQUEST_STATES)[number];
export const routingPoliciesMocks = defineStoryMocks({
controls: {
policies: countControl('Routing policies', {
group: LIST,
description: 'The table paginates at five, so the cap is past that.',
value: 4,
max: ROUTING_POLICY_MAX,
}),
channels: countControl('Notification channels', {
group: LIST,
description:
'The channels a policy can route to, and the ones its Channels row names.',
value: 6,
max: CHANNEL_MAX,
}),
policiesState: choiceControl<RequestState>('Policies request', {
group: REQUEST,
options: REQUEST_STATES,
value: 'loaded',
}),
channelsState: choiceControl<RequestState>('Channels request', {
group: REQUEST,
options: REQUEST_STATES,
value: 'loaded',
}),
},
handlers: (values, _response) => [
rest.get('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
values.policiesState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(
ctx.json(
routingPoliciesResponse(values.policies, channelNames(values.channels)),
),
),
),
rest.post('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
),
rest.put('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.delete('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v1/channels', (_req, res, ctx) =>
values.channelsState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(ctx.json(channelsResponse(values.channels))),
),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.ROUTING_POLICIES}`,
}),
});

View File

@@ -0,0 +1,139 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { routingPoliciesMocks } from './RoutingPolicies.stories.mocks';
import { FIRST_POLICY_NAME } from './__story_mockdata__/routingPolicies';
import AlertList from '../../index';
type RoutingPoliciesArgs = PageStoryArgs<typeof routingPoliciesMocks>;
const pageStory = storyMocks(routingPoliciesMocks, { layout: 'app' });
/**
* Policies that route a firing alert to channels by expression, in the order they
* are evaluated.
*
* Route: `/alerts?tab=Configuration&subTab=RoutingPolicies`.
*/
const meta = {
title: 'Pages/Alerts/Routing Policies',
tags: ['role-gated', 'play'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<RoutingPoliciesArgs>;
export default meta;
type Story = StoryObj<RoutingPoliciesArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The rules that decide which channel an alert reaches, matched on the labels
* the alert carries.
*/
export const Default: Story = {};
/** A workspace routing everything through the rule's own channels. */
export const NoPolicies: Story = {
args: { policies: 0 },
};
/** A viewer: the row actions and the New routing policy button are gone. */
export const Viewer: Story = {
args: { access: 'viewer' },
};
/** A policy opened up: the expression it matches on and where it sends. */
export const Expanded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(FIRST_POLICY_NAME, undefined, untilLoaded),
);
await canvas.findByText(/expression/i);
},
};
/** The form a policy is written in: the expression and the channels it routes to. */
export const NewPolicy: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/new routing policy/i,
undefined,
untilLoaded,
),
);
await screen.findByText(/create routing policy/i);
},
};
/** The policy deletion confirmation, opened from the first row's real action. */
export const DeletePolicyConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
(
await canvas.findAllByTestId(
'delete-routing-policy',
undefined,
untilLoaded,
)
)[0],
);
// The modal titles itself and its confirm button the same.
await screen.findByRole('button', { name: 'Delete Routing Policy' });
},
};
/** A client-side search that has no matching routing policies. */
export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const search = await canvas.findByPlaceholderText(
'Search for a routing policy...',
undefined,
untilLoaded,
);
await userEvent.type(search, 'no matching policy');
await canvas.findByText('No matching routing policies found.');
},
};
/** The list request failed while the rest of the alerts shell remains available. */
export const LoadError: Story = {
args: { policiesState: 'error' },
// The mocked policies request intentionally fails; the resulting console error
// is the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
};
/** The create form with its notification-channel request failed. */
export const ChannelsLoadError: Story = {
args: { channelsState: 'error' },
// The mocked channels request intentionally fails; the resulting console error
// is the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: NewPolicy.play,
};
/** Native form validation after submitting an empty routing-policy form. */
export const FormValidationError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await NewPolicy.play?.({ canvasElement } as never);
await userEvent.click(
await screen.findByRole('button', { name: 'Save Routing Policy' }),
);
await screen.findByText('Please provide a name for the routing policy');
},
};

View File

@@ -0,0 +1,96 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type {
ApiRoutingPolicy,
GetRoutingPoliciesResponse,
} from 'api/routingPolicies/getRoutingPolicies';
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
interface PolicySeed {
name: string;
description: string;
expression: string;
/** Indexes into the channel seeds the shared alert builders publish. */
channels: number[];
}
const SEEDS: PolicySeed[] = [
{
name: 'Critical production to on-call',
description: 'Anything critical in prod pages whoever is on call.',
expression: 'severity = "critical" AND env = "prod"',
channels: [1, 0],
},
{
name: 'Payments team ownership',
description: 'Payment alerts go to the team that owns the service.',
expression: 'team = "payments"',
channels: [0],
},
{
name: 'Platform warnings to chat',
description: 'Warnings from the platform team stay in chat.',
expression: 'team = "platform" AND severity = "warning"',
channels: [5],
},
{
name: 'Staging is email only',
description: 'Nothing from staging is allowed to page.',
expression: 'env = "staging"',
channels: [3],
},
{
name: 'Database incidents',
description: 'Anything touching Postgres opens an incident.',
expression: 'component = "database"',
channels: [4, 2],
},
{
name: 'Catch-all',
description: 'Everything not matched above lands in the ops channel.',
expression: 'severity != ""',
channels: [0],
},
];
export const ROUTING_POLICY_MAX = SEEDS.length;
export const FIRST_POLICY_NAME = SEEDS[0].name;
const buildPolicy = (
index: number,
channelNames: string[],
): ApiRoutingPolicy => {
const seed = SEEDS[index % SEEDS.length];
return {
id: `routing-policy-${index + 1}`,
name: seed.name,
description: seed.description,
expression: seed.expression,
channels: seed.channels
.map((channelIndex) => channelNames[channelIndex])
.filter(Boolean),
createdAt: ago((index + 6) * DAY),
updatedAt: ago((index + 1) * HOUR),
createdBy: 'ada@signoz.io',
updatedBy: 'grace@signoz.io',
};
};
export const routingPoliciesResponse = (
count: number,
channelNames: string[],
): GetRoutingPoliciesResponse => ({
status: 'success',
data: Array.from({ length: count }, (_unused, index) =>
buildPolicy(index, channelNames),
),
});

View File

@@ -0,0 +1,90 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
SEVERITY_CHOICES,
TRIGGERED_ALERT_MAX,
TRIGGERED_STATES,
triggeredAlertsResponse,
type SeverityChoice,
type TriggeredState,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListTabs } from '../../types';
const LIST = 'Triggered alerts · list';
export const triggeredAlertsMocks = defineStoryMocks({
controls: {
alerts: countControl('Triggered alerts', {
group: LIST,
value: 9,
max: TRIGGERED_ALERT_MAX,
}),
alertSeverity: choiceControl<SeverityChoice>('Severity', {
group: LIST,
description:
'The severity label every alert carries. `mixed` leaves each alert with its own, which is what the tag filter has something to narrow.',
options: SEVERITY_CHOICES,
value: 'mixed',
}),
alertState: choiceControl<TriggeredState>('Alert state', {
group: LIST,
description: 'Suppressed alerts are the ones a silence is holding back.',
options: TRIGGERED_STATES,
value: 'mixed',
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/alerts',
response.json(() =>
triggeredAlertsResponse(values.alerts, {
severity: values.alertSeverity,
state: values.alertState,
}),
),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.TRIGGERED_ALERTS}` }),
});
/**
* Long enough that the column can only fit the first badge, so the rest land in
* the overflow tooltip as one joined line.
*/
const OVERFLOW_LABELS: Record<string, string> = {
environment: 'production-eu-central-1',
team: 'platform-observability-oncall',
owner: 'sre-primary@signoz.io',
runbook: 'runbooks.internal.example.com/checkout/latency-budget',
tier: 'tier-0-revenue-critical',
compliance: 'soc2-type-2-in-scope',
};
export const overflowingLabels = rest.get(
'http://localhost/api/v1/alerts',
(_req, res, ctx) => {
const list = triggeredAlertsResponse(3, {
severity: 'mixed',
state: 'mixed',
});
return res(
ctx.status(200),
ctx.json({
...list,
data: list.data.map((alert) => ({
...alert,
labels: { ...alert.labels, ...OVERFLOW_LABELS },
})),
}),
);
},
);

View File

@@ -0,0 +1,150 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
overflowingLabels,
triggeredAlertsMocks,
} from './TriggeredAlerts.stories.mocks';
import AlertList from '../../index';
import { AlertListTabs } from '../../types';
type TriggeredAlertsArgs = PageStoryArgs<typeof triggeredAlertsMocks>;
const pageStory = storyMocks(triggeredAlertsMocks, { layout: 'app' });
/**
* Alerts firing now, grouped and filtered from the query string, with severity and
* state per row.
*
* Route: `/alerts?tab=TriggeredAlerts`.
*/
const meta = {
title: 'Pages/Alerts/Triggered',
tags: ['play'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<TriggeredAlertsArgs>;
export default meta;
type Story = StoryObj<TriggeredAlertsArgs>;
const tab = `/alerts?tab=${AlertListTabs.TRIGGERED_ALERTS}`;
/**
* The alerts firing right now, newest first, with how long each one has been
* firing and the labels the rule attached to it.
*/
export const Default: Story = {};
/** Nothing firing, which is the state an on-call engineer wants to see. */
export const NoAlerts: Story = {
args: { alerts: 0 },
};
/** Search: an unmatched term retains the filters and renders the no-results state. */
export const SearchNoResults: Story = {
parameters: {
signoz: { route: `${tab}&search=no-matching-alert` },
},
};
/** Data: the table's initial loading branch. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/** Data: the retryable error branch when the alert request fails. */
export const LoadError: Story = {
args: { dataState: 'error' },
// The mocked alerts request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
};
/**
* The same alerts collapsed under the service they came from: one row per
* group, each expanding to the alerts inside it.
*/
export const GroupedByService: Story = {
parameters: {
signoz: { route: `${tab}&groupBy=${JSON.stringify(['service'])}` },
},
};
/**
* A tag filter narrowing the list to the critical alerts, which is how the tab
* is read during an incident.
*/
export const FilteredToCritical: Story = {
parameters: {
signoz: {
route: `${tab}&alertFilters=${JSON.stringify(['severity:critical'])}`,
},
},
};
/** Interaction: the tag-filter menu is mounted in its portal. */
export const FilterComboboxOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('triggered-alerts-filter-combobox'),
);
await screen.findByRole('listbox');
},
};
/** Interaction: the group-by menu is mounted in its portal. */
export const GroupByComboboxOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('triggered-alerts-groupby-combobox'),
);
await screen.findByRole('listbox');
},
};
/** Interaction: a grouped row expands to its nested alert table. */
export const GroupedExpanded: Story = {
parameters: {
signoz: { route: `${tab}&groupBy=${JSON.stringify(['service'])}` },
},
play: async (): Promise<void> => {
const [firstGroup] = await screen.findAllByTestId('group-expand-toggle');
await userEvent.click(firstGroup);
// The nested table is what the group opens, and it carries its own count.
await screen.findByText(/showing 1 - 1 of 1/i);
},
};
/** Density: four selected severity filters exercise collapsed filter-pill overflow. */
export const ManyFilterPills: Story = {
parameters: {
signoz: {
route: `${tab}&alertFilters=${JSON.stringify([
'severity:critical',
'severity:error',
'severity:warning',
'severity:info',
])}`,
},
},
};
/**
* Both tooltips the Labels column has, held open: the badge that fits, which
* repeats its own `key: value`, and the overflow chip, which lists every label
* that did not fit as one line. The alerts here carry far more labels than the
* tab's own fixture, which is why the Triggered alerts, Severity and Alert
* state controls do not reach this story.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
parameters: { msw: { handlers: [overflowingLabels] } },
};

View File

@@ -0,0 +1,225 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
type GetFieldsKeys200,
type GetFieldsValues200,
type GetMetricMetadata200,
type ListMetrics200,
type MetricsexplorertypesListMetricDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
MetricRangePayloadV5,
QueryRangeRequestV5,
} from 'types/api/v5/queryRange';
import {
queryRangeV5EmptyResponse,
queryRangeV5TimeSeriesResponse,
timeSeriesPoints,
} from '@/storybook/msw/__story_mockdata__/queryRange';
const HOSTS = [
'ip-10-0-1-14',
'ip-10-0-2-31',
'ip-10-0-3-77',
'ip-10-0-4-08',
'ip-10-0-5-52',
'ip-10-0-6-19',
];
/**
* The chart the alert form previews the condition against, plotted over the
* window the form asked for and named after the query the request carried.
*/
export const alertPreviewSeries = async (
count: number,
req: { json: () => Promise<unknown> },
): Promise<MetricRangePayloadV5> => {
const body = (await req.json()) as QueryRangeRequestV5;
const queryName =
(body.compositeQuery?.queries?.[0]?.spec as { name?: string } | undefined)
?.name ?? 'A';
if (count === 0) {
return queryRangeV5EmptyResponse(queryName);
}
return queryRangeV5TimeSeriesResponse([
{
queryName,
series: Array.from({ length: count }, (_unused, index) => ({
labels: [
{ key: { name: 'host.name' }, value: HOSTS[index % HOSTS.length] },
],
values: timeSeriesPoints({
start: body.start,
end: body.end,
base: 55 + index * 6,
amplitude: 12,
seed: index * 3,
}),
})),
},
]);
};
const METRIC_SEEDS: MetricsexplorertypesListMetricDTO[] = [
{
metricName: 'system_cpu_utilization',
description: 'Ratio of the CPU that is in use, per host.',
unit: 'percent',
type: MetrictypesTypeDTO.gauge,
temporality: MetrictypesTemporalityDTO.unspecified,
isMonotonic: false,
},
{
metricName: 'system_memory_usage',
description: 'Memory in use, per host.',
unit: 'bytes',
type: MetrictypesTypeDTO.gauge,
temporality: MetrictypesTemporalityDTO.unspecified,
isMonotonic: false,
},
{
metricName: 'http_server_duration',
description: 'Duration of inbound HTTP requests.',
unit: 'ms',
type: MetrictypesTypeDTO.histogram,
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: false,
},
{
metricName: 'kafka_consumer_lag',
description: 'Messages a consumer group is behind.',
unit: '',
type: MetrictypesTypeDTO.gauge,
temporality: MetrictypesTemporalityDTO.unspecified,
isMonotonic: false,
},
{
metricName: 'postgresql_backends',
description: 'Connections open against the database.',
unit: '',
type: MetrictypesTypeDTO.sum,
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
},
];
/** The metric picker in the query section, narrowed by whatever was typed. */
export const alertMetricsResponse = (searchText: string): ListMetrics200 => ({
status: 'success',
data: {
metrics: METRIC_SEEDS.filter((metric) =>
metric.metricName.includes(searchText.toLowerCase()),
),
},
});
/** The unit the chart's y-axis defaults to when a metric is selected. */
export const alertMetricMetadataResponse = (
metricName: string,
): GetMetricMetadata200 => {
const metric =
METRIC_SEEDS.find((seed) => seed.metricName === metricName) ??
METRIC_SEEDS[0];
return {
status: 'success',
data: {
description: metric.description,
unit: metric.unit,
type: metric.type,
temporality: metric.temporality,
isMonotonic: metric.isMonotonic,
},
};
};
const FIELD_SEEDS: TelemetrytypesTelemetryFieldKeyDTO[] = [
{
name: 'service.name',
fieldContext: TelemetrytypesFieldContextDTO.resource,
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
},
{
name: 'deployment.environment',
fieldContext: TelemetrytypesFieldContextDTO.resource,
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
},
{
name: 'host.name',
fieldContext: TelemetrytypesFieldContextDTO.resource,
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
},
{
name: 'http.route',
fieldContext: TelemetrytypesFieldContextDTO.attribute,
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
},
{
name: 'http.status_code',
fieldContext: TelemetrytypesFieldContextDTO.attribute,
fieldDataType: TelemetrytypesFieldDataTypeDTO.int64,
},
{
name: 'severity_text',
fieldContext: TelemetrytypesFieldContextDTO.log,
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
},
];
const FIELD_VALUES: Record<string, string[]> = {
'service.name': ['checkout', 'payments', 'auth', 'search'],
'deployment.environment': ['production', 'staging'],
'host.name': ['ip-10-0-1-14', 'ip-10-0-2-31', 'ip-10-0-3-77'],
'http.route': ['/checkout', '/payments/charge', '/v1/login'],
severity_text: ['ERROR', 'WARN', 'INFO'],
};
const NUMBER_FIELD_VALUES: Record<string, number[]> = {
'http.status_code': [200, 404, 500, 503],
};
const matching = <T>(values: T[], searchText: string): T[] =>
values.filter((value) =>
String(value).toLowerCase().includes(searchText.toLowerCase()),
);
/** What the filter box in the alert's query section completes on. */
export const alertFieldKeysResponse = (
searchText: string,
): GetFieldsKeys200 => ({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
FIELD_SEEDS.filter((field) =>
field.name.includes(searchText.toLowerCase()),
).map((field) => [field.name, [field]]),
),
},
});
export const alertFieldValuesResponse = (
name: string,
searchText: string,
): GetFieldsValues200 => ({
status: 'success',
data: {
complete: true,
values: {
stringValues: matching(FIELD_VALUES[name] ?? [], searchText),
numberValues: matching(NUMBER_FIELD_VALUES[name] ?? [], searchText),
relatedValues: [],
},
},
});

View File

@@ -0,0 +1,744 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
MetrictypesSpaceAggregationDTO,
MetrictypesTemporalityDTO,
MetrictypesTimeAggregationDTO,
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5ReduceToDTO,
RuletypesAlertStateDTO,
RuletypesAlertTypeDTO,
RuletypesCompareOperatorDTO,
RuletypesMatchTypeDTO,
RuletypesPanelTypeDTO,
RuletypesQueryTypeDTO,
RuletypesRuleTypeDTO,
RuletypesThresholdBasicDTOKind,
type AlertmanagertypesDeprecatedGettableAlertDTO,
type GetAlerts200,
type GetRuleByID200,
type ListRules200,
type RenderErrorResponseDTO,
type RuletypesAlertCompositeQueryDTO,
type RuletypesRuleConditionDTO,
type RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import type { Channels } from 'types/api/channels/getAll';
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
export const ALERT_SEVERITIES = [
'critical',
'error',
'warning',
'info',
] as const;
export type AlertSeverity = (typeof ALERT_SEVERITIES)[number];
/** `mixed` spreads the seeds' own severities instead of forcing one. */
export const SEVERITY_CHOICES = ['mixed', ...ALERT_SEVERITIES] as const;
export type SeverityChoice = (typeof SEVERITY_CHOICES)[number];
export const RULE_STATES = [
'firing',
'pending',
'inactive',
'disabled',
'nodata',
] as const;
export type RuleState = (typeof RULE_STATES)[number];
export const RULE_STATE_CHOICES = ['mixed', ...RULE_STATES] as const;
export type RuleStateChoice = (typeof RULE_STATE_CHOICES)[number];
export const ALERT_SCHEMAS = ['v2', 'classic'] as const;
export type AlertSchema = (typeof ALERT_SCHEMAS)[number];
export const CHANNEL_TYPES = [
'slack',
'webhook',
'pagerduty',
'opsgenie',
'email',
'msteams',
'googlechat',
'jira',
'jsmops',
'incidentio',
] as const;
export type ChannelType = (typeof CHANNEL_TYPES)[number];
/**
* One query envelope is enough for the alert form to mount its query builder
* over the rule, and it is the query the preview chart is drawn for.
*/
const compositeQuery = (seed: RuleSeed): RuletypesAlertCompositeQueryDTO => ({
queryType: RuletypesQueryTypeDTO.builder,
panelType: RuletypesPanelTypeDTO.graph,
unit: seed.unit,
queries: [
{
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
spec: {
name: 'A',
signal: MetricsSignal.metrics,
disabled: false,
aggregations: [
{
metricName: seed.metric,
temporality: MetrictypesTemporalityDTO.unspecified,
timeAggregation: MetrictypesTimeAggregationDTO.avg,
spaceAggregation: MetrictypesSpaceAggregationDTO.avg,
reduceTo: Querybuildertypesv5ReduceToDTO.last,
},
],
filter: { expression: '' },
groupBy: [],
order: [],
stepInterval: 60,
},
},
],
});
const condition = (seed: RuleSeed): RuletypesRuleConditionDTO => ({
compositeQuery: compositeQuery(seed),
op: RuletypesCompareOperatorDTO.above,
matchType: RuletypesMatchTypeDTO.at_least_once,
selectedQueryName: 'A',
target: seed.target,
targetUnit: seed.unit,
alertOnAbsent: false,
requireMinPoints: false,
thresholds: {
kind: RuletypesThresholdBasicDTOKind.basic,
spec: [
{
name: 'critical',
matchType: RuletypesMatchTypeDTO.at_least_once,
op: RuletypesCompareOperatorDTO.above,
target: seed.target,
targetUnit: seed.unit,
channels: ['ops-slack'],
},
],
},
});
interface RuleSeed {
alert: string;
alertType: RuletypesAlertTypeDTO;
state: RuletypesAlertStateDTO;
severity: AlertSeverity;
labels: Record<string, string>;
metric: string;
unit: string;
target: number;
}
const RULE_SEEDS: RuleSeed[] = [
{
alert: 'Node CPU saturation',
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
state: RuletypesAlertStateDTO.firing,
severity: 'critical',
labels: { team: 'platform', env: 'prod' },
metric: 'system_cpu_utilization',
unit: 'percent',
target: 85,
},
{
alert: 'Checkout API latency above 2s',
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
state: RuletypesAlertStateDTO.firing,
severity: 'critical',
labels: { team: 'checkout', env: 'prod' },
metric: 'http_server_duration',
unit: 'ms',
target: 2000,
},
{
alert: 'Payment service error rate',
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
state: RuletypesAlertStateDTO.pending,
severity: 'critical',
labels: { team: 'payments', env: 'prod' },
metric: 'http_server_duration',
unit: 'percent',
target: 5,
},
{
alert: 'Kafka consumer lag',
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
state: RuletypesAlertStateDTO.pending,
severity: 'error',
labels: { team: 'platform', component: 'kafka' },
metric: 'kafka_consumer_lag',
unit: '',
target: 10_000,
},
{
alert: 'Postgres connections near limit',
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
state: RuletypesAlertStateDTO.inactive,
severity: 'warning',
labels: { team: 'platform', component: 'database' },
metric: 'postgresql_backends',
unit: '',
target: 90,
},
{
alert: 'Auth service 5xx spike',
alertType: RuletypesAlertTypeDTO.LOGS_BASED_ALERT,
state: RuletypesAlertStateDTO.inactive,
severity: 'error',
labels: { team: 'identity', env: 'prod' },
metric: 'http_server_duration',
unit: '',
target: 20,
},
{
alert: 'Unhandled exceptions in web',
alertType: RuletypesAlertTypeDTO.EXCEPTIONS_BASED_ALERT,
state: RuletypesAlertStateDTO.firing,
severity: 'error',
labels: { team: 'web', env: 'prod' },
metric: 'http_server_duration',
unit: '',
target: 15,
},
{
alert: 'Ingest pipeline dropped logs',
alertType: RuletypesAlertTypeDTO.LOGS_BASED_ALERT,
state: RuletypesAlertStateDTO.nodata,
severity: 'warning',
labels: { team: 'platform', component: 'collector' },
metric: 'system_memory_usage',
unit: '',
target: 1,
},
{
alert: 'Nightly batch job overran',
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
state: RuletypesAlertStateDTO.disabled,
severity: 'info',
labels: { team: 'data' },
metric: 'http_server_duration',
unit: 's',
target: 3600,
},
{
alert: 'Search p99 above budget',
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
state: RuletypesAlertStateDTO.inactive,
severity: 'info',
labels: { team: 'search', env: 'staging' },
metric: 'http_server_duration',
unit: 'ms',
target: 800,
},
{
alert: 'Cache hit ratio dropped',
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
state: RuletypesAlertStateDTO.inactive,
severity: 'info',
labels: { team: 'platform', component: 'redis' },
metric: 'system_memory_usage',
unit: 'percent',
target: 70,
},
{
alert: 'Disk usage on ingesters',
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
state: RuletypesAlertStateDTO.pending,
severity: 'critical',
labels: { team: 'platform', env: 'prod' },
metric: 'system_memory_usage',
unit: 'percent',
target: 92,
},
];
export const RULE_MAX = RULE_SEEDS.length;
/** The rule `rule-1` resolves to, which is the one the detail stories open. */
export const FIRST_RULE_NAME = RULE_SEEDS[0].alert;
const seedAt = (index: number): RuleSeed =>
RULE_SEEDS[index % RULE_SEEDS.length];
const ruleName = (index: number): string => {
const seed = seedAt(index);
const round = Math.floor(index / RULE_SEEDS.length);
return round === 0 ? seed.alert : `${seed.alert} (${round + 1})`;
};
export interface RuleShape {
severity: SeverityChoice;
state: RuleStateChoice;
schema?: AlertSchema;
}
const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
const seed = seedAt(index);
const severity = shape.severity === 'mixed' ? seed.severity : shape.severity;
const state =
shape.state === 'mixed'
? seed.state
: (shape.state as RuletypesAlertStateDTO);
return {
id: `rule-${index + 1}`,
alert: ruleName(index),
alertType: seed.alertType,
ruleType: RuletypesRuleTypeDTO.threshold_rule,
state,
disabled: state === RuletypesAlertStateDTO.disabled,
condition: condition(seed),
labels: { severity, ...seed.labels },
annotations: {
summary: `${seed.alert} crossed its threshold of ${seed.target}`,
description:
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
},
evalWindow: '5m0s',
frequency: '1m0s',
createdAt: ago((index + 3) * DAY),
updatedAt: ago((index + 1) * HOUR),
createdBy: 'ada@signoz.io',
updatedBy: 'grace@signoz.io',
schemaVersion:
shape.schema === 'classic' ? undefined : NEW_ALERT_SCHEMA_VERSION,
version: 'v5',
source: 'http://localhost/alerts',
preferredChannels: ['ops-slack'],
notificationSettings: {
groupBy: ['alertname'],
usePolicy: false,
renotify: { enabled: false, interval: '30m0s' },
},
};
};
export const alertRulesResponse = (
count: number,
shape: RuleShape,
): ListRules200 => ({
status: 'success',
data: Array.from({ length: count }, (_unused, index) =>
buildRule(index, shape),
),
});
/**
* The detail endpoint answers for whatever id the URL carries, so a story keeps
* rendering after a row click lands on a rule the list never returned.
*/
export const alertRuleByIdResponse = (
ruleId: string,
shape: RuleShape,
): GetRuleByID200 => {
const index = Math.max(Number.parseInt(ruleId.replace(/\D/g, ''), 10) - 1, 0);
return {
status: 'success',
data: { ...buildRule(Number.isNaN(index) ? 0 : index, shape), id: ruleId },
};
};
interface TriggeredSeed {
alertname: string;
severity: AlertSeverity;
labels: Record<string, string>;
summary: string;
firingForMinutes: number;
}
const TRIGGERED_SEEDS: TriggeredSeed[] = [
{
alertname: 'Checkout API latency above 2s',
severity: 'critical',
labels: { service: 'checkout', env: 'prod', team: 'checkout' },
summary: 'p99 latency is 3.4s against a 2s budget',
firingForMinutes: 14,
},
{
alertname: 'Payment service error rate',
severity: 'critical',
labels: { service: 'payments', env: 'prod', team: 'payments' },
summary: '7.2% of payment spans failed in the last 5 minutes',
firingForMinutes: 42,
},
{
alertname: 'Node CPU saturation',
severity: 'warning',
labels: { service: 'kubelet', env: 'prod', team: 'platform' },
summary: 'CPU utilisation held above 85% on 3 nodes',
firingForMinutes: 128,
},
{
alertname: 'Kafka consumer lag',
severity: 'error',
labels: { service: 'events-consumer', env: 'prod', team: 'platform' },
summary: 'Lag is 24k messages and climbing',
firingForMinutes: 300,
},
{
alertname: 'Unhandled exceptions in web',
severity: 'error',
labels: { service: 'web', env: 'prod', team: 'web' },
summary: '31 unhandled exceptions in the last 10 minutes',
firingForMinutes: 8,
},
{
alertname: 'Auth service 5xx spike',
severity: 'error',
labels: { service: 'auth', env: 'prod', team: 'identity' },
summary: '5xx rate is 22 requests per second',
firingForMinutes: 55,
},
{
alertname: 'Search p99 above budget',
severity: 'info',
labels: { service: 'search', env: 'staging', team: 'search' },
summary: 'p99 is 940ms against an 800ms budget',
firingForMinutes: 1_450,
},
{
alertname: 'Cache hit ratio dropped',
severity: 'info',
labels: { service: 'redis', env: 'prod', team: 'platform' },
summary: 'Hit ratio fell to 61%',
firingForMinutes: 620,
},
{
alertname: 'Disk usage on ingesters',
severity: 'critical',
labels: { service: 'ingester', env: 'prod', team: 'platform' },
summary: 'Two ingesters are above 92% disk',
firingForMinutes: 3,
},
{
alertname: 'Postgres connections near limit',
severity: 'warning',
labels: { service: 'postgres', env: 'prod', team: 'platform' },
summary: '91% of the connection pool is in use',
firingForMinutes: 240,
},
{
alertname: 'Ingest pipeline dropped logs',
severity: 'warning',
labels: { service: 'otel-collector', env: 'prod', team: 'platform' },
summary: 'The collector dropped 4.1k log records',
firingForMinutes: 76,
},
{
alertname: 'Nightly batch job overran',
severity: 'info',
labels: { service: 'batch-runner', env: 'prod', team: 'data' },
summary: 'The nightly job ran 41 minutes past its window',
firingForMinutes: 900,
},
];
export const TRIGGERED_ALERT_MAX = TRIGGERED_SEEDS.length;
/** A resolved alert is one alertmanager still lists with an `endsAt` in the past. */
export const TRIGGERED_STATES = ['mixed', 'active', 'suppressed'] as const;
export type TriggeredState = (typeof TRIGGERED_STATES)[number];
export interface TriggeredShape {
severity: SeverityChoice;
state: TriggeredState;
}
const buildTriggeredAlert = (
index: number,
shape: TriggeredShape,
): AlertmanagertypesDeprecatedGettableAlertDTO => {
const seed = TRIGGERED_SEEDS[index % TRIGGERED_SEEDS.length];
const severity = shape.severity === 'mixed' ? seed.severity : shape.severity;
const mixedState = index % 4 === 3 ? 'suppressed' : 'active';
const state = shape.state === 'mixed' ? mixedState : shape.state;
const ruleId = `rule-${(index % RULE_MAX) + 1}`;
return {
fingerprint: `fingerprint-${index + 1}`,
startsAt: ago(seed.firingForMinutes * MINUTE),
endsAt: new Date(Date.now() + HOUR).toISOString(),
generatorURL: `http://localhost/alerts/overview?ruleId=${ruleId}`,
labels: {
alertname: seed.alertname,
severity,
ruleId,
...seed.labels,
},
annotations: {
summary: seed.summary,
description: `${seed.alertname} has been firing for ${seed.firingForMinutes} minutes`,
},
status: {
state,
silencedBy: state === 'suppressed' ? ['silence-1'] : [],
inhibitedBy: [],
},
receivers: ['ops-slack'],
};
};
export const triggeredAlertsResponse = (
count: number,
shape: TriggeredShape,
): GetAlerts200 => ({
status: 'success',
data: Array.from({ length: count }, (_unused, index) =>
buildTriggeredAlert(index, shape),
),
});
interface ChannelSeed {
name: string;
type: ChannelType;
/** The alertmanager receiver the channel serialises into its `data` field. */
receiver: Record<string, unknown>;
}
const CHANNEL_SEEDS: ChannelSeed[] = [
{
name: 'ops-slack',
type: 'slack',
receiver: {
slack_configs: [
{
api_url: 'https://hooks.slack.com/services/T000/B000/story-token',
channel: '#ops-alerts',
send_resolved: true,
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
},
],
},
},
{
name: 'oncall-pagerduty',
type: 'pagerduty',
receiver: {
pagerduty_configs: [
{
routing_key: 'story-routing-key',
send_resolved: true,
client: 'SigNoz',
description: '{{ .CommonLabels.alertname }}',
severity: 'critical',
details: { firing: '{{ .Alerts.Firing | len }}' },
},
],
},
},
{
name: 'platform-webhook',
type: 'webhook',
receiver: {
webhook_configs: [
{
url: 'https://hooks.example.com/signoz',
send_resolved: true,
http_config: {
basic_auth: { username: 'signoz', password: 'story-password' },
},
},
],
},
},
{
name: 'sre-email',
type: 'email',
receiver: {
email_configs: [
{
to: 'sre@signoz.io',
send_resolved: true,
html: '<p>{{ .CommonLabels.alertname }}</p>',
headers: { Subject: '[SigNoz] {{ .CommonLabels.alertname }}' },
},
],
},
},
{
name: 'incident-opsgenie',
type: 'opsgenie',
receiver: {
opsgenie_configs: [
{
api_key: 'story-api-key',
send_resolved: true,
message: '{{ .CommonLabels.alertname }}',
description: '{{ .CommonLabels.alertname }} is firing',
priority: 'P2',
},
],
},
},
{
name: 'eng-msteams',
type: 'msteams',
receiver: {
msteamsv2_configs: [
{
webhook_url: 'https://signoz.webhook.office.com/story',
send_resolved: true,
title: '{{ .CommonLabels.alertname }}',
text: '{{ .CommonAnnotations.summary }}',
},
],
},
},
{
name: 'support-googlechat',
type: 'googlechat',
receiver: {
googlechat_configs: [
{
webhook_url: 'https://chat.googleapis.com/v1/spaces/story',
send_resolved: true,
title: '{{ .CommonLabels.alertname }}',
text: '{{ .CommonAnnotations.summary }}',
},
],
},
},
{
name: 'tickets-jira',
type: 'jira',
receiver: {
jira_configs: [
{
site: 'https://signoz.atlassian.net',
project: 'ALERT',
issue_type: 'Task',
send_resolved: true,
summary: '{{ .CommonLabels.alertname }}',
description: '{{ .CommonAnnotations.summary }}',
priority: 'High',
labels: ['signoz', 'platform'],
resolve_transition: 'Done',
reopen_transition: 'Reopen',
wont_fix_resolution: "Won't Do",
reopen_duration: '72h',
// The form reads the credentials off the basic auth block rather than
// off the config itself, which is where the backend stores them.
http_config: {
basic_auth: {
username: 'alerts@signoz.io',
password: 'story-api-token',
},
},
},
],
},
},
{
name: 'oncall-jsmops',
type: 'jsmops',
receiver: {
jsmops_configs: [
{
api_key: 'story-jsm-api-key',
send_resolved: true,
message: '{{ .CommonLabels.alertname }}',
description: '{{ .CommonAnnotations.summary }}',
priority: 'P2',
// Stored comma-separated, which is what the form splits into chips.
tags: 'signoz,platform',
},
],
},
},
{
name: 'oncall-incidentio',
type: 'incidentio',
receiver: {
incidentio_configs: [
{
url: 'https://api.incident.io/v2/alert_events/http/story-source-config-id',
token: 'story-source-token',
send_resolved: true,
title: '{{ .CommonLabels.alertname }}',
description: '{{ .CommonAnnotations.summary }}',
metadata: { team: 'platform' },
},
],
},
},
];
export const CHANNEL_MAX = CHANNEL_SEEDS.length;
const buildChannel = (index: number): Channels => {
const seed = CHANNEL_SEEDS[index % CHANNEL_SEEDS.length];
return {
id: String(index + 1),
name: seed.name,
type: seed.type,
created_at: ago((index + 10) * DAY),
updated_at: ago((index + 1) * DAY),
data: JSON.stringify({ name: seed.name, ...seed.receiver }),
};
};
export const channelsResponse = (
count: number,
): { status: string; data: Channels[] } => ({
status: 'success',
data: Array.from({ length: count }, (_unused, index) => buildChannel(index)),
});
/** Channel names the alert form and the routing policies pick from. */
export const channelNames = (count: number): string[] =>
channelsResponse(count).data.map((channel) => channel.name);
export const channelResponse = (
id: string,
type: ChannelType,
): { status: string; data: Channels } => {
const index = CHANNEL_SEEDS.findIndex((seed) => seed.type === type);
return {
status: 'success',
data: { ...buildChannel(Math.max(index, 0)), id },
};
};
export const CHANNEL_ACTION_OUTCOMES = ['succeeds', 'fails'] as const;
export type ChannelActionOutcome = (typeof CHANNEL_ACTION_OUTCOMES)[number];
export const channelActionError = (): RenderErrorResponseDTO => ({
status: 'error',
error: {
code: 'STORYBOOK_FAILURE',
type: 'internal',
message: 'Storybook forced channel failure',
url: '',
errors: [],
suggestions: [],
},
});

View File

@@ -0,0 +1,116 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { countControl, toggleControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
EXCEPTION_CATALOGUE_SIZE,
EXCEPTION_QUICK_FILTER_CAP,
exceptionAttributeKeysResponse,
exceptionAttributeValuesResponse,
exceptionFieldKeysResponse,
exceptionQuickFiltersResponse,
exceptionRows,
exceptionTotal,
type ListErrorsBody,
} from './__story_mockdata__/exceptions';
const LIST = 'Exceptions · list';
const FILTERS = 'Exceptions · filters';
export const exceptionsMocks = defineStoryMocks({
controls: {
exceptions: countControl('Exception groups', {
group: LIST,
description:
'Groups the endpoint holds. The table asks for one page at a time and pages against `/countErrors`, so a count past ten paginates.',
value: EXCEPTION_CATALOGUE_SIZE,
max: EXCEPTION_CATALOGUE_SIZE,
}),
quickFilters: countControl('Quick filters', {
group: FILTERS,
description:
'Filters the org has configured for exceptions. At 0 the panel has nothing to render, which is what a workspace that never customised them shows.',
value: 6,
max: EXCEPTION_QUICK_FILTER_CAP,
}),
filterPanel: toggleControl('Quick filters panel', {
group: FILTERS,
description:
'Whether the panel starts expanded. The page keeps this in local storage, so it survives the collapse arrow being clicked.',
value: true,
}),
},
handlers: (values, response) => [
rest.post(
'http://localhost/api/v1/listErrors',
response.json(async (req) => {
const body = (await req.json()) as ListErrorsBody;
return exceptionRows(values.exceptions, body);
}),
),
rest.post(
'http://localhost/api/v1/countErrors',
response.json(async (req) => {
const body = (await req.json()) as ListErrorsBody;
return exceptionTotal(values.exceptions, body);
}),
),
rest.get(
'http://localhost/api/v2/quick_filters/:source',
response.json(() => exceptionQuickFiltersResponse(values.quickFilters)),
),
rest.get(
'http://localhost/api/v1/fields/keys',
response.json((req) =>
exceptionFieldKeysResponse(req.url.searchParams.get('searchText')),
),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
fieldValuesResponse(
exceptionAttributeValuesResponse(req.url.searchParams.get('name'), null)
.data.stringAttributeValues ?? [],
),
),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) =>
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_values',
response.json((req) =>
exceptionAttributeValuesResponse(
req.url.searchParams.get('attributeKey'),
req.url.searchParams.get('searchText'),
),
),
),
],
effect: (values) => {
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
// The quick-filter settings announcement is a first-run popover that covers
// the toolbar until it is closed, and closing it is what the app persists.
set(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, 'false');
},
});

View File

@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { exceptionsMocks } from './AllErrors.stories.mocks';
import AllErrors from '../index';
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
const pageStory = storyMocks(exceptionsMocks, {
route: ROUTES.ALL_ERROR,
layout: 'app',
});
/**
* Exception groups over the period, with the quick filters and the filter panel
* the explorers share.
*
* Route: `/exceptions`.
*/
const meta = {
title: 'Pages/Exceptions/List',
tags: ['play'],
component: AllErrors,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<AllErrorsArgs>;
export default meta;
type Story = StoryObj<AllErrorsArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const openQuickFiltersSettings = async (): Promise<void> => {
await userEvent.click(
await screen.findByTestId('settings-icon', undefined, untilLoaded),
);
await screen.findByText('Edit quick filters', undefined, untilLoaded);
};
/**
* Every exception group in the window: the org's quick filters down the left, the
* resource filter and the time range above, and the table sorted by application
* with each type linking to its detail page.
*/
export const Default: Story = {};
/**
* A workspace with nothing thrown in the window and no quick filters configured,
* so the table and the filter panel both show their empty states.
*/
export const NoExceptions: Story = {
args: { exceptions: 0, quickFilters: 0 },
};
/** The query area after the quick-filters panel is collapsed. */
export const FiltersCollapsed: Story = {
args: { filterPanel: false },
};
/** The table mid-query, with the cancel action the toolbar offers while it runs. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/**
* What cancelling a running query leaves behind: the table is dropped for a
* placeholder until Run Query is pressed again.
*/
export const QueryCancelled: Story = {
args: { dataState: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(/cancel/i, undefined, untilLoaded),
);
await screen.findByText(/query cancelled/i, undefined, untilLoaded);
},
};
/** The editable quick-filter settings panel. */
export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
},
};

View File

@@ -0,0 +1,341 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
QuickfiltertypesSourceDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
GetFieldsKeys200,
GetQuickFilters200,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
type BaseAutocompleteData,
DataTypes,
type IQueryAutocompleteResponse,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { Exception, Order, OrderBy } from 'types/api/errors/getAll';
import type { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
export interface ExceptionShape {
exceptionType: string;
exceptionMessage: string;
exceptionCount: number;
serviceName: string;
groupID: string;
}
/**
* Ordered by count, the way the backend answers an unsorted request, so a slice
* keeps a spread of services, languages and counts.
*/
export const EXCEPTION_CATALOGUE: ExceptionShape[] = [
{
exceptionType: '*errors.errorString',
exceptionMessage: 'redis timeout',
exceptionCount: 2510,
serviceName: 'redis-manual',
groupID: '511b9c91a92b9c5166ecb77235f5743b',
},
{
exceptionType: 'ConnectionError',
exceptionMessage:
"HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)",
exceptionCount: 1834,
serviceName: 'checkout',
groupID: '6a1f0c2d8e4b7a935c10d4f6b8e2a771',
},
{
exceptionType: 'java.net.SocketTimeoutException',
exceptionMessage: 'Read timed out',
exceptionCount: 1290,
serviceName: 'payment-java',
groupID: 'c93d2f81a0b64e7f95d31c8e7a4b0d26',
},
{
exceptionType: 'TypeError',
exceptionMessage: "Cannot read properties of undefined (reading 'id')",
exceptionCount: 964,
serviceName: 'frontend',
groupID: '1d7e4b93c05f8a26e91b4d70c3f85a12',
},
{
exceptionType: 'psycopg2.OperationalError',
exceptionMessage: 'could not connect to server: Connection refused',
exceptionCount: 742,
serviceName: 'orders',
groupID: 'ab3c5d7e9f10234567890bcdef123456',
},
{
exceptionType: 'KeyError',
exceptionMessage: "'customer_id'",
exceptionCount: 611,
serviceName: 'cart',
groupID: '77e0a1b2c3d4e5f60718293a4b5c6d7e',
},
{
exceptionType: '*net.OpError',
exceptionMessage: 'dial tcp 10.0.4.11:9092: connect: connection refused',
exceptionCount: 508,
serviceName: 'kafka-producer',
groupID: '2f4a6c8e0b1d3f5709a2b4c6d8e0f135',
},
{
exceptionType: 'ValidationError',
exceptionMessage:
'1 validation error for Order\nquantity: value is not a valid integer',
exceptionCount: 402,
serviceName: 'orders',
groupID: '9b8a7c6d5e4f30211f2e3d4c5b6a7988',
},
{
exceptionType: 'java.lang.NullPointerException',
exceptionMessage: 'Cannot invoke "String.length()" because "sku" is null',
exceptionCount: 355,
serviceName: 'inventory-java',
groupID: '3c1e5a79b2d4f68008a1c3e5b7d9f012',
},
{
exceptionType: 'RuntimeError',
exceptionMessage: 'Event loop is closed',
exceptionCount: 287,
serviceName: 'notifications',
groupID: 'e5d4c3b2a1908f7e6d5c4b3a29180706',
},
{
exceptionType: 'sqlalchemy.exc.IntegrityError',
exceptionMessage:
'duplicate key value violates unique constraint "orders_pkey"',
exceptionCount: 213,
serviceName: 'orders',
groupID: '0a1b2c3d4e5f60718293a4b5c6d7e8f9',
},
{
exceptionType: 'AxiosError',
exceptionMessage: 'Request failed with status code 503',
exceptionCount: 168,
serviceName: 'frontend',
groupID: '4d6f8a0c2e4b6d8f0a1c3e5b7d9f1113',
},
{
exceptionType: '*fmt.wrapError',
exceptionMessage: 'publish message: context deadline exceeded',
exceptionCount: 96,
serviceName: 'kafka-producer',
groupID: 'bb0a99887766554433221100ffeeddcc',
},
{
exceptionType: 'RedisTimeoutError',
exceptionMessage: 'Command timed out after 1000ms',
exceptionCount: 41,
serviceName: 'session-store',
groupID: '8e7d6c5b4a39281706f5e4d3c2b1a099',
},
];
export const EXCEPTION_CATALOGUE_SIZE = EXCEPTION_CATALOGUE.length;
export const EXCEPTION_SERVICE_NAMES = Array.from(
new Set(EXCEPTION_CATALOGUE.map(({ serviceName }) => serviceName)),
);
export const EXCEPTION_TYPES = EXCEPTION_CATALOGUE.map(
({ exceptionType }) => exceptionType,
);
/** The nanoseconds every row carries, so `firstSeen` stays the table's row key. */
const SUBSECOND_NANOS = '797616374';
/**
* `lastSeen` and `firstSeen` come back as RFC 3339 with nanoseconds, which is
* what `getNanoSeconds` parses to build the link to the detail page.
*/
const seenAt = (atMs: number): string =>
`${new Date(atMs).toISOString().slice(0, 19)}.${SUBSECOND_NANOS}Z`;
export interface ListErrorsBody {
start: string;
end: string;
order?: Order;
orderParam?: OrderBy;
limit?: number;
offset?: number;
exceptionType?: string;
serviceName?: string;
}
const MINUTE_MS = 60 * 1000;
const contains = (value: string, search: string | undefined): boolean =>
!search || value.toLowerCase().includes(search.toLowerCase());
const compare = (left: Exception, right: Exception, by: OrderBy): number => {
if (by === 'exceptionCount') {
return left.exceptionCount - right.exceptionCount;
}
if (by === 'lastSeen' || by === 'firstSeen') {
return Date.parse(left[by]) - Date.parse(right[by]);
}
return left[by].localeCompare(right[by]);
};
/**
* The exception groups the endpoint holds for one request. The timestamps land
* inside the window the time picker asked for, and the sorting and the column
* searches are applied here: the table writes both into the query string and
* sends them along, rather than sorting or filtering what it already has.
*/
const selectExceptions = (count: number, body: ListErrorsBody): Exception[] => {
const endMs = Number(body.end) / 1e6;
const rows: Exception[] = EXCEPTION_CATALOGUE.slice(0, count).map(
(exception, index) => ({
...exception,
lastSeen: seenAt(endMs - index * MINUTE_MS),
firstSeen: seenAt(endMs - (index + 1) * 30 * MINUTE_MS),
}),
);
const filtered = rows.filter(
(row) =>
contains(row.exceptionType, body.exceptionType) &&
contains(row.serviceName, body.serviceName),
);
const orderParam = body.orderParam ?? 'serviceName';
const direction = body.order === 'descending' ? -1 : 1;
return filtered.sort(
(left, right) => direction * compare(left, right, orderParam),
);
};
export const exceptionRows = (
count: number,
body: ListErrorsBody,
): Exception[] => {
const offset = body.offset ?? 0;
const limit = body.limit ?? 10;
return selectExceptions(count, body).slice(offset, offset + limit);
};
/** `/countErrors` answers the bare total the table pages against. */
export const exceptionTotal = (count: number, body: ListErrorsBody): number =>
selectExceptions(count, body).length;
const { resource, attribute } = TelemetrytypesFieldContextDTO;
const { string: stringType, bool } = TelemetrytypesFieldDataTypeDTO;
const QUICK_FILTERS: TelemetrytypesTelemetryFieldKeyDTO[] = [
{ name: 'service.name', fieldDataType: stringType, fieldContext: resource },
{ name: 'exceptionType', fieldDataType: stringType, fieldContext: attribute },
{
name: 'deployment.environment',
fieldDataType: stringType,
fieldContext: resource,
},
{
name: 'telemetry.sdk.language',
fieldDataType: stringType,
fieldContext: resource,
},
{ name: 'host.name', fieldDataType: stringType, fieldContext: resource },
{
name: 'k8s.namespace.name',
fieldDataType: stringType,
fieldContext: resource,
},
{ name: 'os.type', fieldDataType: stringType, fieldContext: resource },
{ name: 'hasError', fieldDataType: bool, fieldContext: attribute },
];
export const EXCEPTION_QUICK_FILTER_CAP = QUICK_FILTERS.length;
export const exceptionQuickFiltersResponse = (
count: number,
): GetQuickFilters200 =>
quickFiltersResponse(
QuickfiltertypesSourceDTO.exceptions,
QUICK_FILTERS.slice(0, count),
);
const ATTRIBUTE_VALUES: Record<string, string[]> = {
'service.name': EXCEPTION_SERVICE_NAMES,
exceptionType: EXCEPTION_TYPES,
'deployment.environment': ['production', 'staging', 'canary'],
'telemetry.sdk.language': ['go', 'python', 'java', 'nodejs'],
'host.name': ['ip-10-0-4-11', 'ip-10-0-4-12', 'ip-10-0-5-31'],
'k8s.namespace.name': ['default', 'otel-demo', 'payments'],
'os.type': ['linux', 'darwin'],
};
export const exceptionAttributeValuesResponse = (
attributeKey: string | null,
searchText: string | null,
): { status: string; data: IAttributeValuesResponse } => {
const search = (searchText ?? '').toLowerCase();
const values = (ATTRIBUTE_VALUES[attributeKey ?? ''] ?? []).filter((value) =>
value.toLowerCase().includes(search),
);
return {
status: 'success',
data: {
stringAttributeValues: values,
numberAttributeValues: null,
boolAttributeValues: attributeKey === 'hasError' ? ['true', 'false'] : null,
},
};
};
const ATTRIBUTE_KEYS: BaseAutocompleteData[] = [
...QUICK_FILTERS.map(({ name, fieldDataType, fieldContext }) => ({
key: name,
dataType: fieldDataType === bool ? DataTypes.bool : DataTypes.String,
type: fieldContext === attribute ? 'tag' : String(fieldContext),
})),
{ key: 'service.namespace', dataType: DataTypes.String, type: 'resource' },
{ key: 'k8s.pod.name', dataType: DataTypes.String, type: 'resource' },
{ key: 'k8s.cluster.name', dataType: DataTypes.String, type: 'resource' },
{ key: 'cloud.region', dataType: DataTypes.String, type: 'resource' },
];
export const exceptionAttributeKeysResponse = (
searchText: string | null,
): { status: string; data: IQueryAutocompleteResponse } => {
const search = (searchText ?? '').toLowerCase();
return {
status: 'success',
data: {
attributeKeys: ATTRIBUTE_KEYS.filter(({ key }) =>
key.toLowerCase().includes(search),
),
},
};
};
/** The quick filter settings panel lists its "other filters" from `/fields/keys`. */
export const exceptionFieldKeysResponse = (
searchText: string | null,
): GetFieldsKeys200 => {
const search = (searchText ?? '').toLowerCase();
return fieldKeysResponse(
ATTRIBUTE_KEYS.map(({ key }) => key).filter((key) =>
key.toLowerCase().includes(search),
),
{ signal: TelemetrytypesSignalDTO.logs, fieldContext: resource },
);
};

View File

@@ -0,0 +1,406 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { TelemetrytypesTelemetryFieldKeyDTO } from 'api/generated/services/sigNoz.schemas';
import {
QuickfiltertypesSourceDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { DEFAULT_PARAMS } from 'container/ApiMonitoring/queryParams';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { rest } from 'msw';
import type { AppState } from 'store/reducers';
import type { Props as ListOverviewRequest } from 'types/api/thirdPartyApis/listOverview';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
import {
allEndpointsResponse,
DEPENDENT_SERVICE_MAX,
dependentServicesResponse,
DOMAIN_MAX,
domainListResponse,
domainMetricsResponse,
type DrawerDomain,
DRAWER_DOMAINS,
drawerDomainName,
endpointDropdownResponse,
ENDPOINT_MAX,
endpointMetricsResponse,
endpointUrl,
groupByAttributeKeys,
overTimeChartResponse,
STATUS_CODE_MAX,
statusCodeChartResponse,
statusCodeTableResponse,
TOP_ERROR_MAX,
topErrorsResponse,
} from './__story_mockdata__/apiMonitoring';
const DOMAINS = 'External APIs · domains';
const DRAWER = 'External APIs · drawer';
const FILTERS = 'External APIs · filters';
const QUICK_FILTERS: TelemetrytypesTelemetryFieldKeyDTO[] = [
{
name: 'deployment.environment',
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
{
name: 'service.name',
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
{
name: 'rpc.method',
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
fieldContext: TelemetrytypesFieldContextDTO.attribute,
},
];
const QUICK_FILTER_VALUES: Record<string, string[]> = {
'deployment.environment': ['production', 'staging'],
'service.name': ['checkout', 'payments', 'cart'],
'rpc.method': ['GET', 'POST'],
};
const DRAWER_STATES = [
'closed',
'all-endpoints',
'endpoint-stats',
'top-errors',
] as const;
type DrawerState = (typeof DRAWER_STATES)[number];
const VIEW_OF: Record<Exclude<DrawerState, 'closed'>, VIEWS> = {
'all-endpoints': VIEWS.ALL_ENDPOINTS,
'endpoint-stats': VIEWS.ENDPOINT_STATS,
'top-errors': VIEWS.TOP_ERRORS,
};
const RELATIVE_TIME: Time = '30m';
const THIRTY_MINUTES_IN_MS = 30 * 60 * 1000;
const NANOSECONDS_IN_MS = 1_000_000;
/**
* `globalTime` derives its window from `window.location.pathname`, which in a
* story is the preview's rather than the page's, so without a seeded range the
* time picker and the queries would disagree about the window.
*/
const timeRange = (): Partial<AppState> => {
const now = Date.now();
return {
globalTime: {
minTime: (now - THIRTY_MINUTES_IN_MS) * NANOSECONDS_IN_MS,
maxTime: now * NANOSECONDS_IN_MS,
loading: false,
selectedTime: RELATIVE_TIME,
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '',
},
};
};
const serviceFilterItems = {
op: 'AND',
items: [
{
id: 'storybook-service-filter',
key: { key: 'service.name', dataType: 'string', type: 'resource' },
op: '=',
value: 'checkout',
},
],
};
interface RouteValues {
drawer: DrawerState;
drawerDomain: DrawerDomain;
domains: number;
serviceFilter: boolean;
}
const apiMonitoringRoute = ({
drawer,
drawerDomain,
domains,
serviceFilter,
}: RouteValues): string => {
const domainName = drawerDomainName(drawerDomain, domains);
if (drawer === 'closed' || !domainName) {
return ROUTES.API_MONITORING;
}
const params = {
...DEFAULT_PARAMS,
selectedDomain: domainName,
selectedView: VIEW_OF[drawer],
selectedEndPointName:
drawer === 'endpoint-stats' ? endpointUrl(domainName, 0) : '',
...(serviceFilter ? { endPointDetailsLocalFilters: serviceFilterItems } : {}),
};
return `${ROUTES.API_MONITORING}?apiMonitoringParams=${encodeURIComponent(
JSON.stringify(params),
)}`;
};
/** The parts of a query spec the handler tells the page's requests apart by. */
interface RequestSpec {
name?: string;
aggregations?: Array<{ expression?: string }>;
groupBy?: Array<{ name: string }>;
filter?: { expression?: string };
}
interface QueryShape {
names: string[];
expressions: string[];
groupBy: string[];
filters: string[];
/** The endpoint the request pinned, when one is selected. */
endPointName?: string;
}
const shapeOf = (body: QueryRangeRequestV5): QueryShape => {
const specs = (body.compositeQuery?.queries ?? []).map(
({ spec }) => spec as RequestSpec,
);
const filters = specs.map((spec) => spec.filter?.expression ?? '');
return {
names: specs
.map((spec) => spec.name)
.filter((name): name is string => Boolean(name)),
expressions: specs.flatMap((spec) =>
(spec.aggregations ?? []).map((aggregation) => aggregation.expression ?? ''),
),
// Every query in the request repeats the same group-by, so the columns the
// response answers with are the distinct ones.
groupBy: [
...new Set(
specs.flatMap((spec) => (spec.groupBy ?? []).map(({ name }) => name)),
),
],
filters,
endPointName: filters
.map((expression) => /http_url\s*=\s*'([^']+)'/.exec(expression)?.[1])
.find(Boolean),
};
};
export const apiMonitoringMocks = defineStoryMocks({
controls: {
domains: countControl('Domains', {
group: DOMAINS,
description:
'External hosts the workspace called in the window. At 0 the page shows what to instrument instead of the table.',
value: DOMAIN_MAX,
max: DOMAIN_MAX,
}),
drawer: choiceControl<DrawerState>('Domain drawer', {
group: DRAWER,
description:
'The drawer a domain row opens, and which of its three views is showing.',
options: DRAWER_STATES,
value: 'closed',
}),
drawerDomain: choiceControl<DrawerDomain>('Drawer domain', {
group: DRAWER,
description:
'Which row the drawer opens: a healthy host, one failing most calls, or a bare address on a non-standard port.',
options: DRAWER_DOMAINS,
value: 'healthy',
}),
endpoints: countControl('Endpoints', {
group: DRAWER,
description:
'Endpoints the domain has. Fills the Endpoint Overview table and the endpoint picker.',
value: ENDPOINT_MAX,
max: ENDPOINT_MAX,
}),
statusCodes: countControl('Status codes', {
group: DRAWER,
description:
'Distinct response codes the endpoint answered with, in the table and in the call response chart.',
value: STATUS_CODE_MAX,
max: STATUS_CODE_MAX,
}),
dependentServices: countControl('Dependent services', {
group: DRAWER,
description:
'Services calling the endpoint. Past five the list collapses behind Show more.',
value: DEPENDENT_SERVICE_MAX,
max: DEPENDENT_SERVICE_MAX,
}),
topErrors: countControl('Top errors', {
group: DRAWER,
description: 'Rows the Top 10 Errors table has for the domain.',
value: TOP_ERROR_MAX,
max: TOP_ERROR_MAX,
}),
serviceFilter: toggleControl('Service filter', {
group: DRAWER,
description:
'Puts a service.name filter on the endpoint stats view, which drops the Dependent Services block.',
value: false,
}),
quickFilters: countControl('Quick filters', {
group: FILTERS,
description:
'Configured API Monitoring quick filters shown beside the domain list.',
value: QUICK_FILTERS.length,
max: QUICK_FILTERS.length,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/quick_filters/:source',
response.json(() =>
quickFiltersResponse(
QuickfiltertypesSourceDTO.api_monitoring,
QUICK_FILTERS.slice(0, values.quickFilters),
),
),
),
rest.post(
'http://localhost/api/v1/third-party-apis/overview/list',
response.json(async (req) => {
const { show_ip: showIp } = (await req.json()) as ListOverviewRequest;
return domainListResponse(values.domains, showIp, Date.now());
}),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) => ({
status: 'success',
data: {
attributeKeys: groupByAttributeKeys(
req.url.searchParams.get('searchText') ?? '',
),
},
})),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_values',
response.json((req) => ({
status: 'success',
data: {
boolAttributeValues: null,
numberAttributeValues: null,
stringAttributeValues:
QUICK_FILTER_VALUES[req.url.searchParams.get('attributeKey') ?? ''] ?? [],
},
})),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
fieldValuesResponse(
QUICK_FILTER_VALUES[req.url.searchParams.get('name') ?? ''] ?? [],
),
),
),
// Every widget in the drawer asks the same endpoint, so what a request is
// for is only in its shape: the panel type, what it groups by, and which
// aggregations it names.
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
const body = (await req.json()) as QueryRangeRequestV5;
const shape = shapeOf(body);
const domainName =
drawerDomainName(values.drawerDomain, values.domains) ?? '';
const window = { start: body.start, end: body.end };
if (body.requestType === 'time_series') {
if (shape.groupBy.includes('response_status_code')) {
return statusCodeChartResponse(
domainName,
values.statusCodes,
window,
shape.expressions.includes('count()') ? 'calls' : 'latency',
);
}
return overTimeChartResponse(
domainName,
window,
shape.expressions.includes('rate()') ? 'rate' : 'latency',
);
}
if (shape.groupBy.includes('status_message')) {
return topErrorsResponse(
domainName,
values.topErrors,
shape.filters.some((expression) =>
expression.includes('status_message EXISTS'),
),
shape.endPointName,
);
}
if (shape.groupBy.includes('response_status_code')) {
return statusCodeTableResponse(domainName, values.statusCodes);
}
if (shape.groupBy.includes('http_url')) {
if (shape.names.length === 1) {
return endpointDropdownResponse(domainName, values.endpoints);
}
return allEndpointsResponse(
domainName,
values.endpoints,
shape.groupBy,
Date.now(),
);
}
if (shape.groupBy.includes('service.name')) {
return dependentServicesResponse(domainName, values.dependentServices);
}
if (shape.expressions.includes('rate()')) {
return endpointMetricsResponse(
domainName,
shape.endPointName ?? endpointUrl(domainName, 0),
Date.now(),
);
}
return domainMetricsResponse(domainName, Date.now());
}),
),
],
config: (values) => ({
route: apiMonitoringRoute(values),
reduxState: timeRange(),
}),
});

View File

@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { apiMonitoringMocks } from './ApiMonitoringPage.stories.mocks';
import ApiMonitoringPage from '../ApiMonitoringPage';
type ApiMonitoringArgs = PageStoryArgs<typeof apiMonitoringMocks>;
const pageStory = storyMocks(apiMonitoringMocks, { layout: 'app' });
/**
* Third party domains instrumented services call, their endpoints, status codes
* and the services depending on them. The domain drawer is part of the route, so
* it is a control rather than a play.
*
* Route: `/api-monitoring/explorer`.
*/
const meta = {
title: 'Pages/External APIs',
tags: ['play'],
component: ApiMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ApiMonitoringArgs>;
export default meta;
type Story = StoryObj<ApiMonitoringArgs>;
/**
* Every external host the workspace called in the window, with the endpoints it
* uses, how often, how slow and how much of it failed. Clicking a row opens the
* domain drawer.
*/
export const Default: Story = {};
/**
* The domain drawer on All Endpoints: the host's own rate, latency and error
* share above a table of every endpoint under it, groupable by any span
* attribute.
*/
export const DomainEndpoints: Story = {
args: { drawer: 'all-endpoints' },
};
/** The endpoint drawer scoped to checkout, which hides dependent services. */
export const ServiceFiltered: Story = {
args: { drawer: 'endpoint-stats', serviceFilter: true },
};
/** A non-standard-port destination, preserving the endpoint metadata pill. */
export const PortDomain: Story = {
args: { drawer: 'endpoint-stats', drawerDomain: 'ip-address' },
};
/** The page fetches before it renders a filter, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The quick-filter panel has no test id of its own, and it only mounts once the
* workspace's filters have answered.
*/
const selectFirstQuickFilterValue = async (
canvasElement: HTMLElement,
): Promise<void> => {
const panel = await waitFor(() => {
const found = canvasElement.querySelector<HTMLElement>('.quick-filters');
if (!found) {
throw new Error('Quick filters did not render');
}
return found;
}, untilLoaded);
// The V2 checkbox panel starts with every value selected, so its checkbox only
// toggles an exclusion. The value's own label is what selects it on its own
// ("Only"), which is the state this story shows.
const [row] = await within(panel).findAllByTestId(
/^checkbox-value-row-/,
undefined,
untilLoaded,
);
await userEvent.click(within(row).getAllByRole('button')[0]);
// The panel re-renders around the new query, so the checkbox is looked up
// again on every attempt rather than held from before the click.
await waitFor(
() => expect(within(panel).getAllByRole('checkbox')[0]).toBeChecked(),
untilLoaded,
);
};
/** The domain drawer's real empty endpoint-table branch. */
export const EmptyEndpointDrawer: Story = {
args: { drawer: 'all-endpoints', endpoints: 0 },
};
/** A selected API Monitoring quick-filter value. */
export const QuickFilterSelected: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectFirstQuickFilterValue(canvasElement);
},
};
/**
* One endpoint's stats: the services calling it, the codes it answered with as
* a chart and a table, and its rate and latency over the window.
*/
export const EndpointStats: Story = {
args: { drawer: 'endpoint-stats' },
};
/**
* The ten errors the domain returned most, by endpoint, status code and the
* message that came back. A row opens the traces behind it.
*/
export const TopErrors: Story = {
args: { drawer: 'top-errors' },
};
/**
* A domain answering almost every call with an error, which is what the drawer
* looks like when the host is the problem.
*/
export const FailingDomain: Story = {
args: { drawer: 'endpoint-stats', drawerDomain: 'failing' },
};
/**
* Nothing instrumented yet: no client spans carrying a URL, so the page explains
* what to send instead of listing hosts.
*/
export const NoExternalCalls: Story = {
args: { domains: 0 },
};
/** The domain list mid-query, with the cancel action the toolbar offers. */
export const Loading: Story = {
args: { dataState: 'loading' },
};

View File

@@ -0,0 +1,556 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { domainNameKey } from 'container/ApiMonitoring/constants';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import type { APIMonitoringResponseColumn } from 'container/ApiMonitoring/types';
import type { PayloadProps as ListOverviewResponse } from 'types/api/thirdPartyApis/listOverview';
import type { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
import {
queryRangeV5ScalarTableResponse,
queryRangeV5TimeSeriesResponse,
timeSeriesPoints,
} from '@/storybook/msw/__story_mockdata__/queryRange';
interface Domain {
name: string;
/** Endpoints in use, which is also how many the drawer can list. */
endpoints: number;
rate: number;
errorRate: number;
latencyMs: number;
lastSeenMinutesAgo: number;
/** A bare address, which the Show IP addresses filter drops. */
isIp?: boolean;
/** Prefix its endpoint URLs carry, which is where the Port pill reads from. */
origin?: string;
}
const DOMAINS: Domain[] = [
{
name: 'api.stripe.com',
endpoints: 10,
rate: 8.42,
errorRate: 1.24,
latencyMs: 241,
lastSeenMinutesAgo: 2,
},
{
name: 'api.github.com',
endpoints: 7,
rate: 3.16,
errorRate: 0.42,
latencyMs: 187,
lastSeenMinutesAgo: 5,
},
{
name: 's3.us-east-1.amazonaws.com',
endpoints: 5,
rate: 21.68,
errorRate: 0.08,
latencyMs: 96,
lastSeenMinutesAgo: 1,
},
{
name: 'api.segment.io',
endpoints: 4,
rate: 12.94,
errorRate: 4.71,
latencyMs: 318,
lastSeenMinutesAgo: 11,
},
{
name: 'hooks.slack.com',
endpoints: 3,
rate: 0.82,
errorRate: 12.5,
latencyMs: 642,
lastSeenMinutesAgo: 46,
},
{
name: 'api.twilio.com',
endpoints: 4,
rate: 1.64,
errorRate: 61.9,
latencyMs: 1184,
lastSeenMinutesAgo: 184,
},
{
name: '34.120.155.12',
endpoints: 2,
rate: 0.41,
errorRate: 91.3,
latencyMs: 2410,
lastSeenMinutesAgo: 1620,
isIp: true,
origin: 'http://34.120.155.12:8080',
},
{
name: 'api.sendgrid.com',
endpoints: 3,
rate: 2.27,
errorRate: 0,
latencyMs: 152,
lastSeenMinutesAgo: 8,
},
];
export const DOMAIN_MAX = DOMAINS.length;
const MS_IN_MINUTE = 60 * 1000;
const NS_IN_MS = 1_000_000;
const lastSeenIso = (minutesAgo: number, now: number): string =>
new Date(now - minutesAgo * MS_IN_MINUTE).toISOString();
const listOverviewColumns: APIMonitoringResponseColumn[] = [
{
name: domainNameKey,
signal: 'traces',
fieldContext: '',
fieldDataType: 'string',
queryName: '',
aggregationIndex: 0,
meta: {},
columnType: 'attribute',
},
...['endpoints', 'rps', 'error_rate', 'p99', 'lastseen'].map((name) => ({
name,
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: name,
aggregationIndex: 0,
meta: {},
columnType: 'metric',
})),
];
const domainsIn = (count: number, showIp: boolean): Domain[] =>
DOMAINS.filter((domain) => showIp || !domain.isIp).slice(0, count);
export const domainNames = (count: number, showIp = true): string[] =>
domainsIn(count, showIp).map((domain) => domain.name);
export const DRAWER_DOMAINS = ['healthy', 'failing', 'ip-address'] as const;
export type DrawerDomain = (typeof DRAWER_DOMAINS)[number];
const DRAWER_DOMAIN_OF: Record<DrawerDomain, string> = {
healthy: 'api.stripe.com',
failing: 'api.twilio.com',
'ip-address': '34.120.155.12',
};
/** Falls back to the first row when the chosen domain is past the list's count. */
export const drawerDomainName = (
kind: DrawerDomain,
count: number,
): string | undefined => {
const available = domainNames(count);
const target = DRAWER_DOMAIN_OF[kind];
return available.includes(target) ? target : available[0];
};
export const domainListResponse = (
count: number,
showIp: boolean,
now: number,
): ListOverviewResponse => ({
status: 'success',
data: {
type: 'scalar',
meta: { rowsScanned: count, bytesScanned: 0, durationMs: 0 },
data: {
results: [
{
columns: listOverviewColumns,
// Typed as strings, but the error column calls `toFixed` on the cell
// and the last-used column parses it with `new Date`, so the metrics
// go out as numbers and the timestamp as a date string.
data: domainsIn(count, showIp).map((domain) => [
domain.name,
domain.endpoints,
domain.rate,
domain.errorRate,
domain.latencyMs * NS_IN_MS,
lastSeenIso(domain.lastSeenMinutesAgo, now),
]) as unknown as string[][],
},
],
},
},
});
const domainOf = (name: string): Domain =>
DOMAINS.find((domain) => domain.name === name) ?? DOMAINS[0];
const ENDPOINT_PATHS = [
'/v1/charges',
'/v1/customers',
'/v1/payment_intents',
'/v1/refunds',
'/v1/invoices',
'/v1/subscriptions',
'/v1/events',
'/v1/payouts',
'/v1/balance',
'/v1/tokens',
];
export const ENDPOINT_MAX = ENDPOINT_PATHS.length;
/** Full URLs, port included, which is what the drawer splits into endpoint and port. */
export const endpointUrls = (domainName: string, count: number): string[] => {
const { origin = `https://${domainName}` } = domainOf(domainName);
return ENDPOINT_PATHS.slice(0, count).map((path) => `${origin}${path}`);
};
export const endpointUrl = (domainName: string, index = 0): string =>
endpointUrls(domainName, ENDPOINT_MAX)[index];
const endpointScale = (domain: Domain, index: number): number =>
1 + ((index * 7) % 5) / 4;
export const domainMetricsResponse = (
domainName: string,
now: number,
): MetricRangePayloadV5 => {
const domain = domainOf(domainName);
return queryRangeV5ScalarTableResponse({
aggregations: ['A', 'B', 'D', 'F1'],
rows: [
[
domain.endpoints,
domain.latencyMs * NS_IN_MS,
lastSeenIso(domain.lastSeenMinutesAgo, now),
domain.errorRate,
],
],
});
};
export const endpointMetricsResponse = (
domainName: string,
endPointName: string,
now: number,
): MetricRangePayloadV5 => {
const domain = domainOf(domainName);
const index = Math.max(
endpointUrls(domainName, ENDPOINT_MAX).indexOf(endPointName),
0,
);
const scale = endpointScale(domain, index);
return queryRangeV5ScalarTableResponse({
aggregations: ['A', 'B', 'D', 'F1'],
rows: [
[
Number((domain.rate * scale).toFixed(2)),
Math.round(domain.latencyMs * scale) * NS_IN_MS,
lastSeenIso(domain.lastSeenMinutesAgo, now),
Number((domain.errorRate * scale).toFixed(2)),
],
],
});
};
/**
* The Endpoint Overview table. Extra group-by columns come from the request, so
* a group-by picked in the panel widens the table instead of dropping its rows.
*/
export const allEndpointsResponse = (
domainName: string,
count: number,
groupBy: string[],
now: number,
): MetricRangePayloadV5 => {
const domain = domainOf(domainName);
const extraGroupBy = groupBy.filter(
(name) => name !== SPAN_ATTRIBUTES.HTTP_URL,
);
return queryRangeV5ScalarTableResponse({
groupBy: [SPAN_ATTRIBUTES.HTTP_URL, ...extraGroupBy],
aggregations: ['A', 'B', 'C', 'F1'],
rows: endpointUrls(domainName, count).map((url, index) => {
const scale = endpointScale(domain, index);
return [
url,
...extraGroupBy.map((name) => groupByValue(name, index)),
Math.round(domain.rate * scale * 600),
Math.round(domain.latencyMs * scale) * NS_IN_MS,
lastSeenIso(domain.lastSeenMinutesAgo + index, now),
Number((domain.errorRate * scale).toFixed(2)),
];
}),
});
};
const GROUP_BY_VALUES: Record<string, string[]> = {
'service.name': ['checkout', 'payments', 'cart'],
'deployment.environment': ['production', 'staging'],
'rpc.method': ['POST', 'GET'],
};
function groupByValue(name: string, index: number): string {
const values = GROUP_BY_VALUES[name] ?? ['value-a', 'value-b'];
return values[index % values.length];
}
export const endpointDropdownResponse = (
domainName: string,
count: number,
): MetricRangePayloadV5 =>
queryRangeV5ScalarTableResponse({
groupBy: [SPAN_ATTRIBUTES.HTTP_URL],
aggregations: ['A'],
rows: endpointUrls(domainName, count).map((url, index) => [
url,
1200 - index * 90,
]),
});
const STATUS_CODES = ['200', '201', '304', '400', '404', '500'];
export const STATUS_CODE_MAX = STATUS_CODES.length;
const statusCodeCalls = (index: number): number =>
[4820, 1960, 640, 210, 96, 41][index];
export const statusCodeTableResponse = (
domainName: string,
count: number,
): MetricRangePayloadV5 => {
const domain = domainOf(domainName);
return queryRangeV5ScalarTableResponse({
groupBy: [SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE],
aggregations: ['A', 'B', 'C'],
rows: STATUS_CODES.slice(0, count).map((statusCode, index) => [
statusCode,
statusCodeCalls(index),
Math.round(domain.latencyMs * (1 + index / 3)) * NS_IN_MS,
Number((domain.rate / (index + 1)).toFixed(2)),
]),
});
};
const DEPENDENT_SERVICES = [
'checkout',
'payments',
'cart',
'auth',
'notifications',
'search',
'orders',
'shipping',
];
export const DEPENDENT_SERVICE_MAX = DEPENDENT_SERVICES.length;
export const dependentServicesResponse = (
domainName: string,
count: number,
): MetricRangePayloadV5 => {
const domain = domainOf(domainName);
return queryRangeV5ScalarTableResponse({
groupBy: ['service.name'],
aggregations: ['A', 'B', 'C', 'F1'],
rows: DEPENDENT_SERVICES.slice(0, count).map((service, index) => {
const calls = Math.round(3800 / (index + 1));
return [
service,
calls,
Math.round(domain.latencyMs * (1 + index / 5)) * NS_IN_MS,
Number((domain.rate / (index + 1)).toFixed(2)),
Number((domain.errorRate * (1 + index / 4)).toFixed(2)),
];
}),
});
};
interface TopError {
statusCode: string;
message: string;
count: number;
}
const TOP_ERRORS: TopError[] = [
{ statusCode: '500', message: 'upstream connect error', count: 412 },
{ statusCode: '429', message: 'rate limit exceeded', count: 318 },
{ statusCode: '503', message: 'upstream timeout', count: 244 },
{ statusCode: '502', message: 'connection reset by peer', count: 187 },
{ statusCode: '400', message: 'invalid request payload', count: 143 },
{ statusCode: '401', message: 'expired api key', count: 118 },
{ statusCode: '404', message: 'no such customer', count: 96 },
{ statusCode: '409', message: 'idempotency key reused', count: 71 },
{ statusCode: '422', message: 'card declined', count: 54 },
{ statusCode: '500', message: 'internal server error', count: 32 },
];
export const TOP_ERROR_MAX = TOP_ERRORS.length;
/**
* The Top 10 Errors table, which reads the scalar result itself rather than the
* legacy conversion, so its cells are keyed by column name.
*/
export const topErrorsResponse = (
domainName: string,
count: number,
withStatusMessage: boolean,
endPointName?: string,
): MetricRangePayloadV5 => {
const urls = endpointUrls(domainName, ENDPOINT_MAX);
return {
data: {
type: 'scalar',
data: {
results: [
{
columns: [
{
name: SPAN_ATTRIBUTES.HTTP_URL,
queryName: '',
aggregationIndex: 0,
columnType: 'group',
},
{
name: SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE,
queryName: '',
aggregationIndex: 0,
columnType: 'group',
},
{
name: 'status_message',
queryName: '',
aggregationIndex: 0,
columnType: 'group',
},
{
name: '__result_0',
queryName: 'A',
aggregationIndex: 0,
columnType: 'aggregation',
},
],
data: TOP_ERRORS.slice(0, count).map((error, index) => [
endPointName ?? urls[index % urls.length],
error.statusCode,
withStatusMessage ? error.message : 'n/a',
error.count,
]),
},
],
},
meta: {
rowsScanned: count,
bytesScanned: 0,
durationMs: 0,
stepIntervals: {},
},
},
};
};
interface Window {
start: number;
end: number;
}
/**
* Call response status, both the count and the latency the card switches to.
* The chart buckets the codes into 2xx5xx, so the per-code weights are the
* ones the status code table shows and the buckets keep their relative size.
*/
export const statusCodeChartResponse = (
domainName: string,
count: number,
window: Window,
metric: 'calls' | 'latency',
): MetricRangePayloadV5 => {
const domain = domainOf(domainName);
return queryRangeV5TimeSeriesResponse([
{
queryName: 'A',
series: STATUS_CODES.slice(0, count).map((statusCode, index) => {
const base =
metric === 'calls'
? statusCodeCalls(index) / 12
: Math.round(domain.latencyMs * (1 + index / 3)) * NS_IN_MS;
return {
labels: [
{
key: { name: SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE },
value: statusCode,
},
],
values: timeSeriesPoints({
...window,
seed: index * 3,
base,
amplitude: base / 5,
}),
};
}),
},
]);
};
/** The rate and latency charts at the bottom of the endpoint stats view. */
export const overTimeChartResponse = (
domainName: string,
window: Window,
metric: 'rate' | 'latency',
): MetricRangePayloadV5 => {
const domain = domainOf(domainName);
const base = metric === 'rate' ? domain.rate : domain.latencyMs * NS_IN_MS;
return queryRangeV5TimeSeriesResponse([
{
queryName: 'A',
series: [
{
values: timeSeriesPoints({
...window,
base,
amplitude: base / 5,
}),
},
],
},
]);
};
const GROUP_BY_KEYS = [
'service.name',
'deployment.environment',
'rpc.method',
'http.request.method',
'net.peer.name',
];
export const groupByAttributeKeys = (
searchText: string,
): Array<{ key: string; dataType: string; type: string; isColumn: boolean }> =>
GROUP_BY_KEYS.filter((key) =>
key.toLowerCase().includes(searchText.toLowerCase()),
).map((key) => ({
key,
dataType: 'string',
type: 'tag',
isColumn: false,
}));

View File

@@ -0,0 +1,157 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { rest } from 'msw';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertFieldKeysResponse,
alertFieldValuesResponse,
alertMetricMetadataResponse,
alertMetricsResponse,
alertPreviewSeries,
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
import {
channelsResponse,
CHANNEL_MAX,
} from '../../AlertList/stories/__story_mockdata__/alerts';
/**
* Which alert the page is building. The page reads this off the URL, so the
* control is a route rather than a response: with no type at all it stays on
* the picker, anomaly detection routes to the classic form, and everything else
* opens the current one.
*/
const ALERT_MODES = [
'select-type',
'metrics',
'logs',
'traces',
'exceptions',
'anomaly',
'classic-form',
] as const;
type AlertMode = (typeof ALERT_MODES)[number];
const ALERT_TYPE_BY_MODE: Partial<Record<AlertMode, AlertTypes>> = {
metrics: AlertTypes.METRICS_BASED_ALERT,
logs: AlertTypes.LOGS_BASED_ALERT,
traces: AlertTypes.TRACES_BASED_ALERT,
exceptions: AlertTypes.EXCEPTIONS_BASED_ALERT,
anomaly: AlertTypes.METRICS_BASED_ALERT,
'classic-form': AlertTypes.METRICS_BASED_ALERT,
};
const routeFor = (mode: AlertMode): string => {
const alertType = ALERT_TYPE_BY_MODE[mode];
if (!alertType) {
return ROUTES.ALERTS_NEW;
}
const params = new URLSearchParams({
[QueryParams.alertType]: alertType,
[QueryParams.ruleType]:
mode === 'anomaly'
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.relativeTime]: '6h',
});
if (mode === 'classic-form') {
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
}
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
};
const FORM = 'Create alert · form';
export const createAlertMocks = defineStoryMocks({
controls: {
alertMode: choiceControl<AlertMode>('Alert being created', {
group: FORM,
options: ALERT_MODES,
value: 'metrics',
}),
channels: countControl('Notification channels', {
group: FORM,
description: 'What a threshold can be routed to.',
value: 5,
max: CHANNEL_MAX,
}),
previewSeries: countControl('Preview series', {
group: FORM,
description:
'Lines the chart above the condition draws once the query has something to run. A new metric alert has no metric picked yet, so it draws nothing until one is.',
value: 3,
max: 6,
}),
},
handlers: (values, response) => [
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
),
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
}),
),
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
),
rest.get(
'http://localhost/api/v2/metrics',
response.json((req) =>
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v2/metrics/metadata',
response.json((req) =>
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/keys',
response.json((req) =>
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
alertFieldValuesResponse(
req.url.searchParams.get('name') ?? '',
req.url.searchParams.get('searchText') ?? '',
),
),
),
],
config: (values) => ({ route: routeFor(values.alertMode) }),
});

View File

@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { createAlertMocks } from './CreateAlert.stories.mocks';
import CreateAlertPage from '../index';
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
const pageStory = storyMocks(createAlertMocks, { layout: 'app' });
/**
* The new rule builder: the query, the condition, the evaluation preview against
* `query_range`, and the channels to notify. The mode control picks the alert
* type.
*
* Route: `/alerts/new`.
*/
const meta = {
title: 'Pages/Alerts/Create',
tags: ['play'],
component: CreateAlertPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<CreateAlertArgs>;
export default meta;
type Story = StoryObj<CreateAlertArgs>;
/**
* A new metric alert being written: the query it watches, the threshold it
* fires on, and where the notification goes.
*/
export const Default: Story = {};
/** Where a new alert starts: the signal the rule is going to watch. */
export const SelectAlertType: Story = {
args: { alertMode: 'select-type' },
};
/** A log-based alert, whose query section searches logs rather than metrics. */
export const LogsAlert: Story = {
args: { alertMode: 'logs' },
};
/**
* Anomaly detection, which is still written in the classic form: the seasonality
* and the deviation take the place of a fixed threshold.
*/
export const AnomalyAlert: Story = {
args: { alertMode: 'anomaly' },
};
/** The classic form, which `showClassicCreateAlertsPage` opts back into. */
export const ClassicForm: Story = {
args: { alertMode: 'classic-form' },
};
/**
* The match-type tooltip on the threshold sentence: a paragraph on what an
* aggregated data point is, a worked example over five of them, and the docs
* link. There is one per match type, and they are antd tooltips rather than
* `@signozhq/ui` ones, so the Tooltips control leaves them alone and only the
* option under the pointer shows one. This opens the match-type list and holds
* the tallest of the five, "all the time", whose example runs to two lines.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }): Promise<void> => {
const select = await within(canvasElement).findByTestId(
'alert-threshold-match-type-select',
undefined,
{ timeout: 15_000 },
);
// The select opens off a mousedown on its inner selector, so a click on
// the wrapper the test id sits on reaches nothing.
await userEvent.click(within(select).getByRole('combobox'));
await userEvent.hover(
await screen.findByText('ALL THE TIME', undefined, { timeout: 15_000 }),
);
await screen.findByText('Example:', undefined, { timeout: 15_000 });
},
};

View File

@@ -0,0 +1,205 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { choiceControl, toggleControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
fieldKeysResponse,
fieldValuesResponse,
} from '@/storybook/msw/__story_mockdata__/fields';
import {
listMetricsResponse,
metricMetadataResponse,
} from '@/storybook/msw/__story_mockdata__/metrics';
import {
NEW_PANEL_ID,
newPanelSearch,
} from '../../DashboardContainer/PanelEditor/newPanelRoute';
import {
currentDashboardDocument,
PANEL_IDS,
patchDashboardDocument,
seedDashboardDocument,
STORY_DASHBOARD_ID,
VARIABLE_KINDS,
type DashboardArgs,
} from '../../stories/__story_mockdata__/dashboard';
import {
emptyPanelResponse,
NAMESPACE_VALUES,
panelResponse,
serviceVariableValues,
} from '../../stories/__story_mockdata__/panelData';
import {
EDITOR_FIELD_KEYS,
EDITOR_FIELD_VALUES,
EDITOR_METRICS,
NEW_PANEL_KINDS,
newPanelKindOf,
type NewPanelKind,
} from './__story_mockdata__/panelEditor';
const PANEL = 'Panel editor · panel';
const DATA = 'Panel editor · data';
const PANEL_OPTIONS = [...PANEL_IDS, NEW_PANEL_ID] as const;
type PanelOption = (typeof PANEL_OPTIONS)[number];
const editorRoute = (panel: PanelOption, kind: NewPanelKind): string => {
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId: STORY_DASHBOARD_ID,
panelId: panel,
});
return panel === NEW_PANEL_ID
? `${path}${newPanelSearch(newPanelKindOf(kind))}`
: path;
};
export const panelEditorMocks = defineStoryMocks({
controls: {
panel: choiceControl<PanelOption>('Panel', {
group: PANEL,
description:
'The panel the editor opens on. `new` is the create route, which seeds an unsaved panel of the kind below instead of loading one.',
options: PANEL_OPTIONS,
value: 'request-rate',
}),
newPanelKind: choiceControl<NewPanelKind>('New panel kind', {
group: PANEL,
description: 'Which kind the create route seeds. Ignored on a saved panel.',
options: NEW_PANEL_KINDS,
value: 'time-series',
}),
locked: toggleControl('Dashboard locked', {
group: PANEL,
description:
'A locked dashboard is read-only, so the editor loads but Save is refused with the reason.',
value: false,
}),
noData: toggleControl('Preview returns nothing', {
group: DATA,
description: 'The preview query answers with an empty result.',
value: false,
}),
},
handlers: (values, response) => {
const document: DashboardArgs = {
panels: PANEL_IDS.length,
sectioned: true,
variables: VARIABLE_KINDS,
locked: values.locked,
};
return [
// The document the editor resolves its panel from, so it answers on its own
// rather than through the Data control.
rest.get('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
),
// Saving the panel is a JSON Patch whose response replaces the cache, so
// the ops are applied to the story's document and the edit stays.
rest.patch(
'http://localhost/api/v2/dashboards/:id',
async (req, res, ctx) => {
const ops = (await req.json()) as Parameters<
typeof patchDashboardDocument
>[1];
return res(
ctx.status(200),
ctx.json(patchDashboardDocument(document, ops)),
);
},
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
if (values.noData) {
return emptyPanelResponse();
}
const body = (await req.json()) as QueryRangeRequestV5;
const spec = body.compositeQuery?.queries?.[0]?.spec as
| {
aggregations?: { metricName?: string }[];
groupBy?: { name?: string }[];
}
| undefined;
return panelResponse({
requestType: body.requestType,
window: { start: body.start, end: body.end },
metricName: spec?.aggregations?.[0]?.metricName,
groupBy: spec?.groupBy?.[0]?.name,
});
}),
),
rest.post(
'http://localhost/api/v2/variables/query',
response.json(() => ({
status: 'success',
data: { variableValues: serviceVariableValues(4) },
})),
),
rest.get(
'http://localhost/api/v2/metrics',
response.json((req) =>
listMetricsResponse(
EDITOR_METRICS,
req.url.searchParams.get('searchText') ?? '',
),
),
),
rest.get(
'http://localhost/api/v2/metrics/metadata',
response.json((req) =>
metricMetadataResponse(
EDITOR_METRICS,
req.url.searchParams.get('metricName') ?? '',
),
),
),
rest.get(
'http://localhost/api/v1/fields/keys',
response.json(() => fieldKeysResponse(EDITOR_FIELD_KEYS)),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
fieldValuesResponse(
EDITOR_FIELD_VALUES[req.url.searchParams.get('name') ?? ''] ??
NAMESPACE_VALUES,
),
),
),
];
},
config: (values) => ({
route: editorRoute(values.panel, values.newPanelKind),
}),
effect: (values) => {
seedDashboardDocument({
panels: PANEL_IDS.length,
sectioned: true,
variables: VARIABLE_KINDS,
locked: values.locked,
});
},
});

View File

@@ -0,0 +1,86 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { panelEditorMocks } from './PanelEditorPage.stories.mocks';
import PanelEditorPage from '../PanelEditorPage';
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
/**
* The panel editor: the query builder on one side, the panel it renders on the
* other, for a panel that exists or a new one of the chosen kind.
*
* Route: `/dashboard/:dashboardId/panel/:panelId`.
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor',
// The page is wrapped in `withAuthZPage`, which types its props as an index
// signature; the story's args are what the controls resolve to.
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
// The dashboard and panel ids come out of the pathname, so the editor renders
// under its own route rather than being mounted on its own.
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<PanelEditorArgs>;
export default meta;
type Story = StoryObj<PanelEditorArgs>;
/**
* Editing a saved time series panel: the live preview over the query builder on
* the left, the panel's formatting, legend, axes and thresholds on the right.
*/
export const Default: Story = {};
/** The create route, seeding an unsaved panel of the chosen kind. */
export const NewPanel: Story = {
args: { panel: 'new' },
};
/** A list panel, where the config pane is the column editor. */
export const ListPanel: Story = {
args: { panel: 'recent-logs' },
};
/** A table panel, with its column units and thresholds. */
export const TablePanel: Story = {
args: { panel: 'top-endpoints' },
};
/** The editor and query configuration remain visible when its preview has no rows. */
export const NoPreviewData: Story = {
args: { noData: true },
};
/** The editor remains usable while the independently fetched preview has failed. */
export const PreviewQueryError: Story = {
args: { dataState: 'error' },
};
/** A locked dashboard: the editor still opens, but it cannot save. */
export const ReadOnly: Story = {
args: { locked: true },
// The deliberate 500s on the metrics queries are the state under test.
parameters: { allowConsoleErrors: true },
};
/**
* Every tooltip the editor carries, held open: the Quick Add beside the
* Thresholds and Context links section headers, and the copy button on each of
* the preview legend's series.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};

View File

@@ -0,0 +1,98 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
metricSeed,
type MetricSeed,
} from '@/storybook/msw/__story_mockdata__/metrics';
import type { PanelKind } from '../../../DashboardContainer/Panels/types/panelKind';
/**
* The panel kinds the create route can seed, spelled without the `signoz/`
* prefix: a control value carrying a slash does not survive the story URL.
*/
export const NEW_PANEL_KINDS = [
'time-series',
'bar-chart',
'number',
'pie-chart',
'table',
'histogram',
'list',
] as const;
export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
const KIND_BY_OPTION: Record<NewPanelKind, PanelKind> = {
'time-series': 'signoz/TimeSeriesPanel',
'bar-chart': 'signoz/BarChartPanel',
number: 'signoz/NumberPanel',
'pie-chart': 'signoz/PieChartPanel',
table: 'signoz/TablePanel',
histogram: 'signoz/HistogramPanel',
list: 'signoz/ListPanel',
};
export const newPanelKindOf = (option: NewPanelKind): PanelKind =>
KIND_BY_OPTION[option];
/** Attributes the editor's query builder offers while filtering and grouping. */
export const EDITOR_FIELD_KEYS = [
'service.name',
'http.route',
'http.status_code',
'deployment.environment',
'k8s.namespace.name',
'host.name',
] as const;
export const EDITOR_FIELD_VALUES: Record<string, readonly string[]> = {
'service.name': ['checkout', 'payments', 'inventory', 'notifications'],
'http.route': ['/v1/checkout', '/v1/cart', '/v1/payment/authorize'],
'http.status_code': ['200', '404', '500', '503'],
'deployment.environment': ['production', 'staging', 'development'],
'k8s.namespace.name': ['checkout-prod', 'payments-prod', 'platform-prod'],
'host.name': ['ip-10-0-1-14', 'ip-10-0-2-31', 'ip-10-0-3-77'],
};
/** The metrics the editor's aggregation field offers, the panels' own included. */
export const EDITOR_METRICS: MetricSeed[] = [
metricSeed('signoz_calls_total', 'Total spans received', 'count'),
metricSeed('signoz_errors_total', 'Spans with an error status', 'count'),
metricSeed(
'signoz_latency_bucket',
'Span duration histogram',
'ms',
MetrictypesTypeDTO.histogram,
MetrictypesTemporalityDTO.delta,
),
metricSeed(
'signoz_apdex',
'Apdex score per service',
'',
MetrictypesTypeDTO.gauge,
MetrictypesTemporalityDTO.unspecified,
),
metricSeed(
'system_cpu_usage',
'CPU used per host',
'percent',
MetrictypesTypeDTO.gauge,
MetrictypesTemporalityDTO.unspecified,
),
metricSeed(
'system_memory_usage',
'Memory used per host',
'bytes',
MetrictypesTypeDTO.gauge,
MetrictypesTemporalityDTO.unspecified,
),
];

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