Compare commits

...

141 Commits

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -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 'hooks/useResourceAttribute/types';
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,354 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type {
IResourceAttribute,
Tags,
} 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';
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,113 @@
/**
* 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));
},
});

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { expect, screen, userEvent, waitFor, 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> => {
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(() => {
const settings = screen.getByTestId('settings-icon-container');
expect(settings).toBeEnabled();
return settings;
}, untilLoaded);
await userEvent.click(control);
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,
),
];

View File

@@ -0,0 +1,329 @@
/**
* 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 { GetPublicDashboard200 } from 'api/generated/services/sigNoz.schemas';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
countControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
import {
currentDashboardDocument,
patchDashboardDocument,
PANEL_IDS,
seedDashboardDocument,
STORY_DASHBOARD_ID,
VARIABLE_KINDS,
type DashboardArgs,
type VariableKind,
} from './__story_mockdata__/dashboard';
import {
emptyPanelResponse,
NAMESPACE_VALUES,
panelResponse,
serviceVariableValues,
} from './__story_mockdata__/panelData';
import {
desyncedDashboardResponse,
TOOLTIP_SELECTED_SERVICES,
TOOLTIP_WARNED_METRIC,
tooltipDashboardResponse,
} from './__story_mockdata__/tooltipDashboard';
const LAYOUT = 'Dashboard · layout';
const DATA = 'Dashboard · panels';
const SHARING = 'Dashboard · sharing';
export const dashboardRoute = (): string =>
generatePath(ROUTES.DASHBOARD, { dashboardId: STORY_DASHBOARD_ID });
export const tooltipRoute = (): string =>
`${dashboardRoute()}?variables=${encodeURIComponent(
JSON.stringify({ service: TOOLTIP_SELECTED_SERVICES }),
)}`;
const NOT_FOUND = {
status: 'error',
error: {
code: 'not_found',
message: `dashboard with id ${STORY_DASHBOARD_ID} not found`,
url: '',
errors: [],
},
};
const ok = { status: 'success', data: null };
const publicMeta = (): GetPublicDashboard200 => ({
status: 'success',
data: {
timeRangeEnabled: true,
defaultTimeRange: '30m',
publicPath: `/public/dashboard/${STORY_DASHBOARD_ID}`,
},
});
const NOT_PUBLIC = {
status: 'error',
error: {
code: 'public_dashboard_not_found',
message: `dashboard with id ${STORY_DASHBOARD_ID} isn't public`,
url: '',
errors: [],
},
};
interface PanelQuerySpec {
signal?: string;
aggregations?: { metricName?: string }[];
groupBy?: { name?: string }[];
}
const readPanelQuery = (
body: QueryRangeRequestV5,
): { metricName?: string; groupBy?: string } => {
const spec = body.compositeQuery?.queries?.[0]?.spec as
| PanelQuerySpec
| undefined;
return {
metricName: spec?.aggregations?.[0]?.metricName,
groupBy: spec?.groupBy?.[0]?.name,
};
};
export const dashboardMocks = defineStoryMocks({
controls: {
panels: countControl('Panels', {
group: LAYOUT,
description:
'Panels the dashboard holds, taken in layout order. Zero is the blank dashboard a fresh one starts as.',
value: PANEL_IDS.length,
max: PANEL_IDS.length,
}),
sectioned: toggleControl('Sections', {
group: LAYOUT,
description:
'Titled, collapsible, reorderable sections. Off is the single untitled grid a dashboard without sections renders.',
value: true,
}),
locked: toggleControl('Locked', {
group: LAYOUT,
description:
'A locked dashboard is read-only: the lock indicator shows and the edit affordances go.',
value: false,
}),
variables: multiChoiceControl<VariableKind>('Variables', {
group: LAYOUT,
description:
'The variable bar above the panels, one control per kind: a custom list, a query-backed list, a dynamic attribute and a free-text value.',
options: VARIABLE_KINDS,
value: [...VARIABLE_KINDS],
}),
variableValues: countControl('Variable options', {
group: DATA,
description: 'Values the query-backed `service` variable resolves to.',
value: 4,
max: 12,
}),
noData: toggleControl('Panels return nothing', {
group: DATA,
description:
'Every panel query answers with an empty result, which is the no-data state each renderer draws on its own.',
value: false,
}),
notFound: toggleControl('Dashboard not found', {
group: LAYOUT,
description:
'Answers the dashboard document with a 404, which is the page-level failure the shell renders around.',
value: false,
}),
published: toggleControl('Published publicly', {
group: SHARING,
description:
'Whether this dashboard has a public link, which is what the header globe reports. Turning it off is the 404 the endpoint answers for a dashboard nobody published.',
value: true,
}),
},
handlers: (values, response) => {
const document: DashboardArgs = {
panels: values.panels,
sectioned: values.sectioned,
variables: values.variables,
locked: values.locked,
};
return [
// The document is what the page renders from, so it answers on its own
// rather than through the Data control: the panels are what that holds in
// the loading and failed states, with the dashboard already laid out.
rest.get('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
values.notFound
? res(ctx.status(404), ctx.json(NOT_FOUND))
: res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
),
// Every spec edit travels as a JSON Patch, and its response is what
// replaces the cache, so the ops are applied to the story's document
// rather than answered away.
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/v2/dashboards/:id/clone', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
),
rest.delete('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
rest.put('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
rest.delete(
'http://localhost/api/v2/dashboards/:id/lock',
(_req, res, ctx) => res(ctx.status(200), ctx.json(ok)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
if (values.noData) {
return emptyPanelResponse();
}
const body = (await req.json()) as QueryRangeRequestV5;
return panelResponse({
requestType: body.requestType,
window: { start: body.start, end: body.end },
...readPanelQuery(body),
});
}),
),
rest.post(
'http://localhost/api/v2/variables/query',
response.json(() => ({
status: 'success',
data: { variableValues: serviceVariableValues(values.variableValues) },
})),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
),
// The header reads the public link on every load, so it answers even while
// the panels are held in the loading or failed state.
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
values.published
? res(ctx.status(200), ctx.json(publicMeta()))
: res(ctx.status(404), ctx.json(NOT_PUBLIC)),
),
rest.post(
'http://localhost/api/v1/dashboards/:id/public',
(_req, res, ctx) => res(ctx.status(200), ctx.json(publicMeta())),
),
rest.put('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(publicMeta())),
),
rest.delete(
'http://localhost/api/v1/dashboards/:id/public',
(_req, res, ctx) => res(ctx.status(200), ctx.json(ok)),
),
];
},
config: () => ({ route: dashboardRoute() }),
effect: (values) => {
seedDashboardDocument({
panels: values.panels,
sectioned: values.sectioned,
variables: values.variables,
locked: values.locked,
});
},
});
/**
* One panel's query answered with a warning beside its value, so its header
* carries the status indicator while the rest of the dashboard is untouched.
* Every other query falls through to the page's own handler.
*/
export const warnedPanelQueryHandler = rest.post(
'http://localhost/api/v5/query_range',
async (req, res, ctx) => {
const body = (await req.json()) as QueryRangeRequestV5;
const spec = body.compositeQuery?.queries?.[0]?.spec as
| { aggregations?: { metricName?: string }[] }
| undefined;
if (spec?.aggregations?.[0]?.metricName !== TOOLTIP_WARNED_METRIC) {
return undefined;
}
return res(
ctx.status(200),
ctx.json(
queryRangeV5ScalarResponse(0.94, 'A', {
warning: {
code: 'partial_data',
message: `Some series for ${TOOLTIP_WARNED_METRIC} were dropped: the metric changed temporality partway through the selected window.`,
url: 'https://signoz.io/docs/metrics-management/types-and-aggregation/',
warnings: [
{
message:
'Narrow the window to a period with one temporality, or re-record the metric as a delta.',
},
],
},
}),
),
);
},
);
export const tooltipDashboardHandler = rest.get(
'http://localhost/api/v2/dashboards/:id',
(_req, res, ctx) => res(ctx.status(200), ctx.json(tooltipDashboardResponse())),
);
export const desyncedDashboardHandler = rest.get(
'http://localhost/api/v2/dashboards/:id',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(desyncedDashboardResponse())),
);
// The view modal mounts a query builder, which lists metrics before it renders.
export const metricsListHandler = rest.get(
'http://localhost/api/v2/metrics',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: { metrics: [] } })),
);

View File

@@ -0,0 +1,245 @@
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 { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardMocks,
desyncedDashboardHandler,
metricsListHandler,
tooltipDashboardHandler,
tooltipRoute,
warnedPanelQueryHandler,
} from './DashboardPage.stories.mocks';
import { TOOLTIP_PANEL_NAME } from './__story_mockdata__/tooltipDashboard';
import DashboardPage from '../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* One dashboard: its variables, its sections and every panel querying
* `query_range`, plus the lock, clone and publish actions in the header.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail',
tags: ['role-gated', 'play'],
// 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: DashboardPage as ComponentType<DashboardArgs>,
// The page reads the dashboard id out of the pathname, so it renders under
// its own route rather than being mounted on its own.
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/**
* A service dashboard with data: the variable bar over two titled sections, and
* the panel kinds all drawn from the same query endpoint: time series, single
* numbers, a table, a bar chart, a pie and a log list.
*/
export const Default: Story = {};
/**
* The blank dashboard a freshly created one starts as, with the two steps that
* populate it. A titled section renders its own add-panel state instead, so this
* needs both no panels and no sections.
*/
export const Empty: Story = {
args: { panels: 0, sectioned: false },
};
/**
* A locked dashboard: the lock indicator sits over the grid and the edit
* affordances are gone even for an admin.
*/
export const Locked: Story = {
args: { locked: true },
};
/**
* A viewer: the panels and the variable bar work, but nothing that would change
* the dashboard is offered.
*/
export const Viewer: Story = {
args: { access: 'viewer' },
};
/**
* Every query ran and matched nothing, which each panel kind draws as its own
* no-data state.
*/
export const NoData: Story = {
args: { noData: true },
};
/** A partial data failure: the dashboard remains visible while panel queries fail. */
export const PanelQueryError: Story = {
args: { dataState: 'error' },
};
/** The panels mid-fetch, with the header, variable bar and grid already laid out. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/**
* Every tooltip the dashboard itself carries, held open at once: the title, the
* description with its link, the public-page globe, the `+N` of tags that did
* not fit, two panel descriptions, a panel's time-preference pill, the
* collapsed panel search, the warning one panel's query came back with, the
* `+N` of variables the bar hid, the values a multi-select pill stands for, the
* add-variable `+`, and a legend's copy buttons.
*
* The document and one panel's query are answered by the story, so the Panels,
* Sections, Variables and Locked controls do not reach it; the Variable options
* control still does, and the selection comes from the route.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true, variableValues: 12 },
parameters: {
signoz: { route: tooltipRoute() },
msw: { handlers: [tooltipDashboardHandler, warnedPanelQueryHandler] },
},
};
/**
* What a locked dashboard refuses, with the Actions menu open: the padlock
* offering to unlock, the disabled Configure and New Panel buttons, and the
* menu rows saying why they cannot be picked.
*/
export const TooltipsWhenLocked: Story = {
args: { tooltipsOpen: true, locked: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// The dropdown trigger's Slot merge drops the button's own test id.
await userEvent.click(
await canvas.findByRole('button', { name: 'Actions' }, { timeout: 10000 }),
);
await screen.findByText('Clone dashboard');
},
};
/**
* The JSON editor's two warnings, held open: the panels the layout places
* nowhere and the layout slots pointing at panels the spec no longer has, each
* listing the ids behind it.
*
* The document is answered by the story, so the Panels, Sections, Variables and
* Locked controls do not reach it.
*/
export const TooltipsInJsonDrawer: Story = {
args: { tooltipsOpen: true },
parameters: { msw: { handlers: [desyncedDashboardHandler] } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('edit-json', {}, { timeout: 10000 }),
);
await screen.findByTestId('json-editor-dangling-warning');
},
};
/**
* The Overview tab of dashboard settings, where Cross-Panel Sync explains what
* syncing the crosshair does and links out to the docs.
*/
export const TooltipsInSettings: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
);
await screen.findByText('Sync Mode');
},
};
/**
* The Variables tab of dashboard settings, where a dynamic variable's Apply to
* all says whether it is already a filter on every panel. The row keeps its
* actions invisible until it is hovered, which the story does first.
*/
export const TooltipsInVariableSettings: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
);
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
// The tooltip trigger's Slot merge drops the button's own test id.
await userEvent.hover(
await screen.findByRole(
'button',
{ name: 'Apply to all' },
{ timeout: 10000 },
),
);
await screen.findByText(
'Add this variable as a filter to every panel',
undefined,
{ timeout: 10000 },
);
},
};
/**
* A panel expanded into view mode, whose header carries the full panel name its
* title truncates, over the dashboard's own tooltips behind the dialog.
*
* The document is answered by the story, so the Panels, Sections, Variables and
* Locked controls do not reach it.
*/
export const TooltipsInViewPanelModal: Story = {
args: { tooltipsOpen: true },
parameters: {
msw: { handlers: [tooltipDashboardHandler, metricsListHandler] },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId(
'panel-actions-request-rate',
{},
{ timeout: 10000 },
),
);
await userEvent.click(await screen.findByText('View'));
await screen.findByText(`${TOOLTIP_PANEL_NAME} - (View mode)`);
},
};
/**
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
*
* Kept last: test-runner shares one page across a file's stories, and the 404
* this story is about can settle after the next story has already started,
* which fails that one instead.
*/
export const NotFound: Story = {
args: { notFound: true },
// The deliberate 404 is the state under test.
parameters: { allowConsoleErrors: true },
};

View File

@@ -0,0 +1,453 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
DashboardtypesDynamicVariableSignalDTO as DynamicSignal,
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind as GridKind,
DashboardtypesPanelKindDTO as PanelKind,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTOKind as BarChartKind,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTOKind as ListKind,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind as NumberKind,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTOKind as PieChartKind,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTOKind as TableKind,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind as TimeSeriesKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryKind,
DashboardtypesSourceDTO,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListVariableKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextVariableKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomVariableKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicVariableKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryVariableKind,
MetrictypesSpaceAggregationDTO as SpaceAggregation,
MetrictypesTimeAggregationDTO as TimeAggregation,
Querybuildertypesv5OrderDirectionDTO as OrderDirection,
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal as LogSignal,
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricSignal,
Querybuildertypesv5RequestTypeDTO as RequestType,
type DashboardtypesDashboardSpecDTOPanels,
type DashboardtypesLayoutDTO,
type DashboardtypesPanelDTO,
type DashboardtypesGettableDashboardV2DTO,
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesQueryDTO,
type DashboardtypesVariableDTO,
type GetDashboardV2200,
type Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO as LogBuilderQuery,
type Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO as MetricBuilderQuery,
} from 'api/generated/services/sigNoz.schemas';
import { applyJsonPatch } from '../../DashboardContainer/optimistic/applyJsonPatch';
export const STORY_DASHBOARD_ID = 'storybook-dashboard-1';
interface MetricQueryArgs {
metricName: string;
requestType: RequestType;
groupBy?: string;
/** Only the plotted kinds label their series; a table would show it as a column header. */
legend?: string;
}
const QUERY_NAME = 'A';
const metricQuery = ({
metricName,
requestType,
groupBy,
legend,
}: MetricQueryArgs): DashboardtypesQueryDTO[] => {
const spec: MetricBuilderQuery = {
name: QUERY_NAME,
signal: MetricSignal.metrics,
aggregations: [
{
metricName,
spaceAggregation: SpaceAggregation.sum,
timeAggregation: TimeAggregation.rate,
},
],
groupBy: groupBy ? [{ name: groupBy }] : undefined,
legend,
filter: { expression: 'deployment.environment = $environment' },
};
return [
{
kind: requestType,
spec: {
name: QUERY_NAME,
plugin: { kind: BuilderQueryKind['signoz/BuilderQuery'], spec },
},
},
];
};
const logQuery = (): DashboardtypesQueryDTO[] => {
const spec: LogBuilderQuery = {
name: QUERY_NAME,
signal: LogSignal.logs,
selectFields: [{ name: 'body' }, { name: 'service.name' }],
order: [{ key: { name: 'timestamp' }, direction: OrderDirection.desc }],
};
return [
{
kind: RequestType.raw,
spec: {
name: QUERY_NAME,
plugin: { kind: BuilderQueryKind['signoz/BuilderQuery'], spec },
},
},
];
};
/**
* The panels the dashboard holds, in the order the sections lay them out. Each
* one names the query the handler answers for, so a panel's shape and its data
* stay declared together.
*/
export const PANEL_IDS = [
'request-rate',
'error-rate',
'p99-latency',
'apdex',
'top-endpoints',
'errors-by-status',
'traffic-share',
'recent-logs',
] as const;
export type PanelId = (typeof PANEL_IDS)[number];
const PANELS: Record<PanelId, DashboardtypesPanelDTO> = {
'request-rate': {
kind: PanelKind.Panel,
spec: {
display: { name: 'Request rate by service' },
plugin: { kind: TimeSeriesKind['signoz/TimeSeriesPanel'], spec: {} },
queries: metricQuery({
metricName: 'signoz_calls_total',
requestType: RequestType.time_series,
groupBy: 'service.name',
legend: '{{service.name}}',
}),
},
},
'error-rate': {
kind: PanelKind.Panel,
spec: {
display: {
name: 'Error rate',
description: 'Share of 5xx responses over the selected window.',
},
plugin: { kind: TimeSeriesKind['signoz/TimeSeriesPanel'], spec: {} },
queries: metricQuery({
metricName: 'signoz_errors_total',
requestType: RequestType.time_series,
legend: 'errors / sec',
}),
},
},
'p99-latency': {
kind: PanelKind.Panel,
spec: {
display: { name: 'p99 latency' },
plugin: { kind: NumberKind['signoz/NumberPanel'], spec: {} },
queries: metricQuery({
metricName: 'signoz_latency_bucket',
requestType: RequestType.scalar,
}),
},
},
apdex: {
kind: PanelKind.Panel,
spec: {
display: { name: 'Apdex' },
plugin: { kind: NumberKind['signoz/NumberPanel'], spec: {} },
queries: metricQuery({
metricName: 'signoz_apdex',
requestType: RequestType.scalar,
}),
},
},
'top-endpoints': {
kind: PanelKind.Panel,
spec: {
display: { name: 'Top endpoints' },
plugin: { kind: TableKind['signoz/TablePanel'], spec: {} },
queries: metricQuery({
metricName: 'signoz_calls_total',
requestType: RequestType.scalar,
groupBy: 'http.route',
}),
},
},
'errors-by-status': {
kind: PanelKind.Panel,
spec: {
display: { name: 'Errors by status code' },
plugin: { kind: BarChartKind['signoz/BarChartPanel'], spec: {} },
queries: metricQuery({
metricName: 'signoz_errors_total',
requestType: RequestType.time_series,
groupBy: 'http.status_code',
legend: '{{http.status_code}}',
}),
},
},
'traffic-share': {
kind: PanelKind.Panel,
spec: {
display: { name: 'Traffic share' },
plugin: { kind: PieChartKind['signoz/PieChartPanel'], spec: {} },
queries: metricQuery({
metricName: 'signoz_calls_total',
requestType: RequestType.scalar,
groupBy: 'service.name',
}),
},
},
'recent-logs': {
kind: PanelKind.Panel,
spec: {
display: { name: 'Recent logs' },
plugin: { kind: ListKind['signoz/ListPanel'], spec: {} },
queries: logQuery(),
},
},
};
interface SectionSeed {
title: string;
panels: PanelId[];
}
const SECTIONS: SectionSeed[] = [
{
title: 'Golden signals',
panels: ['p99-latency', 'apdex', 'error-rate', 'request-rate'],
},
{
title: 'Breakdown',
panels: ['top-endpoints', 'errors-by-status', 'traffic-share', 'recent-logs'],
},
];
/** Half-width for the charts, quarter-width for the two single numbers. */
const PANEL_WIDTH: Partial<Record<PanelId, number>> = {
'p99-latency': 3,
apdex: 3,
'request-rate': 12,
};
const gridItems = (
panels: PanelId[],
): NonNullable<DashboardtypesLayoutDTO['spec']['items']> => {
let x = 0;
let y = 0;
return panels.map((id) => {
const width = PANEL_WIDTH[id] ?? 6;
if (x + width > 12) {
x = 0;
y += 6;
}
const item = {
x,
y,
width,
height: 6,
content: { $ref: `#/spec/panels/${id}` },
};
x += width;
return item;
});
};
export const VARIABLE_KINDS = ['custom', 'query', 'dynamic', 'text'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
export const QUERY_VARIABLE_NAME = 'service';
export const DYNAMIC_VARIABLE_ATTRIBUTE = 'k8s.namespace.name';
const VARIABLES: Record<VariableKind, DashboardtypesVariableDTO> = {
custom: {
kind: ListVariableKind.ListVariable,
spec: {
name: 'environment',
display: { name: 'environment' },
allowMultiple: false,
allowAllValue: false,
defaultValue: 'production',
plugin: {
kind: CustomVariableKind['signoz/CustomVariable'],
spec: { customValue: 'production,staging,development' },
},
},
},
query: {
kind: ListVariableKind.ListVariable,
spec: {
name: QUERY_VARIABLE_NAME,
display: { name: QUERY_VARIABLE_NAME },
allowMultiple: true,
allowAllValue: true,
plugin: {
kind: QueryVariableKind['signoz/QueryVariable'],
spec: {
queryValue:
"SELECT DISTINCT service_name FROM signoz_metrics WHERE env = '$environment'",
},
},
},
},
dynamic: {
kind: ListVariableKind.ListVariable,
spec: {
name: 'namespace',
display: { name: 'namespace' },
allowMultiple: true,
allowAllValue: true,
plugin: {
kind: DynamicVariableKind['signoz/DynamicVariable'],
spec: {
name: DYNAMIC_VARIABLE_ATTRIBUTE,
signal: DynamicSignal.metrics,
},
},
},
},
text: {
kind: TextVariableKind.TextVariable,
spec: {
name: 'owner',
display: { name: 'owner' },
value: 'platform-team',
constant: false,
},
},
};
export interface PanelQueryShape {
requestType: string;
metricName?: string;
groupBy?: string;
}
/**
* What a panel asks `query_range` for, read back off the panel itself. The
* public viewer addresses a panel by key rather than by request body, so it
* needs the same answer without a request to inspect.
*/
export const panelQueryShape = (id: PanelId): PanelQueryShape => {
const query = PANELS[id].spec.queries[0];
const spec = query.spec.plugin.spec as {
aggregations?: { metricName?: string }[];
groupBy?: { name?: string }[];
};
return {
requestType: query.kind,
metricName: spec.aggregations?.[0]?.metricName,
groupBy: spec.groupBy?.[0]?.name,
};
};
export interface DashboardArgs {
/** Panels kept, taken off the front of `PANEL_IDS`. Zero is a blank dashboard. */
panels: number;
/** Titled sections, or the untitled single grid a dashboard without them renders. */
sectioned: boolean;
variables: readonly VariableKind[];
locked: boolean;
}
export const dashboardResponse = ({
panels,
sectioned,
variables,
locked,
}: DashboardArgs): GetDashboardV2200 => {
const kept = PANEL_IDS.slice(0, panels);
const layouts: DashboardtypesLayoutDTO[] = sectioned
? SECTIONS.map((section) => ({
kind: GridKind.Grid,
spec: {
display: { title: section.title, collapse: { open: true } },
items: gridItems(section.panels.filter((id) => kept.includes(id))),
},
}))
: [{ kind: GridKind.Grid, spec: { items: gridItems([...kept]) } }];
return {
status: 'success',
data: {
id: STORY_DASHBOARD_ID,
orgId: 'storybook-org',
name: 'Checkout service overview',
image: '/assets/Icons/circus-tent',
schemaVersion: 'v6',
source: DashboardtypesSourceDTO.user,
locked,
createdBy: 'ada@signoz.io',
updatedBy: 'ada@signoz.io',
createdAt: '2026-05-04T09:12:00Z',
updatedAt: '2026-08-21T16:40:00Z',
tags: [
{ key: 'env', value: 'prod' },
{ key: 'team', value: 'platform' },
],
spec: {
display: {
name: 'Checkout service overview',
description:
'Traffic, errors and latency for the checkout path, broken down by service.',
},
layouts,
panels: Object.fromEntries(
kept.map((id) => [id, PANELS[id]]),
) as DashboardtypesDashboardSpecDTOPanels,
variables: variables.map((kind) => VARIABLES[kind]),
},
},
};
};
/**
* The document the page is editing. Every spec edit (a panel moved, a section
* renamed, a variable added) travels as a JSON Patch whose response replaces
* the cache, so the story keeps the document where the handler can apply the ops
* to it. Reseeded whenever a control changes, which is also when the story
* remounts.
*/
let document: DashboardtypesGettableDashboardV2DTO | undefined;
export const seedDashboardDocument = (args: DashboardArgs): void => {
document = dashboardResponse(args).data;
};
const envelope = (
data: DashboardtypesGettableDashboardV2DTO,
): GetDashboardV2200 => ({ status: 'success', data });
export const currentDashboardDocument = (
args: DashboardArgs,
): GetDashboardV2200 => envelope(document ?? dashboardResponse(args).data);
export const patchDashboardDocument = (
args: DashboardArgs,
ops: DashboardtypesJSONPatchOperationDTO[],
): GetDashboardV2200 => {
document = applyJsonPatch(document ?? dashboardResponse(args).data, ops);
return envelope(document);
};

View File

@@ -0,0 +1,175 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { MetricRangePayloadV5, RawRow } from 'types/api/v5/queryRange';
import {
queryRangeV5EmptyResponse,
queryRangeV5RawResponse,
queryRangeV5ScalarResponse,
queryRangeV5ScalarTableResponse,
queryRangeV5TimeSeriesResponse,
timeSeriesPoints,
} from '@/storybook/msw/__story_mockdata__/queryRange';
const SERVICES = [
'checkout',
'payments',
'inventory',
'notifications',
] as const;
const ROUTES = [
'POST /v1/checkout',
'GET /v1/cart',
'POST /v1/payment/authorize',
'GET /v1/inventory/:sku',
'POST /v1/notifications/send',
] as const;
const STATUS_CODES = ['500', '502', '503'] as const;
const LOG_LEVELS = ['INFO', 'WARN', 'ERROR'] as const;
const pick = <T>(values: readonly T[], index: number): T =>
values[index % values.length];
export interface PanelWindow {
start: number;
end: number;
}
/** One line per service, each with its own phase so the chart reads as a stack. */
const seriesByLabel = (
{ start, end }: PanelWindow,
label: string,
values: readonly string[],
base: number,
amplitude: number,
): MetricRangePayloadV5 =>
queryRangeV5TimeSeriesResponse([
{
queryName: 'A',
series: values.map((value, index) => ({
labels: [{ key: { name: label }, value }],
values: timeSeriesPoints({
start,
end,
base: base - index * (base / (values.length + 2)),
amplitude,
seed: index * 3,
}),
})),
},
]);
const singleSeries = ({ start, end }: PanelWindow): MetricRangePayloadV5 =>
queryRangeV5TimeSeriesResponse([
{
queryName: 'A',
series: [
{
labels: [],
values: timeSeriesPoints({ start, end, base: 2.4, amplitude: 0.9 }),
},
],
},
]);
const logRows = ({ start, end }: PanelWindow, count: number): RawRow[] =>
Array.from({ length: count }, (_unused, index) => {
const severity = pick(LOG_LEVELS, index);
const service = pick(SERVICES, index);
return {
timestamp: new Date(
end - ((end - start) / Math.max(count, 1)) * index,
).toISOString(),
data: {
id: `storybook-log-${index + 1}`,
body: `${severity} ${service} completed ${pick(ROUTES, index)} in ${
8 + index * 3
}ms`,
severity_text: severity,
resources_string: { 'service.name': service },
},
};
});
/**
* Which answer a panel's request gets. Every panel names its query `A`, so the
* request itself is what tells them apart: the request type, the group-by it
* asks for, and the metric it aggregates.
*/
export interface PanelRequest {
requestType: string;
groupBy?: string;
metricName?: string;
window: PanelWindow;
}
const LOG_ROW_COUNT = 25;
export const panelResponse = ({
requestType,
groupBy,
metricName,
window,
}: PanelRequest): MetricRangePayloadV5 => {
if (requestType === 'raw' || requestType === 'trace') {
return queryRangeV5RawResponse(logRows(window, LOG_ROW_COUNT));
}
if (requestType === 'scalar') {
if (groupBy === 'http.route') {
return queryRangeV5ScalarTableResponse({
groupBy: ['http.route'],
aggregations: ['A'],
rows: ROUTES.map((route, index) => [route, 4200 - index * 630]),
});
}
if (groupBy) {
return queryRangeV5ScalarTableResponse({
groupBy: [groupBy],
aggregations: ['A'],
rows: SERVICES.map((service, index) => [service, 3800 - index * 720]),
});
}
return queryRangeV5ScalarResponse(
metricName === 'signoz_apdex' ? 0.94 : 812.6,
);
}
if (groupBy === 'http.status_code') {
return seriesByLabel(window, groupBy, STATUS_CODES, 18, 6);
}
if (groupBy) {
return seriesByLabel(window, groupBy, SERVICES, 240, 55);
}
return singleSeries(window);
};
/** What a panel shows when the query runs but matches nothing. */
export const emptyPanelResponse = (): MetricRangePayloadV5 =>
queryRangeV5EmptyResponse();
/** Values the `service` query variable offers. */
export const serviceVariableValues = (count: number): string[] =>
Array.from({ length: count }, (_unused, index) =>
index < SERVICES.length
? SERVICES[index]
: `${pick(SERVICES, index)}-${Math.floor(index / SERVICES.length) + 1}`,
);
/** Values the dynamic `namespace` variable resolves from the fields endpoint. */
export const NAMESPACE_VALUES = [
'checkout-prod',
'payments-prod',
'platform-prod',
] as const;

View File

@@ -0,0 +1,165 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind as TimeSeriesKind,
DashboardtypesTimePreferenceDTO as TimePreference,
type DashboardtypesDashboardSpecDTOPanels,
type DashboardtypesGettableDashboardV2DTO,
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { dashboardResponse, PANEL_IDS, VARIABLE_KINDS } from './dashboard';
export const TOOLTIP_DASHBOARD_NAME =
'Checkout service overview across every production region, by service and owner';
export const TOOLTIP_DASHBOARD_DESCRIPTION =
'Traffic, errors and latency for the checkout path, broken down by service, region and deployment channel. Owned by the platform observability team; the runbook is at https://signoz.io/docs/dashboards/ and the rotation is in PagerDuty.';
// Two fit beside the title; the rest fall behind the `+N` badge.
export const TOOLTIP_DASHBOARD_TAGS = [
{ key: 'env', value: 'production-eu-central-1' },
{ key: 'team', value: 'platform-observability' },
{ key: 'component', value: 'otel-collector' },
{ key: 'owner', value: 'sre-oncall-primary' },
{ key: 'tier', value: 'tier-0-revenue-critical' },
{ key: 'compliance', value: 'soc2-in-scope' },
];
export const TOOLTIP_PANEL_NAME =
'Request rate by service, region and deployment channel, excluding synthetic traffic';
export const TOOLTIP_PANEL_DESCRIPTIONS: Record<string, string> = {
'request-rate':
'Requests per second per service, taken from `signoz_calls_total` and filtered to the selected environment. Synthetic and health-check traffic is excluded, so this reads lower than the load balancer count.',
'error-rate':
'Share of 5xx responses over the selected window, rated against the error budget for the quarter.',
};
export const TOOLTIP_SELECTED_SERVICES = [
'checkout',
'payments',
'inventory',
'notifications',
'checkout-2',
'payments-2',
'inventory-2',
'notifications-2',
];
export const TOOLTIP_WARNED_METRIC = 'signoz_apdex';
/**
* The page's own document, rewritten to the lengths the fixture is too tame to
* show: a title and a description that overflow, six tags, panel descriptions
* that run past a line, and a panel on its own time preference.
*/
export const tooltipDashboardDocument =
(): DashboardtypesGettableDashboardV2DTO => {
const document = dashboardResponse({
panels: PANEL_IDS.length,
sectioned: true,
variables: [...VARIABLE_KINDS],
locked: false,
}).data;
const panels = Object.fromEntries(
Object.entries(document.spec.panels ?? {}).map(([id, panel]) => [
id,
{
...panel,
spec: {
...panel.spec,
display: {
...panel.spec.display,
name:
id === 'request-rate' ? TOOLTIP_PANEL_NAME : panel.spec.display.name,
description:
TOOLTIP_PANEL_DESCRIPTIONS[id] ?? panel.spec.display.description,
},
plugin:
id === 'error-rate' &&
panel.spec.plugin.kind === TimeSeriesKind['signoz/TimeSeriesPanel']
? {
...panel.spec.plugin,
spec: {
...panel.spec.plugin.spec,
visualization: { timePreference: TimePreference.last_1_month },
},
}
: panel.spec.plugin,
},
},
]),
) as DashboardtypesDashboardSpecDTOPanels;
return {
...document,
name: TOOLTIP_DASHBOARD_NAME,
tags: TOOLTIP_DASHBOARD_TAGS,
spec: {
...document.spec,
display: {
name: TOOLTIP_DASHBOARD_NAME,
description: TOOLTIP_DASHBOARD_DESCRIPTION,
},
panels,
},
};
};
export const tooltipDashboardResponse = (): GetDashboardV2200 => ({
status: 'success',
data: tooltipDashboardDocument(),
});
/**
* The tooltip document with its layouts scrambled, the shape the JSON editor's
* dangling-reference warning is built to catch.
*/
export const desyncedDashboardResponse = (): GetDashboardV2200 => {
const document = tooltipDashboardDocument();
const [firstGrid, ...rest] = document.spec.layouts ?? [];
return {
status: 'success',
data: {
...document,
spec: {
...document.spec,
// The second grid goes, orphaning the panels it placed, and the
// first gains slots for panels that are not in the spec: the two
// ways a hand-edited document desyncs panels and layouts.
layouts: [
{
...firstGrid,
spec: {
...firstGrid.spec,
items: [
...(firstGrid.spec?.items ?? []),
{
x: 0,
y: 24,
width: 6,
height: 6,
content: { $ref: '#/spec/panels/checkout-saturation' },
},
{
x: 6,
y: 24,
width: 6,
height: 6,
content: { $ref: '#/spec/panels/payment-gateway-latency' },
},
],
},
},
...rest.slice(1),
],
},
},
};
};

View File

@@ -0,0 +1,344 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import {
choiceControl,
countControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { dashboardResponse } from '../../DashboardPage/stories/__story_mockdata__/dashboard';
import {
dashboardIdAt,
dashboardViewsResponse,
dashboardsListResponse,
orgUsersResponse,
recentDashboardIds,
ROW_MARKERS,
savedView,
seedPinnedDashboards,
setDashboardPinned,
STORY_USER_EMAIL,
TOOLTIP_TAGS,
type RowMarker,
} from './__story_mockdata__/dashboardsList';
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
import {
type DashboardDynamicColumns,
useDashboardsListVisibleColumnsStore,
} from '../store/useVisibleColumnsStore';
import { BuiltinViewId } from '../types';
import { builtinViewQuery } from '../utils/views';
const LIST = 'Dashboards · list';
const VIEWS = 'Dashboards · views';
const VIEWS_OPTIONS = [
BuiltinViewId.All,
BuiltinViewId.Mine,
BuiltinViewId.Pinned,
BuiltinViewId.Recent,
BuiltinViewId.Locked,
'saved',
] as const;
type ViewOption = (typeof VIEWS_OPTIONS)[number];
const DETAIL_COLUMNS = ['updatedAt', 'updatedBy'] as const;
type DetailColumn = (typeof DETAIL_COLUMNS)[number];
const RECENT_COUNT = 4;
const PINNED_COUNT = 3;
/** A dashboard document, for the writes that echo the touched dashboard back. */
const writtenDashboard = (): GetDashboardV2200 =>
dashboardResponse({
panels: 0,
sectioned: false,
variables: [],
locked: false,
});
const ok = { status: 'success', data: null };
/**
* `formatQueryErrorMessage` strips the `invalid filter query:` prefix and turns
* the backticks into quotes, so the message carries both to show it doing it.
*/
const INVALID_QUERY_MESSAGE =
'invalid filter query: unexpected token `enviroment` at position 0, expected one of `name`, `description`, `created_by`, `created_at`, `updated_at`, `locked`';
/**
* The rail applies a view by writing both `view` and `query`, so a story that
* opens on one has to seed both or the header shows unsaved changes on mount.
*/
const listRoute = (view: ViewOption): string => {
const { id, query } =
view === 'saved'
? savedView(0)
: { id: view, query: builtinViewQuery(view, STORY_USER_EMAIL) ?? '' };
const params = new URLSearchParams({ view: id });
if (query) {
params.set('query', query);
}
return `${ROUTES.ALL_DASHBOARD}?${params.toString()}`;
};
const visibleColumns = (
columns: readonly DetailColumn[],
): DashboardDynamicColumns => ({
createdAt: true,
createdBy: true,
updatedAt: columns.includes('updatedAt'),
updatedBy: columns.includes('updatedBy'),
});
/**
* A row shows the full-name tooltip only past 50 characters of title and the
* overflow chip only past three tags, and the page's own rows are under both.
* This answers the list with rows over both instead, which is why the Dashboards
* and Row markers controls do not reach this story.
*/
export const overflowingRows = rest.get(
'http://localhost/api/v2/users/me/dashboards',
(_req, res, ctx) => {
const list = dashboardsListResponse({
count: 6,
offset: 0,
limit: 20,
markers: [...ROW_MARKERS],
query: '',
});
return res(
ctx.status(200),
ctx.json({
...list,
data: {
...list.data,
dashboards: list.data.dashboards.map((dashboard) => {
const name = `${dashboard.name} across every production region, rolled up by service and owner`;
// The row reads `spec.display.name`, not `name`.
return {
...dashboard,
name,
spec: { ...dashboard.spec, display: { name } },
tags: TOOLTIP_TAGS,
};
}),
},
}),
);
},
);
export const dashboardsListMocks = defineStoryMocks({
controls: {
dashboards: countControl('Dashboards', {
group: LIST,
description:
'Dashboards the org has. The list pages at 20, so a higher count adds a pager.',
value: 24,
max: 45,
}),
markers: multiChoiceControl<RowMarker>('Row markers', {
group: LIST,
description:
'Pinned rows float to the top, locked rows carry the padlock, and a legacy row opens the "not available in the new experience" dialog instead of the dashboard.',
options: ROW_MARKERS,
value: [...ROW_MARKERS],
}),
columns: multiChoiceControl<DetailColumn>('Detail columns', {
group: LIST,
description: 'The optional fields on each rows second line.',
options: DETAIL_COLUMNS,
value: [...DETAIL_COLUMNS],
}),
invalidQuery: toggleControl('Reject the query', {
group: LIST,
description:
'Answers the list with a 400 and a parse error, which is the Invalid query state: the backend message replaces the generic one and Retry is gone.',
value: false,
}),
view: choiceControl<ViewOption>('Active view', {
group: VIEWS,
description:
'The rail entry the page opens on. Pinned and Recently viewed constrain the fetched rows client-side; the rest apply a query.',
options: VIEWS_OPTIONS,
value: BuiltinViewId.All,
}),
savedViews: countControl('Saved views', {
group: VIEWS,
description: 'Org-shared views listed under the built-in ones.',
value: 3,
max: 6,
}),
},
handlers: (values, response) => [
...(values.invalidQuery
? [
rest.get('http://localhost/api/v2/users/me/dashboards', (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
status: 'error',
error: {
code: 'invalid_input',
message: INVALID_QUERY_MESSAGE,
url: '',
errors: [],
},
}),
),
),
]
: []),
rest.get(
'http://localhost/api/v2/users/me/dashboards',
response.json((req) =>
dashboardsListResponse({
count: values.dashboards,
offset: Number(req.url.searchParams.get('offset') ?? 0),
limit: Number(req.url.searchParams.get('limit') ?? 20),
markers: values.markers,
query: req.url.searchParams.get('query') ?? '',
}),
),
),
rest.get(
'http://localhost/api/v2/dashboard_views',
response.json(() => dashboardViewsResponse(values.savedViews)),
),
rest.get(
'http://localhost/api/v2/users',
response.json(() => orgUsersResponse()),
),
// The writes the rows and the rail offer. Pinning is the one the page can
// see the result of, so it is kept where the handler can write it; the rest
// answer with success and the list re-reads the controls.
rest.put(
'http://localhost/api/v2/users/me/dashboards/:id/pins',
(req, res, ctx) => {
setDashboardPinned(String(req.params.id), true);
return res(ctx.status(200), ctx.json(ok));
},
),
rest.delete(
'http://localhost/api/v2/users/me/dashboards/:id/pins',
(req, res, ctx) => {
setDashboardPinned(String(req.params.id), false);
return res(ctx.status(200), ctx.json(ok));
},
),
rest.post('http://localhost/api/v2/dashboards', (_req, res, ctx) =>
res(ctx.status(201), ctx.json(writtenDashboard())),
),
rest.put('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
),
rest.post('http://localhost/api/v2/dashboards/:id/clone', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
),
rest.post(
'http://localhost/api/v2/dashboards/:id/migrate',
(_req, res, ctx) => res(ctx.status(200), ctx.json(writtenDashboard())),
),
rest.delete('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
rest.put('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
rest.delete('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
rest.post(
'http://localhost/api/v2/dashboard_views',
async (req, res, ctx) => {
const body = (await req.json()) as { name: string };
return res(
ctx.status(201),
ctx.json({
status: 'success',
data: {
id: 'storybook-view-created',
orgId: 'storybook-org',
name: body.name,
data: { version: 'v1' },
},
}),
);
},
),
rest.put(
'http://localhost/api/v2/dashboard_views/:id',
async (req, res, ctx) => {
const body = (await req.json()) as { name: string; data: unknown };
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: String(req.params.id),
orgId: 'storybook-org',
name: body.name,
data: body.data,
},
}),
);
},
),
rest.delete('http://localhost/api/v2/dashboard_views/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
],
config: (values) => ({ route: listRoute(values.view) }),
effect: (values) => {
seedPinnedDashboards(
values.markers.includes('pinned')
? Array.from({ length: PINNED_COUNT }, (_unused, index) =>
dashboardIdAt(index),
)
: [],
);
useDashboardViewsStore.setState({
recent: recentDashboardIds(RECENT_COUNT),
});
useDashboardsListVisibleColumnsStore.setState({
visibleColumns: visibleColumns(values.columns),
});
},
});

View File

@@ -0,0 +1,129 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, screen, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardsListMocks,
overflowingRows,
} from './DashboardsListPage.stories.mocks';
import { BuiltinViewId } from '../types';
import DashboardsListPage from '../DashboardsListPage';
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
const pageStory = storyMocks(dashboardsListMocks, { layout: 'app' });
/**
* Every dashboard in the workspace, with pins, the saved views over the list, and
* the create, clone and lock actions. Creating follows the legacy editor role.
*
* Route: `/dashboard`.
*/
const meta = {
title: 'Pages/Dashboards/List',
tags: ['role-gated', 'play'],
component: DashboardsListPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardsListArgs>;
export default meta;
type Story = StoryObj<DashboardsListArgs>;
/**
* All dashboards: the views rail on the left, the query box and the Created-by
* and Updated dropdowns above the rows, pinned dashboards first, and a pager
* because the org has more than one page of them.
*/
export const Default: Story = {};
/**
* An org-shared saved view applied on load, so the rail entry is selected and
* its query is in the box.
*/
export const SavedView: Story = {
args: { view: 'saved' },
};
/** What a new workspace shows: the create-your-first-dashboard call to action. */
export const EmptyWorkspace: Story = {
args: { dashboards: 0, savedViews: 0 },
};
/**
* A viewer: the rows and the rail are still browsable, but everything that
* writes (New dashboard, saving a view, the row's edit actions) is gone.
*/
export const Viewer: Story = {
args: { access: 'viewer' },
};
/** The rows the user pinned, which the page filters out of the fetched page. */
export const Pinned: Story = {
args: { view: BuiltinViewId.Pinned },
};
/** The template tab of the New dashboard dialog. */
export const NewDashboardTemplateTab: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await expect(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
).toHaveTextContent('New dashboard');
await userEvent.click(await screen.findByText('From a template'));
await screen.findByText('Dashboard templates');
},
};
/**
* Invalid JSON keeps the import dialog open behind the error the app raises for
* it: the parse failure is handed to the API error modal, so it reads
* `UPSTREAM_UNAVAILABLE` over the panel's own inline feedback. See BUGS.md 53.
*/
export const NewDashboardImportJsonInvalid: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await userEvent.click(await screen.findByText('Import JSON'));
await userEvent.click(await screen.findByTestId('import-json-submit'));
await screen.findByText(/error loading json/i);
// The error modal and the toast under it carry the same message.
await screen.findAllByText('Unexpected end of JSON input');
},
};
/**
* Every tooltip a row carries, held open at once: the full name a truncated
* title falls back to, the padlock, the pin, the legacy row's refusal to be
* pinned, and the overflow chip listing the tags that did not fit.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
parameters: { msw: { handlers: [overflowingRows] } },
};
/**
* The query the backend refused: the parse error it returned replaces the
* generic failure copy, and there is nothing to retry.
*
* Kept last: test-runner shares one page across a file's stories, and the 400
* this story is about can settle after the next story has already started,
* which fails that one instead.
*/
export const InvalidQuery: Story = {
args: { invalidQuery: true },
// The deliberate 400 is the state under test.
parameters: { allowConsoleErrors: true },
};

View File

@@ -0,0 +1,348 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
DashboardtypesSourceDTO,
type DashboardtypesListedDashboardForUserV2DTO,
type ListDashboardViews200,
type ListDashboardsForUserV2200,
type ListUsers200,
type TagtypesGettableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import { USER_ROLES } from 'types/roles';
/**
* "My dashboards" matches on the signed-in address, so the rows have to be
* created by the same user the providers mount.
*/
export const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user
.email;
const TEAMMATE_EMAILS = [
'ada@signoz.io',
'grace@signoz.io',
'linus@signoz.io',
] as const;
const HOUR = 60 * 60 * 1000;
const ago = (hours: number): string =>
new Date(Date.now() - hours * HOUR).toISOString();
const tag = (key: string, value: string): TagtypesGettableTagDTO => ({
key,
value,
});
interface DashboardSeed {
name: string;
icon: string;
tags: TagtypesGettableTagDTO[];
locked?: boolean;
}
const SEEDS: DashboardSeed[] = [
{
name: 'Kubernetes cluster health',
icon: 'circus-tent',
tags: [tag('env', 'prod'), tag('team', 'platform')],
locked: true,
},
{
name: 'API latency and errors',
icon: 'siren',
tags: [tag('env', 'prod'), tag('team', 'api')],
},
{
name: 'Checkout funnel',
icon: 'bagel',
tags: [tag('team', 'growth')],
},
{
name: 'Postgres connections',
icon: 'cheese',
tags: [tag('env', 'prod'), tag('component', 'database')],
},
{
name: 'Kafka consumer lag',
icon: 'drum',
tags: [tag('team', 'platform'), tag('component', 'kafka')],
},
{
name: 'Nginx ingress overview',
icon: 'crane',
tags: [tag('env', 'staging')],
},
{
name: 'Billing jobs',
icon: 'dartboard',
tags: [tag('team', 'billing')],
locked: true,
},
{
name: 'Frontend web vitals',
icon: 'basketball',
tags: [tag('team', 'frontend')],
},
{
name: 'Redis cache hit ratio',
icon: 'cookie',
tags: [tag('component', 'redis')],
},
{
name: 'Collector pipeline throughput',
icon: 'motorcycle',
tags: [tag('env', 'prod'), tag('component', 'otel')],
},
{
name: 'On-call triage board',
icon: 'police-car',
tags: [tag('team', 'sre')],
},
{
name: 'Cost per service',
icon: 'orange',
tags: [tag('team', 'finops')],
},
];
/** Row markers a story can put on the list, each landing on a slice of the rows. */
export const ROW_MARKERS = ['pinned', 'locked', 'legacy'] as const;
export type RowMarker = (typeof ROW_MARKERS)[number];
/** Tags on every row of the overflowing-rows fixture, six deep past the chip's cutoff. */
export const TOOLTIP_TAGS: TagtypesGettableTagDTO[] = [
tag('env', 'production-eu-central-1'),
tag('team', 'platform-observability'),
tag('component', 'otel-collector'),
tag('owner', 'sre-oncall-primary'),
tag('tier', 'tier-0-revenue-critical'),
tag('compliance', 'soc2-in-scope'),
];
export const dashboardIdAt = (index: number): string =>
`storybook-dashboard-${index + 1}`;
/**
* Pins are per-user state the endpoint owns, and the page writes them: keeping
* them here is what lets the pin button stick instead of being answered away by
* the next list fetch.
*/
const pinned = new Set<string>();
export const seedPinnedDashboards = (ids: readonly string[]): void => {
pinned.clear();
ids.forEach((id) => pinned.add(id));
};
export const setDashboardPinned = (id: string, isPinned: boolean): void => {
if (isPinned) {
pinned.add(id);
} else {
pinned.delete(id);
}
};
interface ListArgs {
count: number;
offset: number;
limit: number;
markers: readonly RowMarker[];
query: string;
}
const seedAt = (index: number): DashboardSeed => SEEDS[index % SEEDS.length];
const nameAt = (index: number): string => {
const seed = seedAt(index);
const round = Math.floor(index / SEEDS.length);
return round === 0 ? seed.name : `${seed.name} (${round + 1})`;
};
const dashboardAt = (
index: number,
markers: readonly RowMarker[],
): DashboardtypesListedDashboardForUserV2DTO => {
const seed = seedAt(index);
const name = nameAt(index);
const mine = index % 3 === 0;
return {
id: dashboardIdAt(index),
orgId: 'storybook-org',
name,
spec: { display: { name } },
image: `/assets/Icons/${seed.icon}`,
schemaVersion: 'v2',
source: DashboardtypesSourceDTO.user,
pinned: pinned.has(dashboardIdAt(index)),
locked: markers.includes('locked') && !!seed.locked,
legacy: markers.includes('legacy') && index % 7 === 4,
tags: seed.tags,
createdBy: mine
? STORY_USER_EMAIL
: TEAMMATE_EMAILS[index % TEAMMATE_EMAILS.length],
updatedBy: TEAMMATE_EMAILS[(index + 1) % TEAMMATE_EMAILS.length],
createdAt: ago(24 * (index + 3)),
updatedAt: ago(index * 5 + 1),
};
};
/** `key OP value`, the only term shape the mock evaluates. */
const TERM = /(\w+)\s*(=|!=|CONTAINS|IN)\s*(\[[^\]]*\]|'[^']*'|true|false)/gi;
const quoted = (raw: string): string[] =>
Array.from(raw.matchAll(/'([^']*)'/g), (match) => match[1]);
const matchesTerm = (
dashboard: DashboardtypesListedDashboardForUserV2DTO,
key: string,
operator: string,
value: string,
): boolean => {
const values = quoted(value);
const [first = ''] = values;
switch (key.toLowerCase()) {
case 'locked':
return dashboard.locked === (value.toLowerCase() === 'true');
case 'created_by':
return values.includes(dashboard.createdBy ?? '');
case 'updated_by':
return values.includes(dashboard.updatedBy ?? '');
case 'name':
return operator.toUpperCase() === 'CONTAINS'
? dashboard.name.toLowerCase().includes(first.toLowerCase())
: dashboard.name === first;
case 'created_at':
case 'updated_at':
case 'description':
return true;
default:
return dashboard.tags.some((t) => t.key === key && values.includes(t.value));
}
};
/**
* The AND-joined subset of the list DSL the built-in views, the saved views and
* the Created-by dropdown emit. A term the mock cannot read is treated as
* matching, so an unsupported query answers with the unfiltered page rather than
* an empty one.
*/
const matchesQuery = (
dashboard: DashboardtypesListedDashboardForUserV2DTO,
query: string,
): boolean =>
Array.from(query.matchAll(TERM)).every(([, key, operator, value]) =>
matchesTerm(dashboard, key, operator, value),
);
export const dashboardsListResponse = ({
count,
offset,
limit,
markers,
query,
}: ListArgs): ListDashboardsForUserV2200 => {
const all = Array.from({ length: count }, (_, index) =>
dashboardAt(index, markers),
);
const matched = query ? all.filter((d) => matchesQuery(d, query)) : all;
// Pins float to the top of the requested ordering, server-side.
matched.sort((a, b) => Number(b.pinned) - Number(a.pinned));
const tags = matched.flatMap((dashboard) => dashboard.tags);
const uniqueTags = Array.from(
new Map(tags.map((t) => [`${t.key}:${t.value}`, t])).values(),
);
return {
status: 'success',
data: {
total: matched.length,
reservedKeywords: [
'name',
'description',
'created_by',
'created_at',
'updated_at',
'locked',
],
tags: uniqueTags,
dashboards: matched.slice(offset, offset + limit),
},
};
};
const SAVED_VIEW_SEEDS = [
{ name: 'Production dashboards', query: "env = 'prod'" },
{ name: 'Platform team', query: "team = 'platform'" },
{ name: 'Locked dashboards', query: 'locked = true' },
{ name: 'Database dashboards', query: "component = 'database'" },
] as const;
/**
* A saved view as the rail addresses it. Selecting one applies its query, so a
* story that opens on a saved view has to seed the route with both.
*/
export const savedView = (index: number): { id: string; query: string } => ({
id: `storybook-view-${index + 1}`,
query: SAVED_VIEW_SEEDS[index % SAVED_VIEW_SEEDS.length].query,
});
export const dashboardViewsResponse = (
count: number,
): ListDashboardViews200 => ({
status: 'success',
data: {
views: Array.from({ length: count }, (_, index) => {
const seed = SAVED_VIEW_SEEDS[index % SAVED_VIEW_SEEDS.length];
return {
id: savedView(index).id,
orgId: 'storybook-org',
name: index < SAVED_VIEW_SEEDS.length ? seed.name : `${seed.name} ${index}`,
data: {
version: 'v1',
query: seed.query,
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
},
createdAt: ago(24 * (index + 1)),
updatedAt: ago(index + 1),
};
}),
},
});
/** The org's users, which is where the Created-by dropdown gets its options. */
export const orgUsersResponse = (): ListUsers200 => ({
status: 'success',
data: [
{
id: 'storybook-user-me',
email: STORY_USER_EMAIL,
displayName: 'John Doe',
},
...TEAMMATE_EMAILS.map((email, index) => ({
id: `storybook-user-${index + 1}`,
email,
displayName: email.split('@')[0].replace(/^./, (c) => c.toUpperCase()),
})),
],
});
/** Ids the Recently-viewed rail entry reads out of local state. */
export const recentDashboardIds = (count: number): string[] =>
Array.from(
{ length: count },
(_, index) => `storybook-dashboard-${index + 4}`,
);

View File

@@ -0,0 +1,122 @@
/**
* 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 { 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 {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
CHANNEL_MAX,
type AlertSchema,
} from '../../AlertList/stories/__story_mockdata__/alerts';
const STORY_RULE_ID = 'rule-1';
const RULE = 'Edit rule · rule';
export const editRulesMocks = defineStoryMocks({
controls: {
alertSchema: choiceControl<AlertSchema>('Alert schema', {
group: RULE,
description:
'`classic` is the single-form page this route was built for. `v2` throws: the new form reads `CreateAlertProvider`, which only `pages/AlertDetails` mounts, so this route crashes on any rule saved on the current schema.',
options: ALERT_SCHEMAS,
value: 'classic',
}),
channels: countControl('Notification channels', {
group: RULE,
value: 5,
max: CHANNEL_MAX,
}),
previewSeries: countControl('Preview series', {
group: RULE,
description: 'Lines the chart above the condition draws.',
value: 3,
max: 6,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/rules/:id',
response.json((req) =>
alertRuleByIdResponse(String(req.params.id), {
severity: 'critical',
state: 'firing',
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.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: `${ROUTES.EDIT_ALERTS}?ruleId=${STORY_RULE_ID}&relativeTime=6h`,
}),
});

View File

@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { editRulesMocks } from './EditRules.stories.mocks';
import EditRules from '../index';
type EditRulesArgs = PageStoryArgs<typeof editRulesMocks>;
const pageStory = storyMocks(editRulesMocks, { layout: 'app' });
/**
* An existing rule in the builder that created it, loaded from
* `/api/v2/rules/:id`.
*
* Route: `/alerts/edit?ruleId=...`.
*/
const meta = {
title: 'Pages/Alerts/Edit',
component: EditRules,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<EditRulesArgs>;
export default meta;
type Story = StoryObj<EditRulesArgs>;
/**
* The alert form on its own route, without the alert-details tabs around it.
* Metrics Explorer and the assistant link here when they send someone to a rule.
* The rule is on the classic schema, which is the only one this route renders:
* see the Alert schema control for what a current-schema rule does here.
*/
export const Default: Story = {};
/** The rule id in the URL does not resolve, so the page offers the way back. */
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 },
};
/**
* The preview chart's legend, one tooltip per series carrying the full label a
* truncated legend entry falls back to. The Preview series control is turned up
* to its maximum so the legend wraps to a second row, which is where a label
* gets clipped.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true, previewSeries: 6 },
};

View File

@@ -0,0 +1,17 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import ROUTES from 'constants/routes';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
/**
* The screen every error boundary in the app falls back to. It calls nothing;
* where its support button leads follows the License control.
*/
export const errorBoundaryFallbackMocks = defineStoryMocks({
controls: {},
config: () => ({ route: ROUTES.SOMETHING_WENT_WRONG }),
});

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import ErrorBoundaryFallback from '../ErrorBoundaryFallback';
import { errorBoundaryFallbackMocks } from './ErrorBoundaryFallback.stories.mocks';
type ErrorBoundaryFallbackArgs = PageStoryArgs<
typeof errorBoundaryFallbackMocks
>;
const pageStory = storyMocks(errorBoundaryFallbackMocks, { layout: 'app' });
/**
* What a render error leaves on screen: the error boundary's own page, with
* nothing fetching behind it.
*
* Route: `/something-went-wrong`.
*/
const meta = {
title: 'Pages/System/Error Fallback',
component: ErrorBoundaryFallback,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ErrorBoundaryFallbackArgs>;
export default meta;
type Story = StoryObj<ErrorBoundaryFallbackArgs>;
/** What a page that threw is replaced with, anywhere in the app. */
export const Default: Story = {};
/** The self-hosted spelling, where support is the community rather than chat. */
export const SelfHosted: Story = {
args: { license: 'enterprise' },
};

View File

@@ -0,0 +1,100 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import ROUTES from 'constants/routes';
import { choiceControl, toggleControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockResolver } from '@/storybook/msw/types';
import {
DETAIL_PARAMS,
type DetailParams,
ERROR_EVENT_NOT_FOUND,
errorDetailsSearch,
errorEvent,
errorNeighbours,
EXCEPTION_LANGUAGES,
type ExceptionLanguage,
NEIGHBOUR_STATES,
type NeighbourState,
} from './__story_mockdata__/errorDetails';
const eventNotFound: MockResolver = (_req, res, ctx) =>
res(ctx.status(404), ctx.json(ERROR_EVENT_NOT_FOUND));
const EVENT = 'Exception details · event';
const NAVIGATION = 'Exception details · navigation';
export const errorDetailsMocks = defineStoryMocks({
controls: {
language: choiceControl<ExceptionLanguage>('Exception', {
group: EVENT,
description:
'Which exception group the page is opened on, which is what the stack trace panel renders.',
options: EXCEPTION_LANGUAGES,
value: 'go',
}),
found: toggleControl('Event found', {
group: EVENT,
description:
'Off, the lookup answers 404 and the page prints the error type it came back with. It answers that way whatever the Data control is set to, since a status code is not something a response body can carry.',
value: true,
}),
params: choiceControl<DetailParams>('URL parameters', {
group: NAVIGATION,
description:
'`group` is what the list links with and reads `/errorFromGroupID`; `event` carries the id Older and Newer add and reads `/errorFromErrorID`; `no-timestamp` is the incomplete link the page refuses to render.',
options: DETAIL_PARAMS,
value: 'group',
}),
neighbours: choiceControl<NeighbourState>('Neighbouring events', {
group: NAVIGATION,
description:
'Which of Older and Newer the group has, and so which of the two buttons is enabled.',
options: NEIGHBOUR_STATES,
value: 'surrounded',
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/errorFromGroupID',
values.found
? response.json((req) =>
errorEvent(values.language, {
timestamp: req.url.searchParams.get('timestamp'),
}),
)
: eventNotFound,
),
rest.get(
'http://localhost/api/v1/errorFromErrorID',
values.found
? response.json((req) =>
errorEvent(values.language, {
timestamp: req.url.searchParams.get('timestamp'),
errorId: req.url.searchParams.get('errorID'),
}),
)
: eventNotFound,
),
rest.get(
'http://localhost/api/v1/nextPrevErrorIDs',
response.json((req) =>
errorNeighbours(
values.language,
values.neighbours,
req.url.searchParams.get('timestamp'),
),
),
),
],
config: (values) => ({
route: `${ROUTES.ERROR_DETAIL}?${errorDetailsSearch(values.language, values.params)}`,
}),
});

View File

@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { errorDetailsMocks } from './ErrorDetails.stories.mocks';
import ErrorDetails from '../index';
type ErrorDetailsArgs = PageStoryArgs<typeof errorDetailsMocks>;
const pageStory = storyMocks(errorDetailsMocks, { layout: 'app' });
/**
* One exception: its stack trace rendered for the language it came from, the span
* it belongs to, and the occurrence before and after it.
*
* Route: `/error-detail?...`.
*/
const meta = {
title: 'Pages/Exceptions/Detail',
component: ErrorDetails,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ErrorDetailsArgs>;
export default meta;
type Story = StoryObj<ErrorDetailsArgs>;
/**
* One exception event as the list opens it: the type and message it was grouped
* under, when it was seen, its stack trace, and the span and trace it came from.
* Older and Newer walk the group's other events in place.
*/
export const Default: Story = {};
/** A Java exception stack trace, including JVM frame formatting. */
export const JavaStack: Story = {
args: { language: 'java' },
};
/** A Python exception stack trace, including chained traceback formatting. */
export const PythonStack: Story = {
args: { language: 'python' },
};
/** The oldest event in the group, with Older disabled. */
export const OldestEvent: Story = {
args: { neighbours: 'oldest' },
};
/** The newest event in the group, with Newer disabled. */
export const NewestEvent: Story = {
args: { neighbours: 'newest' },
};
/**
* A link to an event the backend no longer has, which is what a bookmarked
* exception past its retention window opens.
*/
export const EventMissing: Story = {
args: { found: false },
// The mocked event lookup deliberately 404s; the resulting console error is the state under test.
parameters: { allowConsoleErrors: true },
};
/** The page mid-fetch, shell included. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/**
* The page without a timestamp to look the event up by, which is the link the
* page refuses and sends back to the exceptions list.
*/
export const IncompleteLink: Story = {
args: { params: 'no-timestamp' },
};

View File

@@ -0,0 +1,232 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { EXCEPTION_CATALOGUE } from 'pages/AllErrors/stories/__story_mockdata__/exceptions';
import type { ExceptionShape } from 'pages/AllErrors/stories/__story_mockdata__/exceptions';
import type { PayloadProps as ErrorEvent } from 'types/api/errors/getByErrorTypeAndService';
import type { PayloadProps as NextPrevIds } from 'types/api/errors/getNextPrevId';
export const EXCEPTION_LANGUAGES = ['go', 'python', 'java'] as const;
export type ExceptionLanguage = (typeof EXCEPTION_LANGUAGES)[number];
export const NEIGHBOUR_STATES = [
'surrounded',
'oldest',
'newest',
'only-event',
] as const;
export type NeighbourState = (typeof NEIGHBOUR_STATES)[number];
/** The three parameter sets the page distinguishes, in the order it reads them. */
export const DETAIL_PARAMS = ['group', 'event', 'no-timestamp'] as const;
export type DetailParams = (typeof DETAIL_PARAMS)[number];
/**
* The three events the group holds, oldest first. Both lookups key on the
* timestamp, so walking with Older and Newer, which is the page rewriting the
* `timestamp` param, is what moves between them.
*/
const WALK = ['older', 'event', 'newer'] as const;
type Step = (typeof WALK)[number];
/** RFC 3339 as the payload carries it, and the nanoseconds the URL carries. */
const STEP_TIMESTAMPS: Record<Step, { at: string; nanos: string }> = {
older: { at: '2025-04-14T18:11:04.113904221Z', nanos: '1744654264113904221' },
event: { at: '2025-04-14T18:27:57.797616374Z', nanos: '1744655277797616374' },
newer: { at: '2025-04-14T18:44:12.550118903Z', nanos: '1744656252550118903' },
};
const stepAt = (nanos: string | null): Step =>
WALK.find((step) => STEP_TIMESTAMPS[step].nanos === nanos) ?? 'event';
const eventIdAt = (step: Step, errorId: string): string =>
step === 'event' ? errorId : `${step}-${errorId}`;
const STACK_TRACES: Record<ExceptionLanguage, string> = {
go: `*errors.errorString: redis timeout
goroutine 51 [running]:
github.com/signoz/sample/pkg/cache.(*Client).Get(0xc000112a80, {0x1096f40, 0xc0001a2000}, {0xf8a3c1, 0x11})
\t/app/pkg/cache/client.go:118 +0x2c5
github.com/signoz/sample/pkg/orders.(*Service).Lookup(0xc0000b8060, {0x1096f40, 0xc0001a2000}, 0x1f4)
\t/app/pkg/orders/service.go:64 +0x11f
github.com/signoz/sample/internal/http.(*Handler).ServeHTTP(0xc0000ba018, {0x1094e80, 0xc00019c0e0}, 0xc0001c4000)
\t/app/internal/http/handler.go:41 +0x1a8
net/http.serverHandler.ServeHTTP({0xc00018e000}, {0x1094e80, 0xc00019c0e0}, 0xc0001c4000)
\t/usr/local/go/src/net/http/server.go:2938 +0x8e`,
python: `Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/urllib3/connectionpool.py", line 715, in urlopen
httplib_response = self._make_request(
File "/usr/local/lib/python3.11/site-packages/urllib3/connectionpool.py", line 467, in _make_request
raise ReadTimeoutError(self, url, "Read timed out")
urllib3.exceptions.ReadTimeoutError: HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/app/checkout/views.py", line 82, in place_order
response = session.post(f"{PAYMENTS_URL}/charge", json=payload, timeout=2)
File "/usr/local/lib/python3.11/site-packages/requests/sessions.py", line 637, in post
return self.request("POST", url, data=data, json=json, **kwargs)
requests.exceptions.ConnectionError: HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)`,
java: `java.net.SocketTimeoutException: Read timed out
\tat java.base/java.net.SocketInputStream.socketRead0(Native Method)
\tat java.base/java.net.SocketInputStream.read(SocketInputStream.java:168)
\tat okhttp3.internal.http1.Http1ExchangeCodec.readResponseHeaders(Http1ExchangeCodec.kt:180)
\tat okhttp3.internal.connection.Exchange.readResponseHeaders(Exchange.kt:106)
\tat io.signoz.sample.payment.GatewayClient.authorize(GatewayClient.java:74)
\tat io.signoz.sample.payment.PaymentService.charge(PaymentService.java:112)
\tat io.signoz.sample.payment.PaymentController.pay(PaymentController.java:48)
\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)`,
};
interface EventShape {
group: ExceptionShape;
errorId: string;
spanID: string;
traceID: string;
}
const groupOf = (exceptionType: string): ExceptionShape => {
const found = EXCEPTION_CATALOGUE.find(
(exception) => exception.exceptionType === exceptionType,
);
if (!found) {
throw new Error(`no exception group for ${exceptionType} in the catalogue`);
}
return found;
};
const EVENTS: Record<ExceptionLanguage, EventShape> = {
go: {
group: groupOf('*errors.errorString'),
errorId: '7c1ba0d4e6f84a1f9d2b3c4e5f607182',
spanID: '3a5c7e9b1d0f2468',
traceID: 'd41f8c2a6b3e5079a1c4e6b8d0f2a468',
},
python: {
group: groupOf('ConnectionError'),
errorId: 'b2d4f60819a3c5e7092b4d6f8a0c1e35',
spanID: '9f1d3b5a7c9e0246',
traceID: '5e7a9c1b3d5f7911a3c5e7b9d1f30517',
},
java: {
group: groupOf('java.net.SocketTimeoutException'),
errorId: 'e6c8a0f2d4b60819273a5c7e9b1d3f50',
spanID: '2c4e6a8b0d2f4618',
traceID: 'a0c2e4b6d8f01325476a8c0e2b4d6f81',
},
};
/** The parameters the list links with, and the ones Older and Newer add to them. */
export const errorDetailsSearch = (
language: ExceptionLanguage,
params: DetailParams,
): string => {
const { group, errorId } = EVENTS[language];
const search = new URLSearchParams({ groupId: group.groupID });
if (params === 'no-timestamp') {
return search.toString();
}
search.set('timestamp', STEP_TIMESTAMPS.event.nanos);
if (params === 'event') {
search.set('errorId', errorId);
}
return search.toString();
};
export interface EventLookup {
timestamp: string | null;
errorId?: string | null;
}
/**
* `/errorFromGroupID` and `/errorFromErrorID` both answer with the event itself,
* unwrapped: the api module hands the whole body through as the payload.
*/
export const errorEvent = (
language: ExceptionLanguage,
lookup: EventLookup,
): ErrorEvent => {
const event = EVENTS[language];
const step = stepAt(lookup.timestamp);
return {
errorId: lookup.errorId || eventIdAt(step, event.errorId),
exceptionType: event.group.exceptionType,
exceptionMessage: event.group.exceptionMessage,
exceptionStacktrace: STACK_TRACES[language],
exceptionEscaped: 'false',
timestamp: STEP_TIMESTAMPS[step].at,
spanID: event.spanID,
traceID: event.traceID,
serviceName: event.group.serviceName,
groupID: event.group.groupID,
};
};
const neighbourSteps = (
step: Step,
state: NeighbourState,
): { older: Step | null; newer: Step | null } => {
if (step === 'older') {
return { older: null, newer: 'event' };
}
if (step === 'newer') {
return { older: 'event', newer: null };
}
return {
older: state === 'surrounded' || state === 'newest' ? 'older' : null,
newer: state === 'surrounded' || state === 'oldest' ? 'newer' : null,
};
};
/**
* An empty id is how the backend says there is nothing further in that
* direction, and it is what the Older and Newer buttons read to disable
* themselves. Which end of the walk the page is on comes from the timestamp it
* asks with, so a step in either direction lands somewhere it can step back
* from.
*/
export const errorNeighbours = (
language: ExceptionLanguage,
state: NeighbourState,
timestamp: string | null,
): NextPrevIds => {
const event = EVENTS[language];
const { older, newer } = neighbourSteps(stepAt(timestamp), state);
return {
groupID: event.group.groupID,
prevErrorID: older ? eventIdAt(older, event.errorId) : '',
prevTimestamp: older ? STEP_TIMESTAMPS[older].at : '',
nextErrorID: newer ? eventIdAt(newer, event.errorId) : '',
nextTimestamp: newer ? STEP_TIMESTAMPS[newer].at : '',
};
};
/**
* What the backend answers when no event matches the group and timestamp: a 404
* carrying its own envelope, which `ErrorResponseHandler` reduces to the
* `errorType` the page prints. A `null` body instead of this reaches the page as
* a TypeError, so the `idPayload === null` branch it guards for is unreachable.
*/
export const ERROR_EVENT_NOT_FOUND = {
status: 'error',
errorType: 'not_found',
error: 'Error/Exception not found',
data: null,
};

View File

@@ -0,0 +1,67 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { StatusCodes } from 'http-status-codes';
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
forgotPasswordRouteState,
ORG_MAX,
} from './__story_mockdata__/forgotPassword';
const RESET = 'Forgot password · reset';
const REQUESTS = ['accepted', 'rejected'] as const;
type ResetRequest = (typeof REQUESTS)[number];
export const forgotPasswordMocks = defineStoryMocks({
controls: {
orgs: countControl('Organizations', {
group: RESET,
description:
'Workspaces the email belongs to. More than one adds the organization select, because the reset is scoped to one.',
value: 1,
max: ORG_MAX,
}),
request: choiceControl<ResetRequest>('Reset request', {
group: RESET,
description:
'What the reset endpoint answers when the form is sent: the check-your-inbox screen, or the error above the button.',
options: REQUESTS,
value: 'accepted',
}),
},
handlers: (values) => [
rest.post(
'http://localhost/api/v2/factor_password/forgot',
(_req, res, ctx) =>
values.request === 'accepted'
? res(ctx.status(StatusCodes.NO_CONTENT))
: res(
ctx.status(StatusCodes.TOO_MANY_REQUESTS),
ctx.json({
error: {
code: 'rate_limited',
message: 'Too many reset emails were sent. Try again in an hour.',
url: '',
errors: [],
},
}),
),
),
],
config: (values) => ({
route: ROUTES.FORGOT_PASSWORD,
routeState: forgotPasswordRouteState(values.orgs),
// Resetting a password is what a signed-out browser does, so the shell has
// no side nav, top nav or banners to draw.
appContext: { isLoggedIn: false },
}),
});

View File

@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import ForgotPassword from '../index';
import { forgotPasswordMocks } from './ForgotPassword.stories.mocks';
type ForgotPasswordArgs = PageStoryArgs<typeof forgotPasswordMocks>;
const pageStory = storyMocks(forgotPasswordMocks, { layout: 'app' });
/**
* The reset request form, before any session exists.
*
* Route: `/forgot-password`.
*/
const meta = {
title: 'Pages/Auth/Forgot Password',
tags: ['play'],
component: ForgotPassword,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ForgotPasswordArgs>;
export default meta;
type Story = StoryObj<ForgotPasswordArgs>;
const submit = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole('button', {
name: /send reset link/i,
}),
);
};
/** The email the login form carried over, ready to have a reset link sent to it. */
export const Default: Story = {};
/** An email in three workspaces: the reset is scoped to one, so it has to be picked. */
export const MultipleOrganizations: Story = {
args: { orgs: 3 },
};
/** The link sent: what the page shows instead of the form. */
export const LinkSent: Story = {
play: async ({ canvasElement }): Promise<void> => {
await submit(canvasElement);
await within(canvasElement).findByRole('heading', {
name: /check your email/i,
});
},
};
/** Asked once too often, which the backend rate-limits. */
export const RateLimited: Story = {
args: { request: 'rejected' },
// The deliberate 429 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await submit(canvasElement);
await within(canvasElement).findByText(/rate_limited/i);
},
};

View File

@@ -0,0 +1,33 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { ForgotPasswordRouteState } from 'container/ForgotPassword';
import type { OrgSessionContext } from 'types/api/v2/sessions/context/get';
export const FORGOT_PASSWORD_EMAIL = 'anna@signoz.io';
const ORGS: OrgSessionContext[] = [
{ id: 'org-signoz', name: 'SigNoz' },
{ id: 'org-acme', name: 'Acme Corp' },
{ id: 'org-globex', name: 'Globex' },
].map((org) => ({
...org,
authNSupport: { password: [{ provider: 'password' }], callback: [] },
}));
export const ORG_MAX = ORGS.length;
/**
* The page is reached by a push from the login form rather than by its URL, so
* the email and the workspaces it resolved to arrive as `location.state`. With
* none the page bounces straight back to login.
*/
export const forgotPasswordRouteState = (
orgs: number,
): ForgotPasswordRouteState => ({
email: FORGOT_PASSWORD_EMAIL,
orgId: orgs === 1 ? ORGS[0].id : undefined,
orgs: ORGS.slice(0, orgs),
});

View File

@@ -9,10 +9,21 @@ import HomePage from '../HomePage';
type HomeArgs = PageStoryArgs<typeof homeMocks>;
const pageStory = storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' });
/**
* The workspace landing page: ingestion state per signal, the welcome checklist
* while a signal is missing, then alert rules, dashboards, saved views and the
* services table.
*
* Route: `/home`.
*/
const meta = {
title: 'Pages/Home',
tags: ['role-gated'],
component: HomePage,
...storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' }),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<HomeArgs>;
export default meta;

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { clustersMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type ClustersArgs = PageStoryArgs<typeof clustersMocks>;
const pageStory = storyMocks(clustersMocks, { layout: 'app' });
/**
* The Kubernetes clusters tab: CPU, memory and pod counts per cluster, each row
* opening the drawer.
*
* Route: `/infrastructure-monitoring/kubernetes?category=clusters`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/Clusters',
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ClustersArgs>;
export default meta;
/**
* Clusters with their node readiness and pod status counts, and CPU and memory
* against the cluster's allocatable capacity. The drawer carries the counts cards
* that jump to the nodes and pods of the cluster.
*/
export const Default: StoryObj<ClustersArgs> = {};
/**
* The tooltips of the cluster's details drawer: one per counts card, each naming
* the cluster it would filter the namespaces, nodes or workloads by, the cluster
* name above them, and on every metrics chart the header's own paragraph, its
* docs link, the Go to Metrics Explorer button and the series names in the
* legend. The counts cards are the clusters and namespaces tabs only, so this is
* where they are reviewable.
*/
export const Tooltips: StoryObj<ClustersArgs> = {
args: { tooltipsOpen: true, drawer: true },
};

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { daemonSetsMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type DaemonSetsArgs = PageStoryArgs<typeof daemonSetsMocks>;
const pageStory = storyMocks(daemonSetsMocks, { layout: 'app' });
/**
* The Kubernetes daemon sets tab: desired against ready nodes, with CPU and
* memory beside them.
*
* Route: `/infrastructure-monitoring/kubernetes?category=daemonsets`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/DaemonSets',
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DaemonSetsArgs>;
export default meta;
/**
* DaemonSets by namespace: ready, current, desired and misscheduled nodes beside
* the CPU and memory of the pods they run. The drawer adds the by-pod metrics
* tab.
*/
export const Default: StoryObj<DaemonSetsArgs> = {};

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { deploymentsMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type DeploymentsArgs = PageStoryArgs<typeof deploymentsMocks>;
const pageStory = storyMocks(deploymentsMocks, { layout: 'app' });
/**
* The Kubernetes deployments tab: desired against available replicas, with CPU and
* memory against request and limit.
*
* Route: `/infrastructure-monitoring/kubernetes?category=deployments`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/Deployments',
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DeploymentsArgs>;
export default meta;
/**
* Deployments by namespace: available against desired pods, the replica count and
* the pod status counts. The drawer adds the by-pod metrics tab, one series per
* pod of the deployment.
*/
export const Default: StoryObj<DeploymentsArgs> = {};

View File

@@ -0,0 +1,466 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import ROUTES from 'constants/routes';
import {
INFRA_MONITORING_ATTR_KEYS,
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
VIEWS,
} from 'container/InfraMonitoringK8sV2/constants';
import type {
QueryEnvelope,
QueryRangeRequestV5,
RawRow,
TimeSeries,
} from 'types/api/v5/queryRange';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockControl, StoryMocks } from '@/storybook/controls/types';
import {
fieldKeysResponse,
fieldValuesResponse,
} from '@/storybook/msw/__story_mockdata__/fields';
import {
queryRangeV5RawResponse,
queryRangeV5ScalarResponse,
queryRangeV5TimeSeriesResponse,
timeSeriesPoints,
} from '@/storybook/msw/__story_mockdata__/queryRange';
import {
CHECKS_STATES,
type ChecksState,
entityForChecksType,
entityForResource,
entityEventRows,
entityLogRows,
entitySelection,
entityTraceRows,
GROUP_BY_KEY_BY_ENTITY,
type InfraEntity,
infraChecksResponse,
infraFieldKeys,
infraFieldValues,
infraListResponse,
type MetricScale,
metricScale,
RATIO_SCALE,
} from './__story_mockdata__/infraMonitoring';
const LIST = 'Infra · list';
const CHECKS = 'Infra · checks';
const DRAWER = 'Infra · drawer';
const EMPTY_REASONS = ['no-results', 'before-retention'] as const;
type EmptyReason = (typeof EMPTY_REASONS)[number];
/**
* The drawer tabs each entity ends up with: `K8sBaseDetailsContent` starts from
* all four signals, Hosts turns events off, Volumes hides the strip entirely,
* and the workload entities add the by-pod metrics tab.
*/
const DRAWER_TABS_BY_ENTITY: Record<InfraEntity, readonly VIEWS[]> = {
[InfraMonitoringEntity.HOSTS]: [VIEWS.METRICS, VIEWS.LOGS, VIEWS.TRACES],
[InfraMonitoringEntity.PODS]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
],
[InfraMonitoringEntity.NODES]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
],
[InfraMonitoringEntity.CLUSTERS]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
],
[InfraMonitoringEntity.NAMESPACES]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
VIEWS.POD_METRICS,
],
[InfraMonitoringEntity.DEPLOYMENTS]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
VIEWS.POD_METRICS,
],
[InfraMonitoringEntity.JOBS]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
VIEWS.POD_METRICS,
],
[InfraMonitoringEntity.DAEMONSETS]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
VIEWS.POD_METRICS,
],
[InfraMonitoringEntity.STATEFULSETS]: [
VIEWS.METRICS,
VIEWS.LOGS,
VIEWS.TRACES,
VIEWS.EVENTS,
VIEWS.POD_METRICS,
],
[InfraMonitoringEntity.VOLUMES]: [VIEWS.METRICS],
};
interface InfraListBody {
filter?: { expression?: string; filterByStatus?: string };
groupBy?: { name: string }[];
offset?: number;
limit?: number;
}
interface RouteValues {
groupRows: boolean;
drawer: boolean;
drawerTab: VIEWS;
}
const infraRoute = (
entity: InfraEntity,
{ groupRows, drawer, drawerTab }: RouteValues,
): string => {
const isHosts = entity === InfraMonitoringEntity.HOSTS;
const pathname = isHosts
? ROUTES.INFRASTRUCTURE_MONITORING_HOSTS
: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES;
const params = new URLSearchParams();
if (!isHosts) {
params.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, entity);
}
if (groupRows) {
params.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY,
JSON.stringify([GROUP_BY_KEY_BY_ENTITY[entity]]),
);
}
if (drawer) {
const selection = entitySelection(entity);
params.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
selection.selectedItem,
);
params.set(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, drawerTab);
if (selection.clusterName) {
params.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME,
selection.clusterName,
);
}
if (selection.namespaceName) {
params.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME,
selection.namespaceName,
);
}
}
const search = params.toString();
return search ? `${pathname}?${search}` : pathname;
};
type QuerySpec = QueryEnvelope['spec'];
const specOf = (body: QueryRangeRequestV5): QuerySpec | undefined =>
body.compositeQuery?.queries?.[0]?.spec;
const signalOf = (body: QueryRangeRequestV5): string | undefined => {
const spec = specOf(body);
return spec && 'signal' in spec ? spec.signal : undefined;
};
const metricNameOf = (spec: QuerySpec): string | undefined => {
if (!('aggregations' in spec)) {
return undefined;
}
const [aggregation] = spec.aggregations ?? [];
return aggregation && 'metricName' in aggregation
? aggregation.metricName
: undefined;
};
/**
* The percentage widgets disable their operand queries and plot a `A/B` formula
* over them, so a response has to answer per query name: the formula reads as a
* ratio, the raw queries in the unit of the metric they ask for.
*/
const plottedQueries = (body: QueryRangeRequestV5): QueryEnvelope[] => {
const queries = body.compositeQuery?.queries ?? [];
const plotted = queries.filter(
({ spec }) => !('disabled' in spec && spec.disabled),
);
return plotted.length > 0 ? plotted : queries;
};
const scaleOf = ({ type, spec }: QueryEnvelope): MetricScale =>
type === 'builder_formula' ? RATIO_SCALE : metricScale(metricNameOf(spec));
const groupByKeysOf = (spec: QuerySpec): string[] =>
'groupBy' in spec ? (spec.groupBy ?? []).map(({ name }) => name) : [];
/**
* A formula carries no group-by of its own: it is grouped by whatever the
* queries it combines were, and those are the disabled ones, so its keys come
* off every query in the request.
*/
const groupByKeysFor = (
query: QueryEnvelope,
queries: QueryEnvelope[],
): string[] => {
const own = groupByKeysOf(query.spec);
if (own.length > 0 || query.type !== 'builder_formula') {
return own;
}
return [...new Set(queries.flatMap(({ spec }) => groupByKeysOf(spec)))];
};
/** Series a grouped widget draws, which is how many the by-pod charts legend. */
const GROUPED_SERIES = 4;
const labelValue = (key: string, index: number): string => {
const pool = infraFieldValues(key);
return pool.length > 0 ? pool[index % pool.length] : `${key}-${index + 1}`;
};
const seriesFor = (
query: QueryEnvelope,
queries: QueryEnvelope[],
{ start, end }: Pick<QueryRangeRequestV5, 'start' | 'end'>,
seed: number,
): TimeSeries[] => {
const scale = scaleOf(query);
const keys = groupByKeysFor(query, queries);
if (keys.length === 0) {
return [
{ labels: [], values: timeSeriesPoints({ start, end, seed, ...scale }) },
];
}
return Array.from({ length: GROUPED_SERIES }, (_unused, index) => ({
labels: keys.map((name) => ({
key: { name },
value: labelValue(name, index),
})),
values: timeSeriesPoints({ start, end, seed: seed + index * 3, ...scale }),
}));
};
/**
* Events and logs are the same signal on the same endpoint; the events tab is
* the one that filters on `k8s.object.kind`.
*/
const isEventsQuery = (body: QueryRangeRequestV5): boolean => {
const spec = specOf(body);
const expression =
spec && 'filter' in spec ? spec.filter?.expression : undefined;
return Boolean(
expression?.includes(INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND),
);
};
/** A type alias, not an interface: `MockControlMap` needs an index signature. */
export type InfraControls = {
rows: MockControl<number>;
groupRows: MockControl<boolean>;
warning: MockControl<boolean>;
emptyReason: MockControl<EmptyReason>;
checks: MockControl<ChecksState>;
drawer: MockControl<boolean>;
drawerTab: MockControl<VIEWS>;
telemetryRows: MockControl<number>;
};
const infraControls = (entity: InfraEntity): InfraControls => ({
rows: countControl('Rows', {
group: LIST,
description:
'Rows the endpoint has. The table asks for as many as fit its height, so a higher count paginates.',
value: 20,
max: 40,
}),
groupRows: toggleControl('Group rows', {
group: LIST,
description: `Answers with a grouped_list: one expandable row per value of ${GROUP_BY_KEY_BY_ENTITY[entity]}.`,
value: false,
}),
warning: toggleControl('Query warning', {
group: LIST,
description: 'Attaches a warning to the response, next to the pagination.',
value: false,
}),
emptyReason: choiceControl<EmptyReason>('Empty state', {
group: LIST,
description: 'Which empty state the table shows when Rows is 0.',
options: EMPTY_REASONS,
value: 'no-results',
}),
checks: choiceControl<ChecksState>('Instrumentation checks', {
group: CHECKS,
description:
'The callout above the table. `ready` and `no-checks` both hide it, which is what a fully instrumented cluster shows.',
options: CHECKS_STATES,
value: 'missing',
}),
drawer: toggleControl('Details drawer', {
group: DRAWER,
description: 'Opens the drawer on the first row.',
value: false,
}),
drawerTab: choiceControl<VIEWS>('Drawer tab', {
group: DRAWER,
description:
'The tabs this entity has. Volumes hides its tab strip, so there the drawer only ever shows metrics.',
options: DRAWER_TABS_BY_ENTITY[entity],
value: VIEWS.METRICS,
}),
telemetryRows: countControl('Drawer log and trace rows', {
group: DRAWER,
value: 12,
max: 20,
}),
});
/**
* The mocks both module tabs run on: the list endpoint of one entity, its
* instrumentation checks, the attribute keys its filters offer, and the drawer's
* metrics, logs, traces and events. The entity decides the route the story
* starts on, so the Kubernetes tab passes its category and the Hosts tab does
* not pass one at all.
*/
export const infraStoryMocks = (
entity: InfraEntity,
): StoryMocks<InfraControls> =>
defineStoryMocks({
controls: infraControls(entity),
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/infra_monitoring/checks',
response.json((req) =>
infraChecksResponse(
entityForChecksType(req.url.searchParams.get('type')) ?? entity,
values.checks,
),
),
),
rest.post(
'http://localhost/api/v2/infra_monitoring/:resource',
response.json(async (req) => {
const requested = entityForResource(String(req.params.resource));
const body = (await req.json()) as InfraListBody;
// The drawer asks the same endpoint for one record; everything else is
// the table, its grouped rows or an expanded group.
const isDetailsFetch = body.limit === 1;
return infraListResponse({
entity: requested ?? entity,
count: isDetailsFetch ? Math.max(values.rows, 1) : values.rows,
offset: body.offset ?? 0,
limit: body.limit ?? 10,
groupBy: body.groupBy?.map(({ name }) => name),
status: body.filter?.filterByStatus,
warning: values.warning && !isDetailsFetch,
endTimeBeforeRetention:
values.rows === 0 && values.emptyReason === 'before-retention',
});
}),
),
rest.get(
'http://localhost/api/v1/fields/keys',
response.json(() => fieldKeysResponse(infraFieldKeys(entity))),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
fieldValuesResponse(infraFieldValues(req.url.searchParams.get('name'))),
),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
const body = (await req.json()) as QueryRangeRequestV5;
const { start, end, requestType } = body;
if (requestType === 'raw') {
const rows = ((): RawRow[] => {
if (signalOf(body) === 'traces') {
return entityTraceRows(start, end, values.telemetryRows);
}
return isEventsQuery(body)
? entityEventRows(start, end, values.telemetryRows)
: entityLogRows(start, end, values.telemetryRows);
})();
return queryRangeV5RawResponse(rows);
}
const queries = body.compositeQuery?.queries ?? [];
const plotted = plottedQueries(body);
if (requestType === 'scalar') {
return queryRangeV5ScalarResponse(scaleOf(plotted[0]).base);
}
return queryRangeV5TimeSeriesResponse(
plotted.map((query, index) => ({
queryName: 'name' in query.spec ? (query.spec.name ?? 'A') : 'A',
series: seriesFor(query, queries, { start, end }, index),
})),
);
}),
),
],
config: (values) => ({ route: infraRoute(entity, values) }),
});
export const infraMonitoringMocks = infraStoryMocks(
InfraMonitoringEntity.HOSTS,
);

View File

@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { infraMonitoringMocks } from './InfrastructureMonitoring.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type InfraArgs = PageStoryArgs<typeof infraMonitoringMocks>;
const pageStory = storyMocks(infraMonitoringMocks, { layout: 'app' });
/**
* The host list, and the way into the Kubernetes tabs: the table, its quick
* filters and the entity drawer.
*
* Route: `/infrastructure-monitoring/hosts`.
*/
const meta = {
title: 'Pages/Infrastructure/Overview',
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<InfraArgs>;
export default meta;
type Story = StoryObj<InfraArgs>;
/**
* The Hosts tab with a full page of hosts: quick filters on the left, the status
* toggle and group-by toolbar above the table, and an instrumentation checks
* callout reporting an optional metric and a required attribute nobody is
* sending yet. The Kubernetes tab is the other half of the module, one story per
* resource under `Kubernetes`.
*/
export const Default: Story = {};
/**
* Hosts grouped by one attribute: one expandable row per OS, with the active and
* inactive counts of the hosts behind it.
*/
export const GroupedRows: Story = {
args: { groupRows: true },
};
/** Nothing reporting yet, which is what a fleet with no collector shows. */
export const NoData: Story = {
args: { rows: 0, checks: 'no-checks' },
};
/** An empty range that predates retention, distinct from an uninstrumented fleet. */
export const BeforeRetention: Story = {
args: { rows: 0, emptyReason: 'before-retention', checks: 'no-checks' },
};
/** The host list remains usable while its query reports a response warning. */
export const QueryWarning: Story = {
args: { warning: true },
};
/** The selected host's metrics drawer, reached through the route's detail state. */
export const HostDetailsMetrics: Story = {
args: { drawer: true, drawerTab: VIEWS.METRICS },
};
/** The selected host's logs drawer, including its independently queried rows. */
export const HostDetailsLogs: Story = {
args: { drawer: true, drawerTab: VIEWS.LOGS },
};
/** The selected host's traces drawer, including its independently queried rows. */
export const HostDetailsTraces: Story = {
args: { drawer: true, drawerTab: VIEWS.TRACES },
};
/** The table and the drawer widgets mid-load, shell included. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/**
* Every tooltip the host list carries, held open: the Options button above the
* table, the Hostname, IOWait and load average headers sharing the generic note
* and its docs link, the Status header's own sentence about the ten minute
* window, and the CPU, memory and disk headers, whose tooltip is the whole
* threshold legend rather than a sentence. The Collapse Filters control beside
* the quick filters is an antd tooltip and stays closed.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { jobsMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type JobsArgs = PageStoryArgs<typeof jobsMocks>;
const pageStory = storyMocks(jobsMocks, { layout: 'app' });
/**
* The Kubernetes jobs tab: active, succeeded and failed pods per job, and how long
* each has been running.
*
* Route: `/infrastructure-monitoring/kubernetes?category=jobs`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/Jobs',
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<JobsArgs>;
export default meta;
/**
* Jobs by namespace, with active, failed and successful pods against the
* completions the job asked for. The drawer adds the by-pod metrics tab.
*/
export const Default: StoryObj<JobsArgs> = {};

View File

@@ -0,0 +1,74 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import { infraStoryMocks } from './InfrastructureMonitoring.stories.mocks';
import { infraListResponse } from './__story_mockdata__/infraMonitoring';
/**
* A node name long enough that the pod's metadata tooltip has to wrap, which the
* page's own fixture is too short to show. The Rows, Query warning and Empty
* state controls do not reach the story using this handler.
*/
export const podLongNodeName = rest.post(
'http://localhost/api/v2/infra_monitoring/pods',
async (req, res, ctx) => {
const body = (await req.json()) as { offset?: number; limit?: number };
const list = infraListResponse({
entity: InfraMonitoringEntity.PODS,
count: 20,
offset: body.offset ?? 0,
limit: body.limit ?? 10,
});
return res(
ctx.status(200),
ctx.json({
...list,
data: {
...list.data,
records: list.data.records.map((record) => ({
...record,
meta: {
...record.meta,
[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME]:
'ip-10-0-3-17.eu-central-1.compute.internal (spot, gpu-a10g, capacity-reservation cr-0f21a8b7)',
},
})),
},
}),
);
},
);
/**
* One mocks module per resource the Kubernetes tab lists. They all answer the
* same endpoints; the entity they carry is what picks the list resource, the
* checks type, the group-by attribute and the tabs the drawer offers, and it is
* what puts `?category=` on the route the story starts on.
*/
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
export const namespacesMocks = infraStoryMocks(
InfraMonitoringEntity.NAMESPACES,
);
export const clustersMocks = infraStoryMocks(InfraMonitoringEntity.CLUSTERS);
export const deploymentsMocks = infraStoryMocks(
InfraMonitoringEntity.DEPLOYMENTS,
);
export const jobsMocks = infraStoryMocks(InfraMonitoringEntity.JOBS);
export const daemonSetsMocks = infraStoryMocks(
InfraMonitoringEntity.DAEMONSETS,
);
export const statefulSetsMocks = infraStoryMocks(
InfraMonitoringEntity.STATEFULSETS,
);
export const volumesMocks = infraStoryMocks(InfraMonitoringEntity.VOLUMES);

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { namespacesMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type NamespacesArgs = PageStoryArgs<typeof namespacesMocks>;
const pageStory = storyMocks(namespacesMocks, { layout: 'app' });
/**
* The Kubernetes namespaces tab: CPU and memory aggregated per namespace, each row
* opening the drawer.
*
* Route: `/infrastructure-monitoring/kubernetes?category=namespaces`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/Namespaces',
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<NamespacesArgs>;
export default meta;
/**
* Namespaces with the pod counts by status behind each one. The drawer carries
* the counts cards that jump back to this list filtered, plus the by-pod metrics
* tab.
*/
export const Default: StoryObj<NamespacesArgs> = {};
/** The selected namespace's by-pod metrics tab, opened through its detail route. */
export const NamespaceDetailsPodMetrics: StoryObj<NamespacesArgs> = {
args: { drawer: true, drawerTab: VIEWS.POD_METRICS },
};

View File

@@ -0,0 +1,56 @@
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 { nodesMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type NodesArgs = PageStoryArgs<typeof nodesMocks>;
const pageStory = storyMocks(nodesMocks, { layout: 'app' });
/**
* The Kubernetes nodes tab: allocatable against used CPU and memory, and the
* node's condition.
*
* Route: `/infrastructure-monitoring/kubernetes?category=nodes`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/Nodes',
tags: ['play'],
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<NodesArgs>;
export default meta;
/**
* Nodes of the cluster, with the Ready and Not Ready condition counts, the pods
* scheduled on each and CPU and memory read against what the node has left to
* allocate.
*/
export const Default: StoryObj<NodesArgs> = {};
/**
* Every tooltip the node list carries, held open over a hovered row: the Pod
* Status cell, whose tooltip is a count per phase and only exists while the row
* is hovered, beside the column headers sharing the generic note and the
* Collapse Filters and Options buttons. The pod list renders its own count cells
* only once grouped, so this is where that breakdown is reviewable.
*/
export const Tooltips: StoryObj<NodesArgs> = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(
await canvas.findByText('ip-10-0-1-24-1', {}, { timeout: 10000 }),
);
// The pod-status breakdown is only rendered for the hovered row.
await screen.findByText('Running: 8', {}, { timeout: 10000 });
},
};

View File

@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { podLongNodeName, podsMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type PodsArgs = PageStoryArgs<typeof podsMocks>;
const pageStory = storyMocks(podsMocks, { layout: 'app' });
/**
* The Kubernetes pods tab: status, restarts and age beside CPU and memory read
* against the pod's own request and limit. The drawer adds the pod's events.
*
* Route: `/infrastructure-monitoring/kubernetes?category=pods`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/Pods',
tags: ['play'],
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<PodsArgs>;
export default meta;
/**
* Pods across every namespace: the status pill, restart count and age beside the
* CPU and memory columns, each of those reading against the pod's own request and
* limit. The drawer adds the events tab, which is the pod's Kubernetes events.
*/
export const Default: StoryObj<PodsArgs> = {};
/** The selected pod's Kubernetes events tab, opened through its detail route. */
export const PodDetailsEvents: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS },
};
/**
* Every tooltip the pod list carries, held open: Collapse Filters beside the
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
* headers sharing the generic note and its docs link, and the CPU and memory
* limit headers, whose tooltip is the whole threshold legend rather than a
* sentence. The matching request columns are hidden until the Options panel adds
* them, and their legend is the same shape.
*/
export const Tooltips: StoryObj<PodsArgs> = {
args: { tooltipsOpen: true },
};
/**
* The tooltips of the pod's details drawer on its logs tab: the namespace,
* cluster and node metadata values, the node one long enough to wrap, and the
* Go to Logs Explorer button beside the tab strip, with the list's own header
* tooltips still open behind the drawer.
*/
export const TooltipsInDetailsDrawer: StoryObj<PodsArgs> = {
args: { tooltipsOpen: true, drawer: true, drawerTab: VIEWS.LOGS },
parameters: { msw: { handlers: [podLongNodeName] } },
};
/**
* The Options panel over the list: the tooltip on the disabled switch of the
* column the table cannot do without, and, because the panel lists the columns
* the table is hiding, the CPU and memory request threshold legends the list
* itself never renders.
*/
export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId(
'k8s-table-options-button',
{},
{ timeout: 10000 },
),
);
await screen.findByText('Columns');
},
};

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { statefulSetsMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type StatefulSetsArgs = PageStoryArgs<typeof statefulSetsMocks>;
const pageStory = storyMocks(statefulSetsMocks, { layout: 'app' });
/**
* The Kubernetes stateful sets tab: desired against ready replicas, with CPU and
* memory beside them.
*
* Route: `/infrastructure-monitoring/kubernetes?category=statefulsets`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/StatefulSets',
component: InfrastructureMonitoringPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<StatefulSetsArgs>;
export default meta;
/**
* StatefulSets by namespace: current against desired pods, the replica count and
* the pod status counts. The drawer adds the by-pod metrics tab.
*/
export const Default: StoryObj<StatefulSetsArgs> = {};

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