Compare commits

...

119 Commits

Author SHA1 Message Date
Vinícius Lourenço
d9f9cdb7a5 feat(system): add storybook story for the workspace suspended page 2026-08-31 11:29:26 -03:00
Vinícius Lourenço
f459000855 feat(system): add storybook story for the workspace locked page 2026-08-31 11:29:26 -03:00
Vinícius Lourenço
687fbdc63b feat(system): add storybook story for the workspace access restricted page 2026-08-31 11:29:26 -03:00
Vinícius Lourenço
8fa126993d feat(system): add shared storybook mock data for the workspace states 2026-08-31 11:29:26 -03:00
Vinícius Lourenço
cb09b36121 feat(system): add storybook story for the unauthorized page 2026-08-31 11:29:26 -03:00
Vinícius Lourenço
777349217c feat(traces): add storybook story for the explorer page 2026-08-31 11:29:25 -03:00
Vinícius Lourenço
76ebc93422 feat(traces): add storybook story for the funnel details page 2026-08-31 11:29:25 -03:00
Vinícius Lourenço
d5a479e7f3 feat(traces): add storybook story for the trace details page 2026-08-31 11:29:25 -03:00
Vinícius Lourenço
ab06efc01b feat(traces): add storybook story for the legacy explorer page 2026-08-31 11:29:25 -03:00
Vinícius Lourenço
d88fa23cb5 feat(system): add storybook story for the support page 2026-08-31 11:29:25 -03:00
Vinícius Lourenço
a10e29e4d5 feat(system): add storybook story for the status page 2026-08-31 11:29:24 -03:00
Vinícius Lourenço
9daf662b97 feat(auth): add storybook story for the sign up page 2026-08-31 11:29:24 -03:00
Vinícius Lourenço
e0a7cf63e4 feat(settings): add storybook story for the workspace page 2026-08-31 11:29:24 -03:00
Vinícius Lourenço
962008ab92 feat(settings): add storybook story for the single sign-on page 2026-08-31 11:29:24 -03:00
Vinícius Lourenço
67798123ea feat(settings): add storybook story for the keyboard shortcuts page 2026-08-31 11:29:24 -03:00
Vinícius Lourenço
6479272f0c feat(settings): add storybook story for the service accounts page 2026-08-31 11:29:24 -03:00
Vinícius Lourenço
79a8532b0f feat(settings): add storybook story for the roles page 2026-08-31 11:29:23 -03:00
Vinícius Lourenço
e734e27625 feat(settings): add storybook story for the role editor page 2026-08-31 11:29:23 -03:00
Vinícius Lourenço
1622e420ec feat(settings): add storybook story for the role details page 2026-08-31 11:29:23 -03:00
Vinícius Lourenço
dee1a07664 feat(settings): add storybook story for the members page 2026-08-31 11:29:23 -03:00
Vinícius Lourenço
20bd6942f8 feat(settings): add storybook story for the mcp server page 2026-08-31 11:29:23 -03:00
Vinícius Lourenço
0b011d27b0 feat(settings): add storybook story for the ingestion page 2026-08-31 11:29:23 -03:00
Vinícius Lourenço
47c3916d3a feat(settings): add storybook story for the billing page 2026-08-31 11:29:22 -03:00
Vinícius Lourenço
f37dfa44fc feat(settings): add storybook story for the account page 2026-08-31 11:29:22 -03:00
Vinícius Lourenço
df3acf9cc9 feat(services): add storybook story for the list page 2026-08-31 11:29:22 -03:00
Vinícius Lourenço
e111bf2b12 feat(services): add storybook story for the top level operations page 2026-08-31 11:29:22 -03:00
Vinícius Lourenço
ff5486523a feat(auth): add storybook story for the reset password page 2026-08-31 11:29:22 -03:00
Vinícius Lourenço
ccba61655c feat(dashboards): add storybook story for the public page 2026-08-31 11:29:22 -03:00
Vinícius Lourenço
65f97a1343 feat(onboarding): add storybook story for the questionnaire page 2026-08-31 11:29:21 -03:00
Vinícius Lourenço
2af3537bdf feat(onboarding): add storybook story for the add data source page 2026-08-31 11:29:21 -03:00
Vinícius Lourenço
1f524929d6 feat(settings): add shared storybook mock data 2026-08-31 11:29:21 -03:00
Vinícius Lourenço
3beaac8930 feat(metrics): add storybook story for the explorer page 2026-08-31 11:29:21 -03:00
Vinícius Lourenço
1fa629a74a feat(services): add storybook story for the detail page 2026-08-31 11:29:21 -03:00
Vinícius Lourenço
dbc79de61f feat(metering): add storybook story for the cost meter page 2026-08-31 11:29:20 -03:00
Vinícius Lourenço
874248f0e3 feat(messaging-queues): add storybook story for the overview page 2026-08-31 11:29:20 -03:00
Vinícius Lourenço
7a0f6764b7 feat(messaging-queues): add storybook story for the kafka page 2026-08-31 11:29:20 -03:00
Vinícius Lourenço
f2c66cb51e feat(messaging-queues): add storybook story for the kafka detail page 2026-08-31 11:29:20 -03:00
Vinícius Lourenço
36f90157a1 feat(messaging-queues): add storybook story for the celery page 2026-08-31 11:29:20 -03:00
Vinícius Lourenço
642b192de6 feat(messaging-queues): add shared storybook mock data 2026-08-31 11:29:20 -03:00
Vinícius Lourenço
590de4d77f feat(logs): add storybook story for the settings page 2026-08-31 11:29:19 -03:00
Vinícius Lourenço
198f565ca9 feat(logs): add storybook story for the saved views page 2026-08-31 11:29:19 -03:00
Vinícius Lourenço
f718267996 feat(logs): add storybook story for the pipelines page 2026-08-31 11:29:19 -03:00
Vinícius Lourenço
61a26066ac feat(logs): add storybook story for the explorer page 2026-08-31 11:29:19 -03:00
Vinícius Lourenço
380bbf69e3 feat(logs): add storybook story for the live tail page 2026-08-31 11:29:19 -03:00
Vinícius Lourenço
ef143d36a3 feat(logs): add shared storybook mock data 2026-08-31 11:29:19 -03:00
Vinícius Lourenço
68523bdc87 feat(logs): add storybook story for the legacy explorer page 2026-08-31 11:29:18 -03:00
Vinícius Lourenço
9a223f6f83 feat(auth): add storybook story for the login page 2026-08-31 11:29:18 -03:00
Vinícius Lourenço
97fe31a482 feat(system): add storybook story for the license page 2026-08-31 11:29:18 -03:00
Vinícius Lourenço
85b237668a feat(ai-observability): add storybook story for the overview page 2026-08-31 11:29:18 -03:00
Vinícius Lourenço
e722a1e77b feat(ai-observability): add storybook story for the model pricing page 2026-08-31 11:29:18 -03:00
Vinícius Lourenço
1ac19e7ce9 feat(ai-observability): add storybook story for the attribute mapping page 2026-08-31 11:29:18 -03:00
Vinícius Lourenço
3c4e40f446 feat(integrations): add storybook story for the list page 2026-08-31 11:29:17 -03:00
Vinícius Lourenço
38e34fc5a6 feat(integrations): add storybook story for the details page 2026-08-31 11:29:17 -03:00
Vinícius Lourenço
1c1d573269 feat(integrations): add storybook story for the cloud account page 2026-08-31 11:29:17 -03:00
Vinícius Lourenço
9cd8a6e276 feat(integrations): add shared storybook mock data 2026-08-31 11:29:17 -03:00
Vinícius Lourenço
6cd390b3c8 feat(infrastructure): add storybook story for the kubernetes volumes page 2026-08-31 11:29:17 -03:00
Vinícius Lourenço
05d9ea38da feat(infrastructure): add storybook story for the kubernetes statefulsets page 2026-08-31 11:29:17 -03:00
Vinícius Lourenço
01d1b37f2a feat(infrastructure): add storybook story for the kubernetes pods page 2026-08-31 11:29:16 -03:00
Vinícius Lourenço
27a6923b85 feat(infrastructure): add storybook story for the kubernetes nodes page 2026-08-31 11:29:16 -03:00
Vinícius Lourenço
7862884059 feat(infrastructure): add storybook story for the kubernetes namespaces page 2026-08-31 11:29:16 -03:00
Vinícius Lourenço
8590ef641e feat(infrastructure): add storybook story for the kubernetes jobs page 2026-08-31 11:29:16 -03:00
Vinícius Lourenço
06372b4583 feat(infrastructure): add storybook story for the kubernetes deployments page 2026-08-31 11:29:16 -03:00
Vinícius Lourenço
f8b8597a13 feat(infrastructure): add storybook story for the kubernetes daemonsets page 2026-08-31 11:29:16 -03:00
Vinícius Lourenço
5c33cd7f46 feat(infrastructure): add storybook story for the kubernetes clusters page 2026-08-31 11:29:15 -03:00
Vinícius Lourenço
2ea5e49e57 feat(infrastructure): add shared storybook mocks for the kubernetes pages 2026-08-31 11:29:15 -03:00
Vinícius Lourenço
fb9d0d2b4d refactor(infrastructure): rework the overview storybook story 2026-08-31 11:29:15 -03:00
Vinícius Lourenço
808bc1b6ba refactor(home): update the storybook story 2026-08-31 11:29:15 -03:00
Vinícius Lourenço
0a470e2da8 feat(auth): add storybook story for the forgot password page 2026-08-31 11:29:15 -03:00
Vinícius Lourenço
f8e952a296 feat(exceptions): add storybook story for the detail page 2026-08-31 11:29:14 -03:00
Vinícius Lourenço
faa99f41ca feat(system): add storybook story for the error fallback page 2026-08-31 11:29:14 -03:00
Vinícius Lourenço
fd0a4b706a feat(alerts): add storybook story for the edit page 2026-08-31 11:29:14 -03:00
Vinícius Lourenço
5114573b83 feat(dashboards): add storybook story for the list page 2026-08-31 11:29:14 -03:00
Vinícius Lourenço
db599d7d5c feat(dashboards): add storybook story for the widget editor page 2026-08-31 11:29:14 -03:00
Vinícius Lourenço
74c6ed1fe6 feat(dashboards): add storybook story for the panel editor page 2026-08-31 11:29:14 -03:00
Vinícius Lourenço
33bad3c685 feat(dashboards): add storybook story for the detail page 2026-08-31 11:29:13 -03:00
Vinícius Lourenço
034fd31cec feat(dashboards): add shared storybook mock data 2026-08-31 11:29:13 -03:00
Vinícius Lourenço
8118a2288c feat(alerts): add storybook story for the create page 2026-08-31 11:29:13 -03:00
Vinícius Lourenço
32bdc41398 feat(external-apis): add storybook story 2026-08-31 11:29:13 -03:00
Vinícius Lourenço
f9a67881aa feat(exceptions): add storybook story for the list page 2026-08-31 11:29:13 -03:00
Vinícius Lourenço
a4185d828e feat(alerts): add storybook story for the triggered page 2026-08-31 11:29:13 -03:00
Vinícius Lourenço
a3dbcd0ea3 feat(alerts): add storybook story for the routing policies page 2026-08-31 11:29:12 -03:00
Vinícius Lourenço
afda5e737a feat(alerts): add storybook story for the planned downtime page 2026-08-31 11:29:12 -03:00
Vinícius Lourenço
6fbf40398d feat(alerts): add storybook story for the channels new page 2026-08-31 11:29:12 -03:00
Vinícius Lourenço
d1e0de3cbf feat(alerts): add storybook story for the channels edit page 2026-08-31 11:29:12 -03:00
Vinícius Lourenço
c64572a087 feat(alerts): add storybook story for the channels list page 2026-08-31 11:29:12 -03:00
Vinícius Lourenço
0d5b4d8ebd feat(alerts): add storybook story for the rules page 2026-08-31 11:29:12 -03:00
Vinícius Lourenço
5cede42232 feat(alerts): add storybook story for the overview page 2026-08-31 11:29:11 -03:00
Vinícius Lourenço
7e190fe345 feat(alerts): add storybook story for the history page 2026-08-31 11:29:11 -03:00
Vinícius Lourenço
7a64f524d0 feat(alerts): add shared storybook mock data 2026-08-31 11:29:11 -03:00
Vinícius Lourenço
691240d7aa feat(noz): add storybook story 2026-08-31 11:29:11 -03:00
Vinícius Lourenço
0a3e338088 feat(metering): add storybook story for the usage explorer page 2026-08-31 11:29:11 -03:00
Vinícius Lourenço
f39dfbbaa7 feat(services): add storybook story for the service map page 2026-08-31 11:29:11 -03:00
Vinícius Lourenço
7ee5581acc feat(system): add storybook story for the not found page 2026-08-31 11:29:10 -03:00
Vinícius Lourenço
0ec4ab9697 fix(ai-assistant): keep the composer height on auto while hidden 2026-08-31 11:25:42 -03:00
Vinícius Lourenço
f4a70f99f5 docs(storybook): update the harness readme 2026-08-31 11:25:42 -03:00
Vinícius Lourenço
eaf9886f3f feat(storybook): expand the shared query_range mock data 2026-08-31 11:25:42 -03:00
Vinícius Lourenço
9669593935 feat(storybook): sort the sidebar like the side nav 2026-08-31 11:25:41 -03:00
Vinícius Lourenço
d11162699f feat(storybook): add the docs addon 2026-08-31 11:25:41 -03:00
Vinícius Lourenço
19ab1044c0 feat(storybook): warn when a doc comment clobbers the story mocks 2026-08-31 11:25:41 -03:00
Vinícius Lourenço
0978cc4532 feat(storybook): support an initial location state 2026-08-31 11:25:40 -03:00
Vinícius Lourenço
19ce2a8515 fix(storybook): mirror the story search onto the preview url 2026-08-31 11:25:40 -03:00
Vinícius Lourenço
7e2fbaacaa fix(storybook): answer the google fonts stylesheet 2026-08-31 11:25:40 -03:00
Vinícius Lourenço
dab021b061 fix(storybook): let msw answer server-sent events 2026-08-31 11:25:40 -03:00
Vinícius Lourenço
49151e999f fix(storybook): keep esbuild as the css minifier 2026-08-31 11:25:40 -03:00
Vinícius Lourenço
85b7bc9dbf fix(storybook): set up the monaco loader in the preview 2026-08-31 11:25:40 -03:00
Vinícius Lourenço
e4d0652d81 fix(storybook): point the redux singleton at the story store 2026-08-31 11:25:39 -03:00
Vinícius Lourenço
292f48d88b docs(storybook): add the page stories design doc 2026-08-31 11:25:39 -03:00
Vinícius Lourenço
739e2de4b4 docs(storybook): update the page story skill 2026-08-31 11:25:39 -03:00
Vinícius Lourenço
00fe541f09 feat(infra-monitoring): add storybook 2026-08-27 16:41:42 -03:00
Vinícius Lourenço
4bfe2dadff chore(package): pin the version for testing lib 2026-08-24 18:28:04 -03:00
Vinícius Lourenço
7b6b41a20e chore(app-shell): from 0.99.0 to 0.0.0 2026-08-24 17:33:29 -03:00
Vinícius Lourenço
f8310797b6 docs(harness): correct which runners mount AppHarness 2026-08-24 17:30:48 -03:00
Vinícius Lourenço
a317af08ba feat(storybook): add the a11y addon 2026-08-24 17:30:48 -03:00
Vinícius Lourenço
8e5f0b3e75 chore: collapse AI-owned story mocks in diffs 2026-08-24 17:30:32 -03:00
Vinícius Lourenço
6dfa8fd985 docs(storybook): mark story mocks as AI-owned 2026-08-24 17:30:32 -03:00
Vinícius Lourenço
ec28feabaf refactor(storybook): rename __mockdata__ to __story_mockdata__ 2026-08-24 17:30:31 -03:00
Vinícius Lourenço
eb0b3eb540 feat(storybook): add story for home page 2026-08-24 17:30:31 -03:00
Vinícius Lourenço
97b50d67e1 feat(storybook): add skill to create stories for pages 2026-08-24 17:30:31 -03:00
Vinícius Lourenço
899a0dc366 feat(storybook): add initial support for storybook 2026-08-24 17:30:31 -03:00
288 changed files with 35006 additions and 157 deletions

View File

@@ -0,0 +1,206 @@
---
name: signoz-page-story
description: Explore a SigNoz page, map the endpoints, states and query params it has, then write its Storybook page story with control-driven msw mocks that reach every state. Use when asked to create, extend or review a Storybook story for a page under frontend/src/pages, to add controls to an existing page story, or to write defineStoryMocks handlers and mock data for a page.
---
# SigNoz page stories
A page story renders the real page inside the real app shell against msw, and its
controls panel can reach every state the page has. The panel is the deliverable,
not the story list.
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
control builders, module mocks, navigation). Read it first; this skill is the
process on top of it.
## Workflow
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
the inventory (endpoints, states, params, permissions, caps) before writing
code. No inventory, no story.
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
names the endpoints step 1 missed.
3. **Inventory to controls**: [references/controls.md](references/controls.md).
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's
mocks module.
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`.
**Order.** `src/storybook/docs/sidebarOrder.ts` 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. 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`). |
| `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') });
/**
* 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,
decorators: [withAppLayout],
...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``.
## Rules
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
every widget carrying data. Empty, loading and failed are variants or control
values, never the default.
- **A control is a knob on a response**, resolved through `handlers`, `config` or
`effect`. Never a component prop, never a module mock added for one story.
- **Every branch in the inventory is reachable from the panel.** A state that
needs a code edit to see is a missing control.
- **Never re-declare what every story already has**: banner, side nav, data state
(loaded/loading/error), access preset, permissions, check state.
- **A variant earns a story only when it is worth linking to**: an empty
workspace, a viewer, a page mid-load. Everything else stays a control.
- **Endpoints the page owns go through `response.json`**, so the Data control
covers loaded, loading and failed in one declaration. Endpoints the page cannot
render without (ingestion detection, preferences, feature payloads) take a
plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param
navigation works inside a story; a different pathname is blocked and reported
by the overlay. A control for a param is worth it only when the param is a page
mode someone would want to flip.
- **File layout**: `src/pages/<Page>/<Page>.stories.tsx`,
`src/pages/<Page>/<Page>.stories.mocks.tsx`, payload builders in
`src/pages/<Page>/__story_mockdata__/<page>.ts`. Nothing 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/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. 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 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:
```ts
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
```
The root `.gitattributes` marks both paths `linguist-generated=true`, so the
reviewer gets them collapsed and spends the attention on the rendered page. The
story file is the human surface and never carries the banner. A file with the
banner has to stay regenerable from the page alone: no page knowledge in
`src/storybook/`, and builders typed from `src/api/generated` where the endpoint
has types, so a contract change is a compile error instead of a mock that lies.
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
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
name, a type, or what a builder plainly builds; if the sentence reads as the
signature in prose, delete it. Nothing addressed to a reviewer. The one
comment a story always gets is its own doc comment: what it shows, in the
page's own terms.
## 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 `src/storybook/docs/sidebarOrder.ts`
- [ ] 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.`
- [ ] 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
request, no React warning
- [ ] no navigation overlay on mount
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
`pnpm exec oxfmt --check <files>` all clean. The repo has no
prettier: `pnpm exec prettier` prints a pass while exiting 254

View File

@@ -0,0 +1,227 @@
# Turning the inventory into controls
Every row of the inventory becomes a control, a global control that already
exists, or a documented reason it cannot be one.
## Imports
Paths written as `src/storybook/...` in prose are repo paths, not import
specifiers. Stories import through the `@/` alias (`@/*``./src/*`); modules
inside `src/storybook/` import each other relatively.
| Import | From |
| --- | --- |
| `toggleControl`, `countControl`, `choiceControl`, `multiChoiceControl` | `../controls/controls` |
| `defineStoryMocks`, `storyMocks` | `../controls/defineStoryMocks` |
| `PageStoryArgs` | `../controls/resolveStoryMocks` |
| `MockRequest`, `MockResponse` | `../controls/types` |
| `withAppLayout` | `@/storybook/decorators/withAppLayout` |
| the page's mocks, from the story | `./<Page>.stories.mocks` |
| `queryRangeV5ScalarResponse`, `queryRangeV5RawResponse`, etc. | `@/storybook/msw/__story_mockdata__/queryRange` |
## Which builder
`src/storybook/controls/controls.ts`:
| The state is | Builder |
| --- | --- |
| on or off (a signal ingesting, a feature present) | `toggleControl` |
| how many rows a list has | `countControl` |
| one of several modes (tab, visibility, plan, severity filter) | `choiceControl` |
| a subset (steps skipped, columns shown, signals selected) | `multiChoiceControl` |
Rules that come with them:
- `countControl` `max` goes past what the page renders, so a story can show the
cap being hit. `0` is the empty state, which is why an empty list rarely needs
its own story. When the cap is in the *request* (`?limit=5`) rather than the
renderer, stop `max` at the limit: a longer response is a body the backend
cannot send.
- `choiceControl` options come from a `const` array typed with
`(typeof X)[number]`, not from string literals scattered in the handlers.
- Defaults describe the fully-populated page. The panel starts where `Default`
starts.
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
control.
- `description` only when the name does not carry the effect (what dismissing
does, what the cap is, which widget it feeds).
## Which hook
`defineStoryMocks` takes three, all optional:
- `handlers(values, response)`: the page's endpoints. Everything the page owns
goes through `response.json`, so the global Data control turns the whole page
into loading or failed without a second declaration. An endpoint the page
cannot render at all without (ingestion detection, preferences, license
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
visible while the rest hangs or fails.
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
`appContext`, `reduxState`, `queryBuilder`, `theme`.
- `effect(values)`: module-level state no provider exposes.
One endpoint feeding several widgets stays one handler that reads the request.
`response.json` hands the request to the builder and awaits it, so reading a
query param, or a POST body, does not cost the Data control:
```ts
rest.get(
'http://localhost/api/v1/explorer/views',
response.json((req) =>
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
),
),
```
```ts
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
const body = (await req.json()) as QueryRangeRequestV5;
const signal = body.compositeQuery?.queries?.[0]?.spec?.signal;
return countResponse(values[`${signal}Ingestion`] ? 4213 : 0);
}),
),
```
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
endpoint has to keep answering while the Data control is on `loading` or
`error`: detection calls the page cannot render without.
## Mutations
A control drives the response, so a write the page makes against state a control
owns does not stick: the refetch answers with the control's value and the button
appears to do nothing. Two honest options: leave it declarative and say so in
the PR, or move the state into `effect` so the handler can read what the page
wrote. Never fake the write by mutating a builder's module state without saying
where the state lives.
## Wiring it up
```ts
// src/pages/Services/Services.stories.mocks.tsx
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
export const servicesMocks = defineStoryMocks({
controls: {
services: countControl('Services', { group: LISTS, value: 8, max: 12 }),
apdex: choiceControl<ApdexState>('Apdex', {
group: HEALTH,
options: APDEX_STATES,
value: 'mixed',
}),
},
handlers: (values, response) => [
rest.post(
'http://localhost/api/v2/services',
response.json(() => buildServices(values.services, values.apdex)),
),
],
});
```
```tsx
// src/pages/Services/Services.stories.tsx
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
const pageStory = storyMocks(servicesMocks, { route: ROUTES.APPLICATION });
/**
* Every instrumented service with its p99, error rate and throughput.
*
* Route: `/services`.
*/
const meta = {
title: 'Pages/Services/List',
component: Services,
decorators: [withAppLayout],
...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.
- 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`,
`viewer`), and the builder turns intent into the payload.
- Anything that needs a module mock or a component prop to work. If the state
cannot be produced from a response, config or module state, say so in the PR
instead of faking it.
## Control or story
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
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.
Everywhere else the comment rule in SKILL.md applies: write one only for what
the code cannot show.

View File

@@ -0,0 +1,64 @@
# Mapping a page
Two passes: read the code, then let the running story correct you. Write the
inventory down: it is what the controls are derived from, and the only
protection against a story that renders one state and calls it a page.
## Pass 1: read the page
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts
(`src/container/<Feature>/`), the hooks those use, the components with their own
fetches. Stop at leaf components that take props only.
Grep recipes, run against the page's directories:
| Looking for | Grep |
| --- | --- |
| endpoints | `useQuery\|useMutation\|useInfiniteQuery`, then the `api/` module it calls |
| endpoint URLs | the api module's `axios.get\|post` |
| endpoint URLs behind a generated hook | the hook lives in `src/api/generated/services/<name>/index.ts` and the URL only appears in the fetcher body: `rg 'url: \`' src/api/generated/services/<name>/` |
| url state | `useUrlQuery\|useUrlQueryData\|useUrlSearchState\|useQueryState\|QueryParams\.` |
| navigation | `useSafeNavigate\|history.push\|<Link` |
| permissions | `useAuthZ\|AuthZGuard\|AuthZButton\|hasEditPermission\|routePermission` |
| flags and prefs | `useFeatureFlag\|FeatureKeys\.\|USER_PREFERENCES\.\|userPreferences` |
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
| render caps | `slice(0,\|PAGE_SIZE\|pageSize\|limit` |
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names,
`src/lib/authz/README.md` how a permission check resolves.
## The inventory
One table, in the story's PR or scratch notes:
| Endpoint | Feeds | States it can be in |
| --- | --- | --- |
| `GET /api/v1/x` | the header count | populated, zero, error |
Plus four short lists:
- **Query params** the page reads, and what each one switches.
- **Permission checks** the page makes, and what disappears when each is denied.
- **Preferences and flags** that change layout (dismissed banners, onboarding
checklists, opt-in views).
- **Caps**: how many rows each list renders before it truncates or paginates.
A state that appears in this inventory and not in the controls panel is a bug in
the story.
## Pass 2: let it run
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the
route, add `withAppLayout`, then open it (see verify.md). The console is the
oracle:
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1
missed. Add it to the inventory.
- an msw unhandled-request warning: a request going to an origin the handlers do
not answer on. handlers are declared against `http://localhost`.
- a spinner that never resolves with the Data control on `loaded`: a handler
whose URL does not match what the page calls.
- the navigation overlay on mount: the page redirects, usually because `route`
is wrong or a guard is failing on a permission the controls have not granted.
Repeat until the console is silent. Only then start declaring controls.

View File

@@ -0,0 +1,111 @@
# Verifying a page story
A story is not done because it compiles. It is done when each control has been
seen changing the page and the console is silent.
## Run it
```bash
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/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-list--default&viewMode=story
```
## Flip controls from the URL
Args are settable in the iframe URL, so a whole sweep runs headless without
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
indexed, several separated by `;`, and the theme through `globals`:
```
&args=services:0;apdex:poor;access:viewer;dataState:loading
&args=signals[0]:logs;signals[1]:traces
&globals=theme:light
```
That is the cheap way to check a control does something: load with and without
it, diff the page text.
## Drive it
Playwright lives in the repo's e2e workspace, so a scratch script can use it
directly:
```js
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
const { chromium } = pw;
const browser = await chromium.launch();
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') problems.push(m.text());
});
page.on('pageerror', (e) => problems.push(e.message));
await page.goto(`${story}&args=services:0`, { waitUntil: 'networkidle' });
await page.locator('body').waitFor();
console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
await browser.close();
```
Screenshots are worth taking for `Default` in both themes
(`&globals=theme:light`): text extraction does not catch an unstyled page.
## Gates
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
msw unhandled-request warning, a React key or state warning: assume the story
is wrong first. A warning that survives is sometimes the app's. Prove it by
turning off the control that renders the widget and watching the warning go
with it, and by finding the same component elsewhere doing it right. Then
report the app bug in the PR. Never invent a field the API does not return to
silence a warning.
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
means the page is trying to leave: wrong `route`, or a guard denying on a
permission the controls did not grant.
- **Every control moves something.** Sweep them one at a time from the URL and
diff the page text. A control with no diff is either wired to nothing or aimed
at a widget that is not rendering. Some only show their effect after an
interaction, such as a tab that has to be clicked or a select that has to be
opened. Drive that interaction rather than calling the control unobservable.
- **Both themes render styled.** An unstyled page means the story is not inside
the provider decorator, or `<body data-theme>` was lost.
- **Roles agree.** `<body data-signoz-story-role>` and
`<body data-signoz-context-role>` disagreeing means the page reads a different
`AppContext` than the story config fills.
- **The page's own navigation works.** Tabs, filters and pagination that write
query params should re-render the page in place; only leaving the page belongs
in the overlay.
## Then the usual
```bash
pnpm tsgo --noEmit
pnpm exec oxlint <changed files>
pnpm exec oxfmt --check <changed files>
```
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
not one.
## Common failures
| Symptom | Cause |
| --- | --- |
| endless spinner with Data on `loaded` | handler URL does not match the call; handlers answer on `http://localhost` |
| page renders but empty | response shape wrong; compare against the api module's type, not a guess |
| 501 in the console | endpoint nobody mocked; the catch-all is answering |
| new control missing from the panel | project-level control added; the tab needs a reload |
| control flips but nothing changes | the widget is gated by something else: a permission, a flag, a preference |
| shell disappears in `loading` | an endpoint the shell needs went through `response.json`; give it a plain resolver |

4
.gitattributes vendored
View File

@@ -1 +1,3 @@
*.css linguist-detectable=false
*.css linguist-detectable=false
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true

6
frontend/.gitignore vendored
View File

@@ -28,4 +28,8 @@ e2e/test-plan/saved-views/
e2e/test-plan/service-map/
e2e/test-plan/services/
e2e/test-plan/traces/
e2e/test-plan/user-preferences/
e2e/test-plan/user-preferences/
# Storybook
/storybook-static/
debug-storybook.log

102
frontend/.storybook/main.ts Normal file
View File

@@ -0,0 +1,102 @@
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import type { StorybookConfig } from '@storybook/react-vite';
import type { Plugin, PluginOption } from 'vite';
const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
/**
* Modules replaced for every story. Same idea as `moduleNameMapper` in
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
* not catch `lib/historyUtils`.
*
* Each replacement is typed as the module it stands in for, so drift is a
* compile error rather than a story that fails at render. The `jest` note on
* each entry is where the same import lands under the other runner. The two
* only diverge where the runner needs them to.
*/
const mockAliases = [
{
// jest: not replaced, jsdom drives a real browser history.
find: /^(?:src\/)?lib\/history$/,
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
},
{
// jest: src/__tests__/logEventMock.ts
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`,
},
];
/**
* Plugins from `vite.config.ts` that either target the app's `index.html` or
* only pay off in a production build.
*/
const EXCLUDED_PLUGINS = [
'vite-plugin-checker',
'dev-base-path',
'dev-boot-data',
'vite-plugin-image-optimizer',
'vite-plugin-compression',
];
const isExcluded = (plugin: PluginOption): boolean =>
!!plugin &&
typeof plugin === 'object' &&
'name' in plugin &&
EXCLUDED_PLUGINS.includes((plugin as Plugin).name);
const config: StorybookConfig = {
framework: '@storybook/react-vite',
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', '@storybook/addon-docs'],
core: { disableTelemetry: true },
viteFinal: async (viteConfig) => {
const plugins = (viteConfig.plugins ?? [])
.flat(Infinity as 1)
.filter((plugin) => !isExcluded(plugin as PluginOption));
const existingAlias = viteConfig.resolve?.alias;
const normalizedAlias = Array.isArray(existingAlias)
? existingAlias
: Object.entries(existingAlias ?? {}).map(([find, replacement]) => ({
find,
replacement: replacement as string,
}));
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,
alias: [...mockAliases, ...normalizedAlias],
},
};
},
};
export default config;

View File

@@ -0,0 +1,24 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/uPlot.min.css" />
<script>
// i18n's language detector would pick the browser locale (e.g. `en-US`), which
// /public/locales does not ship; pin it to the bundled language instead.
window.localStorage.setItem('i18nextLng', 'en');
// The Go backend injects this at boot; every integration it enables is off in
// Storybook so no third-party script loads inside the iframe.
window.signozBootData = {
settings: {
posthog: { enabled: false, apiHost: '', key: '', uiHost: '' },
appcues: { enabled: false, appId: '' },
sentry: { enabled: false, dsn: '', tunnel: '' },
pylon: { enabled: false, appId: '', identitySecret: '' },
},
};
</script>

View File

@@ -0,0 +1,230 @@
import type { Preview } from '@storybook/react-vite';
import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
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';
import { clearBlockedNavigations } from '../src/storybook/navigation/blockedNavigationStore';
import {
resolveStory,
type StoryRuntimeContext,
} from '../src/storybook/runtime/resolveStory';
import '../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';
import '../src/storybook/storybook-root.scss';
interface StorybookWorkerHolder {
__signozStorybookWorker?: StorybookWorker;
}
const holder = window as unknown as StorybookWorkerHolder;
/**
* One worker per page, even if this module is re-executed by HMR. Two live
* workers both answer the service worker and the story gets whichever replies
* first.
*/
interface StorybookWorker {
worker: SetupWorker;
ready: Promise<unknown>;
}
const { worker, ready } = (holder.__signozStorybookWorker ??=
((): StorybookWorker => {
const instance = setupWorker();
return {
worker: instance,
ready: instance.start({
serviceWorker: { url: './mockServiceWorker.js' },
// Storybook's own traffic (index.json, HMR, telemetry) goes unhandled by
// design; only flag the app's API calls so a missing handler is obvious.
onUnhandledRequest: (request, print): void => {
const url = new URL(request.url.href);
const isStaticAsset =
/\.(?:woff2?|ttf|otf|css|js|map|png|jpe?g|svg|webp|ico)$/.test(
url.pathname,
);
const isAppRequest =
!isStaticAsset &&
(url.pathname.startsWith('/api/') || url.host !== window.location.host);
if (isAppRequest) {
print.warning();
}
},
}),
};
})());
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', 'Widget Editor', 'Public'],
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',
[
'Explorer',
'Live Tail',
'Saved Views',
'Pipelines',
'Settings',
'Legacy Explorer',
],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details', 'Legacy Explorer'],
'Metrics',
['Explorer'],
'Infrastructure',
[
'Overview',
'Kubernetes',
[
'Clusters',
'Nodes',
'Namespaces',
'Pods',
'Deployments',
'DaemonSets',
'StatefulSets',
'Jobs',
'Volumes',
],
],
'Integrations',
['List', 'Details', 'Cloud Account'],
'Exceptions',
['List', 'Detail'],
'External APIs',
'AI Observability',
['Overview', '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',
'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 },
},
// 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',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'dark', title: 'Dark' },
{ value: 'light', title: 'Light' },
],
dynamicTitle: true,
},
},
},
initialGlobals: { theme: 'dark' },
// Controls every story carries: permissions, banners, and whether the page's
// own endpoints answer, hang or fail.
args: globalMocks.args,
argTypes: globalMocks.argTypes,
decorators: [withProviders],
loaders: [
// Runs on every render, args changes included, and ahead of the decorators:
// the whole story world is put in place here, so the provider tree only has
// to read it. Re-registering the handlers per render also means an edit to a
// handler module takes effect on the next render instead of leaving the
// worker on the set it was created with.
async (context): Promise<void> => {
const world = resolveStory(context as unknown as StoryRuntimeContext);
world.apply();
world.install(worker);
await ready;
},
],
beforeEach: () => {
clearBlockedNavigations();
resetStoryHistory();
},
};
export default preview;

View File

@@ -0,0 +1,309 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (1.3.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// 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') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
'[MSW] Successfully emulated a network error for the "%s %s" request.',
request.method,
request.url,
)
return
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`,
)
}),
)
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [channel.port2])
})
}
function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(resolve, timeMs)
})
}
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

View File

@@ -88,6 +88,17 @@ pnpm test
pnpm tsgo --noEmit
```
## Storybook
```bash
pnpm storybook
```
Opens [http://localhost:6006](http://localhost:6006). Pages run against msw
mocks with no backend; query-param navigation works inside a story, leaving the
page is blocked. See [`src/storybook/README.md`](src/storybook/README.md) for the
override surface.
## Linting
```bash

View File

@@ -0,0 +1,495 @@
# Storybook page stories
> Frontend Technical Design Document
---
## Document Info
| Field | Value |
|-------|-------|
| **Author** | TODO |
| **Reviewers** | TODO |
| **Status** | In Review |
| **Created** | 2026-08-24 |
| **Last Updated** | 2026-08-24 |
| **Effort Estimate** | TODO |
| **Owning Team** | Frontend Platform |
| **PR** | [SigNoz/signoz#12639](https://github.com/SigNoz/signoz/pull/12639) |
| **Issue** | SigNoz/engineering-pod#5951 |
---
## 1. Summary
Run whole SigNoz pages in Storybook with no backend: msw answers every request,
and the state a page is in becomes a row in the controls panel. The unit is the
page inside its real shell, not a component in isolation, so the artifact is a
picture of what a user sees. Phase 2 points Chromatic at that picture set and
makes visual diffs a blocking, human-accepted check.
The mocks that feed those pages are written and maintained by AI through the
`signoz-page-story` skill. Nobody reviews them line by line, so the design is
built around that: mocks are cheap to regenerate, typed against the app's own
modules, and marked as generated in the diff.
---
## 2. Context & Problem Statement
### Current state
| Metric | Value |
|--------|-------|
| Stories in `frontend/` before this work | 0 |
| Routes in `src/AppRoutes/routes.ts` | 71 |
| Page directories under `src/pages/` | 60 |
| Jest test files | 786 |
| E2E feature suites | 10 |
Every layer of the current test stack looks at something other than the page.
Jest mounts fragments with mocked providers. E2E drives real flows but needs a
running backend and reads assertions, not pixels. Neither answers "what does
this page look like when the workspace is empty, the user is a viewer, and the
license expired".
### Problems
- **Hidden states are unreachable.** Empty workspace, denied permission, expired
license, mid-load, failed request. Seeing any of them today means finding an
environment in that state or editing code.
- **Visual review is manual.** A change to a shared component or a design token
is checked by whoever remembers which pages use it. A regression two pages
away from the diff ships.
---
## 3. Goals & Non-Goals
### Goals
- Render any page, in any state, with no backend and no environment.
- Make every state a page can be in reachable from the controls panel.
- Catch visual regressions before merge (Phase 2, Chromatic).
- Keep mock authorship an AI task, at a cost low enough to keep adding pages.
- A story mounts the provider tree the app mounts, not a second approximation of
it.
### Non-Goals
- **Component stories.** A page story with controls covers what a component
story would, in context. Component-level stories are not the pattern here.
- **Behaviour testing.** A wrongly migrated component, a broken handler, a bad
request payload: that is E2E's job. A story renders, it does not assert.
- **Replacing jest.** Stories are not reused as jest tests (no portable stories,
no `composeStories`).
- **Interaction coverage.** No play functions for now (see 6.3).
- **A published static build.** Possible later, not in scope.
- **msw v2.** The jest suite is on msw v1; upgrading is its own project.
---
## 4. Requirements
### Functional
- A page renders inside the real `AppLayout` (side nav, top nav, banners).
- Every endpoint the page calls is answered locally, and an unanswered one fails
loudly instead of hanging.
- Loaded, loading and failed reachable for the page's own endpoints from one
control.
- Access, license, banner and side nav state selectable on every story.
- Query-param navigation (tabs, filters, pagination, time range) works inside a
story; leaving the page is blocked and reported.
- Both themes render fully styled.
### Non-Functional
- **Isolation:** no state, cache or override bleeds between stories.
- **Determinism:** the same story renders the same pixels on every run (Phase 2
prerequisite, see 6.2).
- **Authoring cost:** a new page story is a skill run, not a project.
- **No production impact:** nothing in `src/storybook/` or the msw worker reaches
the app bundle.
---
## 5. Success Metrics
The metric is qualitative on purpose: this exists to make hidden UI states
visible and to make small UI changes trustworthy without a manual sweep.
| Metric | Baseline | Target | Measurement |
|--------|----------|--------|-------------|
| Pages whose states can be seen without an environment | 0 | grows every sprint, no end date | stories under `src/pages/**` |
| States reachable per page | n/a | every branch in the page's inventory | skill's discovery inventory vs the panel |
| Small UI change reviewed without a manual sweep | 0% | the common case | Chromatic diff accepted in the PR |
| Regressions caught before merge | 0 | > 0 and rising | Chromatic build history |
A feature change still needs a recording of the real UI. Stories cover tiny
changes and regressions, not "does the feature work".
---
## 6. Proposed Design
### 6.1 Architecture overview
Three layers: the app's provider tree extracted so a runner can mount it, a
story runtime that turns controls into a mocked world, and per-page mock modules
that live with the page.
Only the third layer is authored per page, and it is three files next to the
page:
```
src/pages/<Page>/
├── <Page>.stories.tsx # human-owned: title, route, named variants
├── <Page>.stories.mocks.tsx # AI-owned: controls, handlers, config
└── __story_mockdata__/<page>.ts # AI-owned: payload builders
```
The story file is the whole human surface. Everything else the page needs
(providers, shell, access, msw, containment) arrives from the global decorator:
```tsx
type HomeArgs = PageStoryArgs<typeof homeMocks>;
const meta = {
title: 'Pages/Home',
component: HomePage,
decorators: [withAppLayout],
...storyMocks(homeMocks, { route: ROUTES.HOME }),
} satisfies Meta<HomeArgs>;
/** Every widget carrying data. */
export const Default: StoryObj<HomeArgs> = {};
/** Fresh workspace: nothing ingested, so the welcome checklist takes over. */
export const NoIngestion: StoryObj<HomeArgs> = {
args: { logsIngestion: false, tracesIngestion: false, dashboards: 0 },
};
```
A named story exists when the state is worth linking to. Everything else is a
control someone turns.
`src/storybook/` holds the rest: the story runtime, the control builders, the
global mock modules, the access grant, the msw stack, the navigation
containment and the two decorators. It is infrastructure, edited when the
mechanism changes, not when a page is added.
### 6.2 Key technical decisions
#### The page is the unit, not the component
**Decision:** stories render pages. A component's states are reached by turning
a control on the page that renders it.
**Rationale:** a component out of context proves nothing about the page. The
picture that matters is the one with the shell, the real spacing and the real
data around it. It also keeps the story count near the page count instead of
near the component count.
#### Controls are knobs on responses, never props
**Decision:** a control resolves through `handlers`, `config` or `effect`. A
control change re-registers the msw handlers and remounts the story with an
empty query cache.
**Rationale:** a prop-driven control shows a state the page cannot actually be
in. Driving the response means the page fetches, decides and renders the way it
does in production, including its own loading and empty branches.
#### Mocks are AI-owned, and marked as such
**Decision:** `*.stories.mocks.tsx` and `__story_mockdata__/**` carry a
generated banner enforced by the skill, and the root `.gitattributes` marks
those paths `linguist-generated=true` so GitHub collapses them in the diff.
```ts
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
```
The folder is `__story_mockdata__`, not `__mockdata__`: `src/mocks-server/__mockdata__`
already exists and is human-owned jest fixture data, so one glob cannot mark
both without collapsing files nobody agreed to hand over.
**Rationale:** the review target is the rendered page and the Chromatic diff. A
reviewer reading 300 lines of payload builders is spending attention on the one
part of the change that no human is expected to keep correct.
**Consequence:** mocks must be regenerable from the page alone. Nothing
page-specific goes into `src/storybook/`, and payload builders take their types
from `src/api/generated` where the endpoint has them, so a contract change is a
compile error rather than a mock that quietly lies.
#### A story is one page, and stays there
**Decision:** `lib/history` is aliased to a proxy over a memory history. A
navigation that keeps the pathname (query or hash) is applied; a different
pathname, an off-site href, `window.open` or a relative `go`/`goBack` is
swallowed and listed in `NavigationBlockedOverlay`.
**Rationale:** leaving the page unmounts the story, and following a real href
navigates the iframe out of Storybook. Tabs, filters and pagination are
query-param navigation, so they keep working, which is most of what a page story
needs. Nothing is dropped silently.
**Gap:** nuqs runs on its testing adapter with its own copy of the query string.
A page that writes params through both `useQueryState` and
`history.push({ search })` sees the two diverge inside a story.
#### Determinism is a Chromatic prerequisite
**Decision:** before Chromatic is turned on, mock data uses frozen dates and
charts render from static data with animation disabled.
**Rationale:** a snapshot has to be byte-identical between runs. Two live
sources of drift today: `appShellMocks` computes trial and payment-failed dates
from `Date.now()`, and uPlot charts animate in. Charts stay in the picture,
they just have to be the same picture every time.
#### Module aliases are typed against the real module
**Decision:** three modules are replaced for every story, each annotated
`typeof import('<real module>')`.
| Module | Replacement | Why |
|--------|-------------|-----|
| `lib/history` | `navigation/history.alias.ts` | containment, above |
| `api/common/logEvent` | `mocks/logEvent.mock.ts` | analytics never leave the iframe |
| `constants/env` | `mocks/env.mock.ts` | pins the API origin the handlers answer on |
**Rationale:** the annotation makes drift a compile error instead of a story that
fails at render in whichever component imports it. Same mechanism as jest's
`moduleNameMapper`, and each alias notes where the same import lands under jest.
### 6.3 Edge cases & failure modes
| Scenario | Behaviour |
|----------|-----------|
| Endpoint nobody mocked | catch-all logs `[storybook] no msw handler`, answers 501 |
| Page redirects on mount | navigation overlay on load, usually a wrong `route` or a guard denying |
| Shell disappears in `loading` | a shell endpoint went through `response.json`; it needs a plain resolver |
| Leftover AuthZ dev override | cleared on every render unless Access is `dev-tools` |
| A story sets a role the grant denies | impossible: `role` is derived from the grant |
| Project-level control added | the tab needs a reload; Vite hot-updates `preview.tsx` without re-preparing open stories |
| AuthZ dev overrides in a static build | inert, they need `IS_DEV`. Dev server only |
| State only reachable by interaction | not covered. See below |
**Play functions.** `storybook/test` is available and the module mocks are `fn()`
spies, so a `play` could open a dropdown, click a tab or assert on `logEvent`,
and Chromatic snapshots after `play` completes. That is the mechanism for states
no control can reach. Not used for now: controls only, deliberately, to keep the
authoring cost of a page at one skill run.
---
## 7. Alternatives considered
| Option | Pros | Cons | Why not chosen |
|--------|------|------|----------------|
| Component stories | small, cheap, conventional | proves nothing about the page; story count tracks component count; still needs mocked providers | a page story with controls covers the same states in context |
| Presentational `PageView` + args | no msw, no providers, no query cache; a state is one `args` object | requires splitting every page into a container and a view first; the page's own fetching, loading and empty branches are never exercised; each state is a named story, so the count grows fast | the split is a refactor of the 60 directories in `src/pages/` before the first story, and it moves the fetching the page actually does out of the picture |
| Jest snapshot tests | already in the repo | serialised DOM is not a picture; a token or CSS change is invisible | not a visual test |
### Prior art
Page-level stories are an established pattern, and the teams doing it split into
two camps over where the data comes from. The list below is what could be
verified in public repos and docs.
**The real connected page against msw**, which is what this proposal does:
| Who | What they do | Where |
|-----|--------------|-------|
| PostHog | the closest match to this proposal, and the largest. 104 `.stories.tsx` files import `scenes/App`: the story mounts the whole app and pushes a route (`router.actions.push(urls.logs())`), so it gets the real shell, not a page in isolation. `mswDecorator` maps each endpoint to a mock at the meta level, `layout: 'fullscreen'`, `mockDate` freezes the clock, and a seeded LCG supplies the random data. Feature flags are a story parameter. Fixtures are sized past the page's cap on purpose, so pagination and the truncation banner render | [`LogsScene.stories.tsx`](https://github.com/PostHog/posthog/blob/master/products/logs/frontend/LogsScene.stories.tsx) |
| Teleport (Gravitational) | the real page inside `TeleportProviderBasic`, with `beforeEach({ msw })` per story: `successGetUsers`, `handleGetUsers(() => delay('infinite'))` for loading, `errorGetUsers` for failed. The handlers come from `teleport/test/helpers/`, shared with the test suite. The preview starts one msw worker in a loader, clears the query cache per story, sets `retry: false`, and toggles the auth provider from an arg. 160 story files under `web/packages/teleport/src/` | [`Users.story.tsx`](https://github.com/gravitational/teleport/blob/master/web/packages/teleport/src/Users/Users.story.tsx), [`preview.tsx`](https://github.com/gravitational/teleport/blob/master/web/.storybook/preview.tsx) |
| Metabase | msw globally through `msw-storybook-addon`, plus explicit determinism work: `METABASE_REMOVE_DELAYS`, every font forced to load before render, and the lazy echarts chunk pulled into the bundle so a snapshot cannot catch a Suspense skeleton. Visual regression through Loki | [`preview.tsx`](https://github.com/metabase/metabase/blob/master/.storybook/preview.tsx) |
| Prefect | one msw worker in the preview, seeded from `@tests/utils/handlers`, the same handler set the tests use | [`preview.ts`](https://github.com/PrefectHQ/prefect/blob/main/ui-v2/.storybook/preview.ts) |
| Mealdrop (Yann Braga, Storybook maintainer) | the reference project for this pattern: the real routed page, a `withDeeplink` decorator, msw handlers, `Loading` via infinite delay, `NotFound` and `Error` via 404 and 500, and `play` functions driving the interactions | [`RestaurantDetailPage.stories.tsx`](https://github.com/yannbf/mealdrop/blob/main/src/pages/RestaurantDetailPage/RestaurantDetailPage.stories.tsx) |
**The presentational split**, where a `PageView` takes the data as props and the
fetching lives in a wrapper outside Storybook:
| Who | What they do | Where |
|-----|--------------|-------|
| Coder | 76 `*PageView.stories.tsx` files, part of 290 story files under `site/src/pages/`. `WorkspacesPageView` alone carries 23 named stories driven by args (`CannotCreateWorkspace`, `Loading`, `NoSearchResults`, `UnhealthyWorkspace`, `InvalidPageNumber`), with providers supplied by `withAuthProvider`, `withDashboardProvider`, `withProxyProvider()`. Chromatic gates the diffs | [`WorkspacesPageView.stories.tsx`](https://github.com/coder/coder/blob/main/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx) |
| Netlify | stories for a whole page (Team Overview) next to stories for its cards, reusing the fixture data their integration and unit tests already maintain, with Chromatic for the visual diff | [blog post](https://www.netlify.com/blog/storybook-visual-regression-testing/) |
| BBC, The Guardian, the Storybook maintainers | named in Storybook's own guidance as teams that keep components presentational up to the screen level and put the connected logic in one wrapper component outside Storybook | [Building pages with Storybook](https://storybook.js.org/docs/writing-stories/build-pages-with-storybook) |
---
## 8. Trade-offs & risks
### Trade-offs accepted
- Mocks can lie. Nothing checks a payload against the real API at runtime.
Typing builders from `src/api/generated` narrows it, it does not close it.
- Controls only. States behind an interaction are not covered until play
functions are adopted.
- msw v1 pinned by the jest suite.
- `.storybook/main.ts` carries an exclusion list of `vite.config.ts` plugins,
maintained by hand.
### Risks
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Mock drift: story green, production broken | High | Medium | builders typed from `src/api/generated`; E2E owns the real contract; drift shows up as a Chromatic diff on the next regeneration |
| Snapshot cost grows with pages × states × 2 themes | High | Medium | per-story `chromatic.disableSnapshot` to trim; snapshot the states worth watching |
| Flaky diffs from non-deterministic pixels | High | Medium | frozen dates, animations off, static chart data (Phase 2 prerequisite) |
---
## 9. Accessibility plan
`@storybook/addon-a11y` is in the addon list. It runs axe on the rendered
story, so a page gets an a11y pass in every state its controls can reach, which
is more than the app gets today.
Informational, not a gate: the addon reports on existing pages that were never
audited, so making it blocking would block on debt this work did not create.
Chromatic's review check is the only blocking gate.
---
## 10. Performance plan
- **No production impact.** `src/storybook/` and `src/harness/` are only reached
by stories. The msw worker is served from `.storybook/public/`, kept out of
`../public` so it cannot ship in a production build.
- **Per-story cost.** A control change remounts the tree with a fresh query
cache, which is the point: the page refetches. Remount is keyed on theme plus
the resolved control values, so nothing remounts without a reason.
- **`retry: false`** on the story query client, so a forced failure renders its
error state immediately instead of after three retries.
---
## 11. Rollout plan
### Phases
| Phase | Description | Status |
|-------|-------------|--------|
| **Phase 1: Home** | the story runtime and the `signoz-page-story` skill, proved by one page: Home | This PR |
| **Phase 2: Chromatic** | determinism prerequisites, CI job, blocking review check, snapshot exclusions | Not started |
| **Phase 3: Page fan-out** | more pages, gradually, one skill run each, the auth-less ones (login, signup, workspace-locked) included | Continuous |
No end date. Phase 3 runs for as long as there are pages worth covering, and a
page is added when someone is working on it.
### Phase 2 detail
- **Coverage:** every story, light and dark. A story can opt out with
`parameters.chromatic.disableSnapshot` when the cost is not worth it.
- **Gate:** Chromatic's review check is required. It stays red until a frontend
reviewer accepts the diffs, so an unreviewed visual change cannot merge.
- **Prerequisites:** frozen dates in mock data (`appShellMocks` computes banner
dates from `Date.now()` today), animations off, charts rendering the same
picture from static data.
- **Baseline:** main.
### Failure triage
A red story has two causes, and they are told apart before anything is edited:
1. **A real visual regression.** Fix the app.
2. **The component changed and the mocks no longer match it.** Regenerate the
mocks with the skill. This is expected, routine, and not a review event.
### Rollback
Phase 1 is additive: nothing in the app bundle depends on it, so removing it is
deleting directories. The two app-side changes (`AppHarness` extraction, the
`appContextMock` fixture) stand on their own and stay. Phase 2 rolls back by
making the Chromatic check non-required.
---
## 12. Testing strategy
Stories are not tests, and there are no assertions in them. What guards the
stories themselves:
### Per-story gates (the skill's `verify.md`)
- Console silent: no `[storybook] no msw handler`, no 501, no msw unhandled
request, no React warning.
- No navigation overlay on mount.
- Every control flipped once, with its effect seen on screen. Args are settable
from the iframe URL, so the sweep runs headless.
- Both themes render styled (`<body data-theme>` present).
- `<body data-signoz-story-role>` and `data-signoz-context-role` agree, which is
the tell that the page reads the `AppContext` the story filled.
- `pnpm tsgo --noEmit`, `pnpm exec oxlint`, `pnpm exec oxfmt --check` clean.
### The layers around it
| Layer | What it owns |
|-------|--------------|
| Jest (786 files) | component and hook behaviour |
| E2E (10 suites) | flows against a real backend, real contracts, migration correctness |
| Page stories | what the page looks like, in every state |
| Chromatic (Phase 2) | that it still looks like that |
A component migrated wrongly is E2E's to catch, not Chromatic's. A story that
renders a broken component still renders, and the diff only shows what moved.
---
## 13. Observability
N/A. Nothing here runs in production. `api/common/logEvent` is replaced by a
spy, and `preview-head.html` stubs `signozBootData` with posthog, sentry,
appcues and pylon all off, so no third-party script loads inside the iframe.
Phase 2 adds Chromatic build history, which is where the regression record
lives.
---
## 14. Design system check
- [x] Reuses existing components/patterns, no new UI pattern introduced
Two UI surfaces are added, both Storybook-only: `NavigationBlockedOverlay`
(lists what the page tried to navigate to) and the story controls panel, which
is Storybook's own. `applyThemeBodyClass` puts `<body>` in the state the app
gets from `index.html` plus `AppLayout`, including `data-theme="default"`,
without which every `@signozhq/design-tokens` semantic token resolves to nothing
and the page renders unstyled.
---
## 15. Timeline & milestones
| Milestone | Status |
|-----------|--------|
| Phase 1: Home | This PR |
| Phase 2: Chromatic + determinism | Not started, TODO |
| Phase 3: page fan-out | Continuous, no end date |
Effort estimate: TODO.
---
## 16. What would let a regression through
Ranked by likelihood, since a visual test that passes wrongly is worse than one
that fails.
| # | Hazard | Mitigation |
|---|--------|------------|
| 1 | **Mock payload no longer matches the API.** The page renders the mock's shape happily while production gets something else. | builders typed from `src/api/generated`; E2E owns the real contract |
| 2 | **A state exists that no control reaches.** It is never snapshotted, so it never regresses visibly. | the skill's inventory is the checklist: a branch in it and not in the panel is a bug in the story |
| 3 | **Non-deterministic pixels.** A diff every day teaches the team to click accept. | frozen dates, animations off, static chart data |
| 4 | **A story stops covering the page** because a widget silently stopped rendering (permission, flag, preference). | control sweep: a control with no visible effect is a finding, not a shrug |
| 5 | **Endpoint answered by a stale layer.** A page handler that does not match the call falls through to a shared handler, which answers something plausible. | endless spinner with Data on `loaded` is the symptom |
| 6 | **nuqs and history diverge** on a page that writes params through both. | known gap, documented; a page on one mechanism is unaffected |
| 7 | **Snapshot exclusions accumulate** until the gate covers little. | exclusions are a reviewed edit in the story file |

View File

@@ -7,6 +7,8 @@
"preinstall": "npx only-allow pnpm",
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
"dev": "vite",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",
@@ -158,6 +160,10 @@
"@commitlint/config-conventional": "20.4.4",
"@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",
"@testing-library/dom": "8.20.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "14.4.3",
@@ -203,6 +209,7 @@
"redux-mock-store": "1.5.4",
"sass": "1.97.3",
"sharp": "0.35.0",
"storybook": "10.5.9",
"stylelint": "17.7.0",
"svgo": "4.0.2",
"ts-jest": "29.4.9",

1097
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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

@@ -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,15 @@
import { ReactNode } from 'react';
import { ConfigProvider } from 'antd';
import { useThemeConfig } from 'hooks/useDarkMode';
/**
* `useThemeConfig` has to run under `ThemeProvider`, so the antd
* `ConfigProvider` lives in its own component the way `AppRoutes` does it.
*/
function AntdThemeBridge({ children }: { children: ReactNode }): JSX.Element {
const themeConfig = useThemeConfig();
return <ConfigProvider theme={themeConfig}>{children}</ConfigProvider>;
}
export default AntdThemeBridge;

View File

@@ -0,0 +1,125 @@
import { ReactNode } from 'react';
import { HelmetProvider } from 'react-helmet-async';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
// eslint-disable-next-line no-restricted-imports
import { Store } from 'redux';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { ThemeProvider } from 'hooks/useDarkMode';
import { NotificationProvider } from 'hooks/useNotifications';
import { ResourceProvider } from 'hooks/useResourceAttribute';
import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import {
QueryBuilderContext,
QueryBuilderProvider,
} from 'providers/QueryBuilder';
import TimezoneProvider from 'providers/Timezone';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import AntdThemeBridge from './AntdThemeBridge';
/**
* A layer the runner supplies. A render function rather than a component, so an
* inline one does not change identity between renders and remount the tree.
*/
export type HarnessWrapper = (children: ReactNode) => ReactNode;
export interface AppHarnessProps {
children: ReactNode;
/** Stands in for `AppProvider`, whose fetches no harness can make. */
appContext: IAppContext;
store: Store;
queryClient: QueryClient;
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
queryBuilder?: Partial<QueryBuilderContextType>;
/**
* The router the runner drives: jest a `MemoryRouter`, Storybook a `Router` on
* the contained history. Everything above it in the tree is router-free, so
* the choice stays here.
*/
router: HarnessWrapper;
/** The nuqs adapter: the react one under jest, the testing one in Storybook. */
searchParams: HarnessWrapper;
/** Rendered beside the subject: Storybook's palette, overlay and probe. */
overlays?: ReactNode;
}
/**
* The app's provider tree, as `src/index.tsx` and `src/AppRoutes/index.tsx`
* mount it, minus Sentry, posthog and `AppProvider`, with the pieces a test
* runner has to choose left as props. Storybook's `StorybookProviders` mounts
* it, so a story gets the tree production gets. jest's `test-utils` keeps its
* own smaller tree: ~20 files in the suite mock provider modules down to a
* single export, so the providers those modules also carry would come back
* `undefined`.
*/
function AppHarness({
children,
appContext,
store,
queryClient,
queryBuilder,
router,
searchParams,
overlays,
}: AppHarnessProps): JSX.Element {
const subject = queryBuilder ? (
<QueryBuilderContext.Provider value={queryBuilder as QueryBuilderContextType}>
{children}
</QueryBuilderContext.Provider>
) : (
<QueryBuilderProvider>{children}</QueryBuilderProvider>
);
return (
<HelmetProvider>
{searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
<AppContext.Provider value={appContext}>
<AntdThemeBridge>
{router(
<TooltipProvider>
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
<ResourceProvider>
<KeyboardHotkeysProvider>
<PreferenceContextProvider>
{subject}
{overlays}
</PreferenceContextProvider>
</KeyboardHotkeysProvider>
</ResourceProvider>
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
</TooltipProvider>,
)}
</AntdThemeBridge>
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
)}
</HelmetProvider>
);
}
AppHarness.defaultProps = {
queryBuilder: undefined,
overlays: undefined,
};
export default AppHarness;

View File

@@ -135,3 +135,5 @@ export const closeAuthZDevModal = (): void =>
useAuthZDevStore.getState().closeModal();
export const toggleAuthZDevModal = (): void =>
useAuthZDevStore.getState().toggleModal();
export const clearAllAuthZDevOverrides = (): void =>
useAuthZDevStore.getState().clearAllOverrides();

View File

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

View File

@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* Service to service calls as a force graph over `/api/v1/dependency_graph`,
* nodes sized by request rate and coloured by error rate, each opening its own
* side panel.
*
* Route: `/service-map`.
*/
const meta = {
title: 'Pages/Services/Service Map',
tags: ['beta', 'play'],
component: ServiceMapContainer,
decorators: [withAppLayout],
...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 = {};
/**
* 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 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 = canvas.getByTestId('resource-attributes-filter');
// 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 canvas.findByText('k8s.cluster.name', undefined, untilLoaded);
},
};

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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,240 @@
/**
* 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() } : {},
});
},
});

View File

@@ -0,0 +1,218 @@
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 { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import AIAssistantPage from './AIAssistantPage';
import { aiAssistantMocks } from './AIAssistantPage.stories.mocks';
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
type AIAssistantArgs = PageStoryArgs<typeof aiAssistantMocks>;
const pageStory = storyMocks(aiAssistantMocks);
/**
* 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,
decorators: [withAppLayout],
// 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$/,
);
},
};
/** The approval diff at full size, before against after. */
export const ApprovalDiff: Story = {
args: { agent: 'awaiting-approval', contents: BRIEF },
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(
() =>
within(canvasElement).findByLabelText(
/expand diff/i,
undefined,
untilLoaded,
),
/approval diff/i,
);
},
};
/** 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,
);
},
};

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 '../__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,53 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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 '../__story_mockdata__/alerts';
import {
alertFieldKeysResponse,
alertFieldValuesResponse,
alertMetricMetadataResponse,
alertMetricsResponse,
alertPreviewSeries,
} from '../__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,57 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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' },
};

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 '../__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,83 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { alertRulesMocks } from './AlertRules.stories.mocks';
import AlertList from '../index';
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
const pageStory = storyMocks(alertRulesMocks);
/**
* 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,
decorators: [withAppLayout],
...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 },
};
/**
* 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);
},
};
/** 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);
},
};

View File

@@ -0,0 +1,32 @@
/**
* 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 '../__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,50 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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 },
};
/**
* 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,48 @@
/**
* 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 {
CHANNEL_TYPES,
channelResponse,
type ChannelType,
} from '../__story_mockdata__/alerts';
const STORY_CHANNEL_ID = '1';
const CHANNEL = 'Channel · integration';
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',
}),
},
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', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.post('http://localhost/api/v1/testChannel', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
],
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
});

View File

@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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',
component: AlertList,
decorators: [withAppLayout],
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<ChannelsEditArgs>;
export default meta;
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 = {};
/**
* 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' },
};

View File

@@ -0,0 +1,28 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
/**
* The form holds the channel type in component state and posts on save, so the
* page has no response for a control to turn: the types are stories with a
* `play` that picks one. The write endpoints are here so Save and Test answer
* instead of falling through to the catch-all.
*/
export const channelsNewMocks = defineStoryMocks({
controls: {},
handlers: () => [
rest.post('http://localhost/api/v1/channels', (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
),
rest.post('http://localhost/api/v1/testChannel', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
],
config: () => ({ route: '/alerts/channels/new' }),
});

View File

@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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));
};
/** A new notification channel, on the Slack form the page opens with. */
export const Default: Story = {};
/** 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 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$/);
},
};

View File

@@ -0,0 +1,80 @@
/**
* 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 '../__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../types';
const LIST = 'Planned downtime · list';
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,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/downtime_schedules',
response.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',
response.json(() =>
alertRulesResponse(values.silencedRules, {
severity: 'mixed',
state: 'mixed',
}),
),
),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
}),
});

View File

@@ -0,0 +1,82 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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);
},
};

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,69 @@
/**
* 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 {
ROUTING_POLICY_MAX,
routingPoliciesResponse,
} from './__story_mockdata__/routingPolicies';
import {
CHANNEL_MAX,
channelNames,
channelsResponse,
} from '../__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../types';
const LIST = 'Routing policies · list';
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,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/route_policies',
response.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',
response.json(() => channelsResponse(values.channels)),
),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.ROUTING_POLICIES}`,
}),
});

View File

@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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);
},
};

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,56 @@
/**
* 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 '../__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}` }),
});

View File

@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { triggeredAlertsMocks } from './TriggeredAlerts.stories.mocks';
import AlertList from '../index';
import { AlertListTabs } from '../types';
type TriggeredAlertsArgs = PageStoryArgs<typeof triggeredAlertsMocks>;
const pageStory = storyMocks(triggeredAlertsMocks);
/**
* 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',
component: AlertList,
decorators: [withAppLayout],
...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 },
};
/**
* 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'])}`,
},
},
};

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,661 @@
/**
* 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 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',
] 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 }}',
description: '{{ .CommonAnnotations.summary }}',
},
],
},
},
];
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 },
};
};

View File

@@ -0,0 +1,97 @@
/**
* 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 {
EXCEPTION_CATALOGUE_SIZE,
EXCEPTION_QUICK_FILTER_CAP,
exceptionAttributeKeysResponse,
exceptionAttributeValuesResponse,
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/v1/orgs/me/filters/:signal',
response.json(() => exceptionQuickFiltersResponse(values.quickFilters)),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) =>
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_values',
response.json((req) =>
exceptionAttributeValuesResponse(
req.url.searchParams.get('attributeKey'),
req.url.searchParams.get('searchText'),
),
),
),
],
effect: (values) => {
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
// The quick-filter settings announcement is a first-run popover that covers
// the toolbar until it is closed, and closing it is what the app persists.
set(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, 'false');
},
});

View File

@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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 });
/**
* 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,
decorators: [withAppLayout],
...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 };
/**
* 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 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);
},
};

View File

@@ -0,0 +1,298 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
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 type { Filter } from 'types/api/quickFilters/getCustomFilters';
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 QUICK_FILTERS: Filter[] = [
{ key: 'service.name', dataType: 'string', type: 'resource' },
{ key: 'exceptionType', dataType: 'string', type: 'tag' },
{ key: 'deployment.environment', dataType: 'string', type: 'resource' },
{ key: 'telemetry.sdk.language', dataType: 'string', type: 'resource' },
{ key: 'host.name', dataType: 'string', type: 'resource' },
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
{ key: 'os.type', dataType: 'string', type: 'resource' },
{ key: 'hasError', dataType: 'bool', type: 'tag' },
];
export const EXCEPTION_QUICK_FILTER_CAP = QUICK_FILTERS.length;
export const exceptionQuickFiltersResponse = (
count: number,
): { status: string; data: { filters: Filter[]; signal: string } } => ({
status: 'success',
data: { filters: QUICK_FILTERS.slice(0, count), signal: 'exceptions' },
});
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(({ key, dataType, type }) => ({
key,
dataType: dataType === 'bool' ? DataTypes.bool : DataTypes.String,
type,
})),
{ 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),
),
},
};
};

View File

@@ -0,0 +1,342 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
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 {
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 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,
}),
},
handlers: (values, response) => [
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/v1/orgs/me/filters/:signal',
response.json((req) => ({
status: 'success',
data: { filters: [], signal: req.params.signal },
})),
),
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) => ({
status: 'success',
data: {
attributeKeys: groupByAttributeKeys(
req.url.searchParams.get('searchText') ?? '',
),
},
})),
),
// 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,84 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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',
component: ApiMonitoringPage,
decorators: [withAppLayout],
...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' },
};
/**
* 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/__story_mockdata__/alertQuery';
import {
channelsResponse,
CHANNEL_MAX,
} from '../AlertList/__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,61 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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',
component: CreateAlertPage,
decorators: [withAppLayout],
...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' },
};

View File

@@ -0,0 +1,261 @@
/**
* 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 {
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';
const LAYOUT = 'Dashboard · layout';
const DATA = 'Dashboard · panels';
const SHARING = 'Dashboard · sharing';
export const dashboardRoute = (): string =>
generatePath(ROUTES.DASHBOARD, { dashboardId: STORY_DASHBOARD_ID });
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,
});
},
});

View File

@@ -0,0 +1,89 @@
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 { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardMocks } from './DashboardPageV2.stories.mocks';
import DashboardPageV2 from './DashboardPageV2';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks);
/**
* 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'],
component: DashboardPageV2,
decorators: [withAppLayout],
// 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={DashboardPageV2} />
),
...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 },
};
/** The panels mid-fetch, with the header, variable bar and grid already laid out. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/** A dashboard id nobody has, which is what a deleted or mistyped link opens on. */
export const NotFound: Story = {
args: { notFound: true },
};

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 '../__story_mockdata__/dashboard';
import {
emptyPanelResponse,
NAMESPACE_VALUES,
panelResponse,
serviceVariableValues,
} from '../__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,64 @@
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 { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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',
component: PanelEditorPage,
decorators: [withAppLayout],
// 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' },
};
/** A locked dashboard: the editor still opens, but it cannot save. */
export const ReadOnly: Story = {
args: { locked: 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,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,163 @@
/**
* 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 { QueryParams } from 'constants/query';
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 {
emptyPanelResponse,
panelResponse,
} from '../DashboardPageV2/__story_mockdata__/panelData';
import {
EDITOR_FIELD_KEYS,
EDITOR_FIELD_VALUES,
EDITOR_METRICS,
} from '../DashboardPageV2/PanelEditorPage/__story_mockdata__/panelEditor';
import {
dashboardV1Response,
exportedQuery,
NEW_WIDGET_ID,
SAVED_WIDGET_ID,
STORY_DASHBOARD_ID,
WIDGET_PANEL_TYPES,
type WidgetPanelType,
} from './__story_mockdata__/dashboardWidget';
const WIDGET = 'Widget editor · widget';
const DATA = 'Widget editor · data';
const WIDGET_SOURCES = ['export', 'saved'] as const;
type WidgetSource = (typeof WIDGET_SOURCES)[number];
/**
* The route the explorers' "Add to dashboard" builds: the `new` path segment is
* the editor, and the widget id, panel type and exported query all ride the
* query string.
*/
const widgetRoute = (
source: WidgetSource,
panelType: WidgetPanelType,
): string => {
const path = generatePath(ROUTES.DASHBOARD_WIDGET, {
dashboardId: STORY_DASHBOARD_ID,
widgetId: 'new',
});
const params = new URLSearchParams({
[QueryParams.graphType]: panelType,
[QueryParams.widgetId]: source === 'export' ? NEW_WIDGET_ID : SAVED_WIDGET_ID,
});
if (source === 'export') {
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(exportedQuery(panelType))),
);
}
return `${path}?${params.toString()}`;
};
export const dashboardWidgetMocks = defineStoryMocks({
controls: {
source: choiceControl<WidgetSource>('Widget', {
group: WIDGET,
description:
'`export` is the explorer handing a query over to a brand-new widget; `saved` opens the editor on a widget the dashboard already holds.',
options: WIDGET_SOURCES,
value: 'export',
}),
panelType: choiceControl<WidgetPanelType>('Panel type', {
group: WIDGET,
description: 'The panel type the route opens on.',
options: WIDGET_PANEL_TYPES,
value: 'graph',
}),
noData: toggleControl('Preview returns nothing', {
group: DATA,
description: 'The preview query answers with an empty result.',
value: false,
}),
},
handlers: (values, response) => [
// The editor cannot render without the dashboard it saves into, so it
// answers on its own rather than through the Data control.
rest.get('http://localhost/api/v1/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(dashboardV1Response(values.panelType))),
),
// Saving the widget writes the whole v1 dashboard back; the editor navigates
// away on success, which the story reports as a blocked navigation.
rest.put('http://localhost/api/v1/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(dashboardV1Response(values.panelType))),
),
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 },
});
}),
),
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') ?? ''] ?? [],
),
),
),
],
config: (values) => ({ route: widgetRoute(values.source, values.panelType) }),
});

View File

@@ -0,0 +1,55 @@
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 { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardWidgetMocks } from './DashboardWidget.stories.mocks';
import DashboardWidget from './index';
type DashboardWidgetArgs = PageStoryArgs<typeof dashboardWidgetMocks>;
const pageStory = storyMocks(dashboardWidgetMocks);
/**
* The widget editor a v1 dashboard opens: one query per panel type against
* `query_range`.
*
* Route: `/dashboard/:dashboardId/:widgetId`.
*/
const meta = {
title: 'Pages/Dashboards/Widget Editor',
component: DashboardWidget,
decorators: [withAppLayout],
// The dashboard id comes out of the pathname and the widget out of the query
// string, so the editor renders under its own route.
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD_WIDGET} component={DashboardWidget} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardWidgetArgs>;
export default meta;
type Story = StoryObj<DashboardWidgetArgs>;
/**
* Where an explorer's "Add to dashboard" lands: the query it exported already in
* the builder, the preview above it, and the panel's options on the right,
* waiting to be saved into the dashboard.
*/
export const Default: Story = {};
/** Editing a widget the dashboard already holds, opened on its saved query. */
export const SavedWidget: Story = {
args: { source: 'saved' },
};
/** The same export as a table, which swaps the preview and the options pane. */
export const TableWidget: Story = {
args: { panelType: 'table' },
};

View File

@@ -0,0 +1,117 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type { MetricAggregation } from 'types/api/v5/queryRange';
import {
DataSource,
MetricAggregateOperator,
ReduceOperators,
} from 'types/common/queryBuilder';
export const STORY_DASHBOARD_ID = 'storybook-dashboard-1';
/** The id the explorer export puts in `widgetId`; nothing in the dashboard has it. */
export const NEW_WIDGET_ID = 'storybook-widget-new';
export const SAVED_WIDGET_ID = 'storybook-widget-saved';
/**
* Panel types the editor's own type switcher offers, as the `graphType` param
* spells them. Written as the enum's string values so a control option stays a
* plain string while still coming from the enum.
*/
export type WidgetPanelType = `${
| PANEL_TYPES.TIME_SERIES
| PANEL_TYPES.VALUE
| PANEL_TYPES.TABLE
| PANEL_TYPES.LIST
| PANEL_TYPES.BAR
| PANEL_TYPES.PIE
| PANEL_TYPES.HISTOGRAM}`;
export const WIDGET_PANEL_TYPES: readonly WidgetPanelType[] = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.VALUE,
PANEL_TYPES.TABLE,
PANEL_TYPES.LIST,
PANEL_TYPES.BAR,
PANEL_TYPES.PIE,
PANEL_TYPES.HISTOGRAM,
];
const EXPORTED_AGGREGATION: MetricAggregation = {
metricName: 'signoz_calls_total',
temporality: '',
timeAggregation: MetricAggregateOperator.RATE,
spaceAggregation: MetricAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
};
/**
* The exported query the explorer hands over in `compositeQuery`, built from the
* app's own seed so a change to the query-builder shape reaches the story.
*/
export const exportedQuery = (panelType: WidgetPanelType): Query => {
const seed = initialQueriesMap[DataSource.METRICS];
return {
...seed,
builder: {
...seed.builder,
queryData: seed.builder.queryData.map(
(data): IBuilderQuery => ({
...data,
aggregations: [EXPORTED_AGGREGATION],
legend: panelType === PANEL_TYPES.LIST ? '' : 'requests',
}),
),
},
};
};
const savedWidget = (panelType: WidgetPanelType): Widgets => ({
id: SAVED_WIDGET_ID,
panelTypes: panelType as PANEL_TYPES,
title: 'Request rate by service',
description: 'Calls per second, grouped by service.',
opacity: '1',
nullZeroValues: 'zero',
timePreferance: 'GLOBAL_TIME',
softMin: null,
softMax: null,
selectedLogFields: null,
selectedTracesFields: null,
query: exportedQuery(panelType),
});
export const dashboardV1Response = (
panelType: WidgetPanelType,
): { status: string; data: Dashboard } => ({
status: 'success',
data: {
id: STORY_DASHBOARD_ID,
createdAt: '2026-05-04T09:12:00Z',
updatedAt: '2026-08-21T16:40:00Z',
createdBy: 'ada@signoz.io',
updatedBy: 'ada@signoz.io',
locked: false,
data: {
title: 'Checkout service overview',
description: 'Traffic, errors and latency for the checkout path.',
tags: ['env:prod'],
variables: {},
widgets: [savedWidget(panelType)],
layout: [{ i: SAVED_WIDGET_ID, x: 0, y: 0, w: 6, h: 6 }],
panelMap: {},
version: 'v4',
},
},
});

View File

@@ -0,0 +1,303 @@
/**
* 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 '../DashboardPageV2/__story_mockdata__/dashboard';
import {
dashboardIdAt,
dashboardViewsResponse,
dashboardsListResponse,
orgUsersResponse,
recentDashboardIds,
ROW_MARKERS,
savedView,
seedPinnedDashboards,
setDashboardPinned,
STORY_USER_EMAIL,
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'),
});
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,90 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, screen, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardsListMocks } from './DashboardsListPageV2.stories.mocks';
import { BuiltinViewId } from './types';
import DashboardsListPageV2 from './DashboardsListPageV2';
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
const pageStory = storyMocks(dashboardsListMocks);
/**
* 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: DashboardsListPageV2,
decorators: [withAppLayout],
...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 query the backend refused: the parse error it returned replaces the
* generic failure copy, and there is nothing to retry.
*/
export const InvalidQuery: Story = {
args: { invalidQuery: true },
};
/** The New dashboard dialog, on its blank-dashboard tab. */
export const NewDashboard: 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');
},
};

View File

@@ -0,0 +1,338 @@
/**
* 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];
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/__story_mockdata__/alertQuery';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
CHANNEL_MAX,
type AlertSchema,
} from '../AlertList/__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,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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' },
};

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,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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,58 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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 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 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/__story_mockdata__/exceptions';
import type { ExceptionShape } from 'pages/AllErrors/__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 { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* The reset request form, before any session exists.
*
* Route: `/forgot-password`.
*/
const meta = {
title: 'Pages/Auth/Forgot Password',
tags: ['play'],
component: ForgotPassword,
decorators: [withAppLayout],
...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' },
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

@@ -0,0 +1,200 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import {
countControl,
choiceControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
buildAlertRules,
buildServices,
homeFeatureFlags,
homeUserPreferences,
HOME_CHECKLIST_STEPS,
isSavedViewSignal,
metricsOnboardingResponse,
queryRangeV5ScalarResponse,
recentDashboardsResponse,
SAVED_VIEW_SIGNALS,
type SavedViewSignal,
savedViewsResponse,
SERVICES_SOURCES,
type ServicesSource,
spanMetricsResponse,
topLevelOperationsResponse,
} from './__story_mockdata__/home';
const SIGNALS = 'Home · signals';
const ONBOARDING = 'Home · onboarding';
const LISTS = 'Home · lists';
const INGESTED_COUNT = 4213;
/** Home caps every list at five rows, so the control has to go past that. */
const LIST_MAX = 8;
const CHECKLIST_VISIBILITY = ['visible', 'dismissed'] as const;
type ChecklistVisibility = (typeof CHECKLIST_VISIBILITY)[number];
interface QueryRangeV5Body {
compositeQuery?: { queries?: { spec?: { signal?: string } }[] };
}
/**
* Home detects logs and traces with one `query_range` call each, told apart by
* the signal on the query spec.
*/
const signalOf = (body: QueryRangeV5Body): string | undefined =>
body.compositeQuery?.queries?.[0]?.spec?.signal;
export const homeMocks = defineStoryMocks({
controls: {
logsIngestion: toggleControl('Logs ingestion', {
group: SIGNALS,
value: true,
}),
tracesIngestion: toggleControl('Traces ingestion', {
group: SIGNALS,
value: true,
}),
metricsIngestion: toggleControl('Metrics ingestion', {
group: SIGNALS,
value: true,
}),
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
group: ONBOARDING,
description:
'Dismissing it moves the checklist behind the header button, as "I\'ll do this later" does.',
options: CHECKLIST_VISIBILITY,
value: 'visible',
}),
skippedSteps: multiChoiceControl('Skipped steps', {
group: ONBOARDING,
description: 'Steps the user chose to skip. Completion follows the data.',
options: HOME_CHECKLIST_STEPS,
value: [],
}),
alertRules: countControl('Alert rules', {
group: LISTS,
value: 5,
max: LIST_MAX,
}),
dashboards: countControl('Recent dashboards', {
group: LISTS,
value: 5,
max: LIST_MAX,
}),
savedViews: countControl('Saved views per signal', {
group: LISTS,
value: 5,
max: LIST_MAX,
}),
savedViewSignals: multiChoiceControl<SavedViewSignal>('Signals with views', {
group: LISTS,
description:
'Explorer tabs that have views; the rest fall back to their empty state.',
options: SAVED_VIEW_SIGNALS,
value: SAVED_VIEW_SIGNALS,
}),
services: countControl('Services', {
group: LISTS,
value: 6,
max: LIST_MAX,
}),
servicesSource: choiceControl<ServicesSource>('Services source', {
group: LISTS,
description:
'`span-metrics` turns on the feature flag that swaps the services card for the span-metrics one.',
options: SERVICES_SOURCES,
value: 'traces',
}),
},
handlers: (values, response) => [
rest.get('http://localhost/api/v2/metrics/onboarding', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(metricsOnboardingResponse(values.metricsIngestion)),
),
),
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
const signal = signalOf((await req.json()) as QueryRangeV5Body);
const isActive =
signal === 'traces' ? values.tracesIngestion : values.logsIngestion;
return res(
ctx.status(200),
ctx.json(queryRangeV5ScalarResponse(isActive ? INGESTED_COUNT : 0)),
);
}),
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: homeUserPreferences({
checklistDismissed: values.welcomeChecklist === 'dismissed',
skippedSteps: values.skippedSteps,
}),
}),
),
),
rest.get(
'http://localhost/api/v2/users/me/dashboards',
response.json(() => recentDashboardsResponse(values.dashboards)),
),
rest.get(
'http://localhost/api/v2/rules',
response.json(() => ({
status: 'success',
data: buildAlertRules(values.alertRules),
})),
),
rest.get(
'http://localhost/api/v1/explorer/views',
response.json((req) => {
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
return savedViewsResponse(
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
signal,
);
}),
),
rest.post(
'http://localhost/api/v2/services',
response.json(() => ({
status: 'success',
data: buildServices(values.services),
})),
),
rest.post(
'http://localhost/api/v1/service/top_level_operations',
response.json(() => topLevelOperationsResponse(values.services)),
),
rest.post(
'http://localhost/api/v4/query_range',
response.json(() => spanMetricsResponse()),
),
],
config: ({ servicesSource }) => ({
appContext: { featureFlags: homeFeatureFlags(servicesSource) },
}),
});

View File

@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
import { homeMocks } from './HomePage.stories.mocks';
import HomePage from './HomePage';
type HomeArgs = PageStoryArgs<typeof homeMocks>;
const pageStory = storyMocks(homeMocks, { route: ROUTES.HOME });
/**
* 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,
decorators: [withAppLayout],
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<HomeArgs>;
export default meta;
type Story = StoryObj<HomeArgs>;
/**
* Every widget carrying data: all three signals ingesting, alert rules across
* severities, recent dashboards, saved views on each explorer tab and a
* services table with failing services.
*/
export const Default: Story = {};
/** Fresh workspace: nothing ingested yet, so the welcome checklist takes over. */
export const NoIngestion: Story = {
args: {
logsIngestion: false,
tracesIngestion: false,
metricsIngestion: false,
alertRules: 0,
dashboards: 0,
savedViews: 0,
services: 0,
},
};
/**
* Telemetry reads only: no permission to manage anything, so the create actions
* and the legacy editor role are both gone.
*/
export const ViewerAccess: Story = {
args: { access: 'viewer' },
};
/** Widgets stuck in their loading state, shell included. */
export const Loading: Story = {
args: { dataState: 'loading' },
};

View File

@@ -0,0 +1,316 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import type { ServicesList } from 'types/api/metrics/getService';
import type { UserPreference } from 'types/api/preferences/preference';
import { baseUserPreferences } from '@/storybook/msw/__story_mockdata__/appShell';
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
export { queryRangeV5ScalarResponse };
/** Shapes follow the fields the components read, not the full generated DTOs. */
export const metricsOnboardingResponse = (
hasMetrics: boolean,
): Record<string, unknown> => ({
status: 'success',
data: { hasMetrics },
});
export const HOME_CHECKLIST_STEPS = [
'SEND_LOGS',
'SEND_TRACES',
'SEND_METRICS',
'SETUP_ALERTS',
'SETUP_SAVED_VIEWS',
'SETUP_DASHBOARDS',
] as const;
export type HomeChecklistStep = (typeof HOME_CHECKLIST_STEPS)[number];
const skippedPreference = (name: string): UserPreference => ({
name,
description: 'Welcome checklist step skipped',
valueType: 'boolean',
defaultValue: false,
allowedValues: ['true', 'false'],
allowedScopes: ['org'],
value: true,
});
/**
* The welcome checklist reads its dismissed and skipped flags off the user
* preferences list, one entry per step.
*/
export const homeUserPreferences = ({
checklistDismissed,
skippedSteps,
}: {
checklistDismissed: boolean;
skippedSteps: readonly HomeChecklistStep[];
}): UserPreference[] => [
...baseUserPreferences,
...(checklistDismissed
? [skippedPreference(ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER)]
: []),
...skippedSteps.map((step) =>
skippedPreference(checkListStepToPreferenceKeyMap[step]),
),
];
const DASHBOARDS = [
{
name: 'Kubernetes cluster health',
tags: [{ key: 'team', value: 'platform' }, { key: 'k8s' }],
},
{ name: 'API latency overview', tags: [{ key: 'sre' }] },
{
name: 'Checkout funnel',
tags: [{ key: 'team', value: 'payments' }, { key: 'business' }],
},
{ name: 'Postgres slow queries', tags: [{ key: 'database' }] },
{
name: 'Kafka consumer lag',
tags: [{ key: 'team', value: 'data' }, { key: 'streaming' }],
},
{ name: 'Ingress error budget', tags: [{ key: 'sre' }, { key: 'slo' }] },
{ name: 'Cost per service', tags: [{ key: 'finops' }] },
{
name: 'Redis cache hit rate',
tags: [{ key: 'database' }, { key: 'cache' }],
},
];
export const recentDashboardsResponse = (
count: number,
): Record<string, unknown> => ({
status: 'success',
data: {
dashboards: DASHBOARDS.slice(0, count).map((dashboard, index) => ({
id: `storybook-dashboard-${index + 1}`,
name: dashboard.name,
spec: { display: { name: dashboard.name } },
tags: dashboard.tags,
})),
},
});
const ALERT_NAMES = [
'Checkout p99 above 2s',
'Payment failure rate',
'Log volume spike',
'Kafka consumer lag',
'Pod restart storm',
'Disk usage above 85%',
'Frontend error rate',
'Postgres connections saturated',
];
/**
* Cycles the jest fixtures so the list keeps their severity and firing spread.
* `updatedAt` descends because that is the order the page sorts on.
*/
export const buildAlertRules = (count: number): RuletypesRuleDTO[] =>
Array.from({ length: count }, (_, index) => ({
...alertRulesFixture[index % alertRulesFixture.length],
id: `storybook-rule-${index + 1}`,
alert: ALERT_NAMES[index % ALERT_NAMES.length],
updatedAt: new Date(Date.UTC(2026, 7, 20 - index, 9)).toISOString(),
}));
export const SAVED_VIEW_SIGNALS = ['logs', 'traces', 'metrics'] as const;
export type SavedViewSignal = (typeof SAVED_VIEW_SIGNALS)[number];
const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
logs: [
'Checkout errors',
'Auth service warnings',
'Slow SQL statements',
'Payment webhooks',
'Rate limited requests',
'Cron job failures',
],
traces: [
'Slowest checkout spans',
'Failed payment traces',
'Cart to order funnel',
'External API calls',
'Cold start requests',
'Retried gRPC calls',
],
metrics: [
'Pod memory by namespace',
'Queue depth by topic',
'HTTP throughput',
'Container CPU throttling',
'JVM heap usage',
'Cache hit ratio',
],
};
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
export const savedViewsResponse = (
count: number,
sourcePage: SavedViewSignal,
): Record<string, unknown> => {
const names = VIEW_NAMES[sourcePage];
return {
status: 'success',
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
...explorerView.data[0],
id: `storybook-${sourcePage}-view-${index + 1}`,
name: names[index],
sourcePage,
tags: [sourcePage],
})),
};
};
/** Ordered by p99 with errors at both ends, so any slice keeps the spread. */
const SERVICES: ServicesList[] = [
{
serviceName: 'payments',
p99: 2_940_100_000,
avgDuration: 921_440_000,
numCalls: 46_080,
callRate: 25.6,
numErrors: 5990,
errorRate: 13,
},
{
serviceName: 'checkout',
p99: 1_248_900_000,
avgDuration: 486_310_000,
numCalls: 92_160,
callRate: 51.2,
numErrors: 4608,
errorRate: 5,
},
{
serviceName: 'frontend',
p99: 812_450_000,
avgDuration: 274_120_000,
numCalls: 184_320,
callRate: 102.4,
numErrors: 1843,
errorRate: 1,
},
{
serviceName: 'shipping',
p99: 486_200_000,
avgDuration: 192_800_000,
numCalls: 23_040,
callRate: 12.8,
numErrors: 691,
errorRate: 3,
},
{
serviceName: 'cart',
p99: 214_800_000,
avgDuration: 88_640_000,
numCalls: 138_240,
callRate: 76.8,
numErrors: 0,
errorRate: 0,
},
{
serviceName: 'catalogue',
p99: 96_300_000,
avgDuration: 41_220_000,
numCalls: 276_480,
callRate: 153.6,
numErrors: 276,
errorRate: 0.1,
},
{
serviceName: 'recommendations',
p99: 64_100_000,
avgDuration: 28_400_000,
numCalls: 61_440,
callRate: 34.1,
numErrors: 61,
errorRate: 0.1,
},
{
serviceName: 'notifications',
p99: 38_700_000,
avgDuration: 15_900_000,
numCalls: 12_288,
callRate: 6.8,
numErrors: 0,
errorRate: 0,
},
];
export const buildServices = (count: number): ServicesList[] =>
SERVICES.slice(0, count);
export const SERVICES_SOURCES = ['traces', 'span-metrics'] as const;
export type ServicesSource = (typeof SERVICES_SOURCES)[number];
/** `USE_SPAN_METRICS` swaps the services card for the span-metrics one. */
export const homeFeatureFlags = (source: ServicesSource): FeatureFlagProps[] =>
defaultFeatureFlags.map((flag) =>
flag.name === FeatureKeys.USE_SPAN_METRICS
? { ...flag, active: source === 'span-metrics' }
: flag,
);
/** The span-metrics card takes its row set from the top level operations. */
export const topLevelOperationsResponse = (count: number): ServiceDataProps =>
Object.fromEntries(
buildServices(count).map((service) => [
service.serviceName,
['HTTP GET /', 'HTTP POST /checkout'],
]),
);
/**
* Span-metrics latency, error rate and ops per second come off a table panel.
* The page reads them from `newResult`, which the table branch of
* `GetMetricQueryRange` never adds, so the columns render as zero however
* complete this body is. That is the app's gap, not the mock's.
*/
export const spanMetricsResponse = (): {
status: string;
data: MetricRangePayloadV3['data'];
} => ({
status: 'success',
data: {
resultType: 'table',
result: [
{
queryName: '',
legend: '',
series: null,
list: null,
table: {
columns: [
{ name: 'A', queryName: 'A', isValueColumn: true },
{ name: 'D', queryName: 'D', isValueColumn: true },
{ name: 'F1', queryName: 'F1', isValueColumn: true },
],
rows: [{ data: { A: 148_000_000, D: 12.4, F1: 1.8 } }],
},
},
],
},
});

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,58 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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' },
};
/** The table and the drawer widgets mid-load, shell included. */
export const Loading: Story = {
args: { dataState: 'loading' },
};

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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> = {};

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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,35 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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,32 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { infraStoryMocks } from '../InfrastructureMonitoring.stories.mocks';
/**
* 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,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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> = {};

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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',
component: InfrastructureMonitoringPage,
decorators: [withAppLayout],
...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> = {};

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { podsMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type PodsArgs = PageStoryArgs<typeof podsMocks>;
const pageStory = storyMocks(podsMocks);
/**
* 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',
component: InfrastructureMonitoringPage,
decorators: [withAppLayout],
...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> = {};

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
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);
/**
* 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,
decorators: [withAppLayout],
...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> = {};

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { volumesMocks } from './Kubernetes.stories.mocks';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type VolumesArgs = PageStoryArgs<typeof volumesMocks>;
const pageStory = storyMocks(volumesMocks);
/**
* The Kubernetes volumes tab: capacity, used and available per persistent volume
* claim.
*
* Route: `/infrastructure-monitoring/kubernetes?category=volumes`.
*/
const meta = {
title: 'Pages/Infrastructure/Kubernetes/Volumes',
component: InfrastructureMonitoringPage,
decorators: [withAppLayout],
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<VolumesArgs>;
export default meta;
/**
* Persistent volume claims by namespace, with usage against capacity and the
* inode counts beside it. The volume drawer hides its tab strip, so it opens on
* the metrics widgets and stays there.
*/
export const Default: StoryObj<VolumesArgs> = {};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockResolver } from '@/storybook/msw/types';
import {
ACCOUNT_CAP,
accountResponse,
accountsResponse,
CLOUD_PROVIDERS,
CLOUD_SERVICE_CAP,
type CloudProvider,
createdAccountResponse,
BUILT_IN_INTEGRATION_NOT_FOUND,
credentialsResponse,
seedServiceSignals,
serviceResponse,
servicesMetadataResponse,
setServiceSignals,
} from './__story_mockdata__/cloudIntegration';
/**
* Answered whatever the Data control says, because it is not the page's data:
* the request goes out and its failure is discarded either way.
*/
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
const PROVIDER = 'Cloud integration · provider';
const SERVICES = 'Cloud integration · services';
export const cloudIntegrationMocks = defineStoryMocks({
controls: {
provider: choiceControl<CloudProvider>('Provider', {
group: PROVIDER,
description:
'The last segment of the pathname, so it decides which provider the page is for: its logo and copy, which setup flow Add New Account opens, and the account configuration the settings drawer edits.',
options: CLOUD_PROVIDERS,
value: 'aws',
}),
accounts: countControl('Connected accounts', {
group: PROVIDER,
description:
'At 0 the hero offers Integrate Now and the service list is read-only: the page falls back to the provider catalogue and the collection switches are disabled.',
value: 1,
max: ACCOUNT_CAP,
}),
services: countControl('Services', {
group: SERVICES,
description:
'Services the provider answers with. GCP ships six, so anything past that shows the same list.',
value: 8,
max: CLOUD_SERVICE_CAP,
}),
enabledServices: countControl('Enabled', {
group: SERVICES,
description:
'How many of them have a signal switched on, taken from the top of the list. At 0 the sidebar says so and every service sits under Not Enabled.',
value: 3,
max: CLOUD_SERVICE_CAP,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/integrations/:integrationId',
builtInIntegrationNotFound,
),
rest.put(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id/services/:serviceId',
response.json(async (req) => {
const body = (await req.json()) as CloudintegrationtypesUpdatableServiceDTO;
setServiceSignals(String(req.params.serviceId), body.config ?? {});
return { status: 'success', data: null };
}),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id/services/:serviceId',
response.json((req) =>
serviceResponse(values.provider, String(req.params.serviceId), true),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id/services',
response.json(() =>
servicesMetadataResponse(values.provider, values.services, true),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/services/:serviceId',
response.json((req) =>
serviceResponse(values.provider, String(req.params.serviceId), false),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/services',
response.json(() =>
servicesMetadataResponse(values.provider, values.services, false),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/credentials',
response.json(() => credentialsResponse()),
),
rest.post(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
response.json(() => createdAccountResponse(values.provider)),
),
rest.put(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
response.json((req) =>
accountResponse(values.provider, String(req.params.id)),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
response.json((req) =>
accountResponse(values.provider, String(req.params.id)),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
response.json(() => accountsResponse(values.provider, values.accounts)),
),
],
config: (values) => ({
route: `/integrations/${values.provider}`,
}),
effect: (values) => {
seedServiceSignals(values.provider, values.services, values.enabledServices);
},
});

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