Compare commits

..

5 Commits

Author SHA1 Message Date
Abhi kumar
370b278f28 fix(dashboard): reserve legend rows the grid actually lays out (#12951)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Bottom legends could silently drop series. The legend box reserved
fewer rows than the grid actually laid out, and the surplus row was
clipped away by the wrapper's `overflow: hidden` — nothing indicated the
series were still there apart from a scrollbar.
- The cause is two different formulas for the same quantity: how many
legend items fit on one row. The height reservation in
`calculateChartDimensions` used `floor((containerWidth - padding) /
itemWidth)`. The grid resolves `auto-fill` over `--legend-item-width`,
which is `itemWidth + LEGEND_ITEM_EXTRA_WIDTH`, separated by a column
gap, inside a scroller with its own gutter. Ignoring the extra width,
the gap and the gutter, the reservation over-counts and reserves one row
where the grid needs two.
- The two formulas only diverge over a narrow band of widths, so the
failure is width-dependent and its boundary is a single pixel. A panel
sitting near that boundary flips between states as the layout reflows,
which is seen as flickering rather than as a fixed layout bug.
- Fix: `legendItemsPerRow` now mirrors the `auto-fill` track count. The
two CSS values it depends on are pinned as constants beside the existing
`LEGEND_ROW_HEIGHT` / `LEGEND_ROW_GAP`, which already carry the same
"must match the stylesheet" caveat.

#### Additional Information

- Adds a regression test at a width where the two formulas diverge; it
fails on `main`.
- Two pre-existing gaps left out of scope and unchanged by this PR:
- `MAX_SHORT_PANEL_LEGEND_RATIO` deliberately reserves a single row on
very short panels while the grid still lays out two, so the clip remains
there. Closing it needs somewhere for the dropped row's series to go —
an overflow affordance, which is a design decision.
2026-09-23 02:22:18 +00:00
Pandey
f2229a1064 fix(analytics): format segment logger messages before passing to slog (#12950)
#### Description

- segment's `Logger` interface is printf-style, but the adapter passed
`format` as the slog message and `args` as key-value pairs.
- slog never substituted the `%d` placeholders and rendered each
positional arg as a `!BADKEY` attr.
- `Logf` and `Errorf` now `fmt.Sprintf` the message first, matching the
opamp logger adapter.
2026-09-22 19:18:36 +00:00
Vinicius Lourenço
099832b26b chore(codeowners): change ownership of storybook (#12949)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

Add myself as owner of storybook structural files, the stories still
belongs to each pod.
2026-09-22 18:04:23 +00:00
Ashwin Bhatkal
057571cf6d fix(dashboard): restore related values and API search in dynamic variable dropdowns (#12935)
#### Description

The V1 to V2 dashboard rewrite carried over the *request* for a dynamic
variable's values but not the *response* handling — `relatedValues` and
`complete` were fetched and then thrown away. Both issues below are that
single regression.

- **Related values.** The dropdown now splits a dynamic variable's
values into "Related Values" (scoped by the sibling dynamic variables'
selections) and "All Values", as V1 did. The `existingQuery` that scopes
them was already being sent; only the response was ignored. Worth
knowing while reviewing: the backend never narrows the main list by
`existingQuery` — `GetAllValues` doesn't see it, and `GetRelatedValues`
returns nothing when it is empty — so the scoping is only ever visible
as the second section.
- **Value search.** A variable whose list the backend truncated
(`complete: false`) could only be filtered against the values already
fetched, so typing anything outside that first batch found nothing.
Search now goes to the API. It runs on its own react-query, deliberately
not the fetch engine's, so a keystroke cannot settle the variable's
fetch cycle and re-cascade its dependent variables and panels.
- **Retry action.** Restores V1's gating: the shared select defaults
`showRetryButton` to `true`, so a 4xx offered a retry that could only
fail again.

Commits are split by concern in that order.

#### Screen Recording


https://github.com/user-attachments/assets/ef51f481-de66-4334-9a59-dc98a7c7e50f

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/352
Closes https://github.com/SigNoz/pulse-pod/issues/249
2026-09-22 17:31:51 +00:00
Vinicius Lourenço
ccb6ef68c1 feat(storybook): add stories for each existing page (#12734)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### How to review this PR

You can pretty much ignore anything inside `<parent>/stories` folder
since this was generated by AI and should be maintained by AI. If you
want to suggest a change in a specific story, let's have a follow-up for
it.

You should focus to review the non-essential files that were changed,
such as the skills or storybook files.

#### Description

This adds stories for all reachable pages in the app, with few
variations in the state of the page.

This can be used as ground-work to later each team add more
customizations/states for their controlled pages, to ensure we are
covering all the states available and having a good coverage during
visual testing.

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

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

<img width="1867" height="1268" alt="image"
src="https://github.com/user-attachments/assets/78f2f10e-e8bb-4353-a78b-1a88df4bb545"
/>
2026-09-22 16:50:00 +00:00
295 changed files with 35009 additions and 75 deletions

View File

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

View File

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

View File

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

6
.github/CODEOWNERS vendored
View File

@@ -280,3 +280,9 @@ go.mod @therealpandey
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
## Storybook
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad
/.claude/skills/storybook-visual-diff/ @H4ad

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from '../CustomSelect';
@@ -203,4 +204,21 @@ describe('CustomSelect Component', () => {
// Check onChange was called
expect(handleChange).toHaveBeenCalled();
});
it('tells the consumer its search was cleared when the dropdown closes', async () => {
// The component clears its own search text on close. A consumer running a
// server-side search needs to hear that, or its results outlive the dropdown.
const onSearch = jest.fn();
const user = userEvent.setup();
render(<CustomSelect options={mockOptions} onSearch={onSearch} />);
const selectElement = screen.getByRole('combobox');
await user.click(selectElement);
await user.type(selectElement, 'opt');
expect(onSearch).toHaveBeenLastCalledWith('opt');
await user.keyboard('{Escape}');
expect(onSearch).toHaveBeenLastCalledWith('');
});
});

View File

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

View File

@@ -258,6 +258,10 @@ $custom-border-color: #2c3044;
overflow: hidden;
.group-label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
padding: 4px 12px;
font-size: 13px;
@@ -442,7 +446,7 @@ $custom-border-color: #2c3044;
.group-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-weight: 500;
padding: 4px 12px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,11 @@ export const MIN_LEGEND_ITEM_WIDTH = 110;
/** Marker + row padding, on top of the estimated label width. */
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
/** Must match `.gridList`'s column gap and `.scroller`'s padding-right, or the
* reserved row count disagrees with the grid that gets laid out. */
export const LEGEND_COLUMN_GAP = 8;
export const LEGEND_SCROLLER_PADDING_RIGHT = 4;
/** Must match `.row`'s height and the grid's row gap, or the reserved
* rectangle clips a row. */
export const LEGEND_ROW_HEIGHT = 28;

View File

@@ -113,7 +113,7 @@ describe('calculateChartDimensions', () => {
});
it('BOTTOM: items one past a row still reserve two rows', () => {
// 1000px wide fits 5 of these per row, so 6 items need a second row.
// 1000px wide fits 4 of these per row, so 6 items need a second row.
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
@@ -123,6 +123,19 @@ describe('calculateChartDimensions', () => {
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: reserves the rows the grid actually lays out, not the rows a bare width estimate allows', () => {
// The item width alone suggests three fit on one row; the grid's per-item
// padding and column gap leave room for two.
const dims = calculateChartDimensions({
containerWidth: 412,
containerHeight: 310,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: ['P99', 'P95', 'P50'],
});
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(240);
});
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,

View File

@@ -1,8 +1,11 @@
import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_COLUMN_GAP,
LEGEND_ITEM_EXTRA_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
LEGEND_SCROLLER_PADDING_RIGHT,
MAX_LEGEND_WIDTH,
} from 'lib/uPlotV2/components/Legend/constants';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
@@ -143,9 +146,16 @@ export function calculateChartDimensions({
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
// Must resolve to the same track count as `.gridList`'s `auto-fill`; a more
// generous one under-reserves rows and the grid's last row is clipped away.
const gridWidth =
containerWidth - LEGEND_PADDING * 2 - LEGEND_SCROLLER_PADDING_RIGHT;
const legendItemsPerRow = Math.max(
1,
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
Math.floor(
(gridWidth + LEGEND_COLUMN_GAP) /
(legendItemWidth + LEGEND_ITEM_EXTRA_WIDTH + LEGEND_COLUMN_GAP),
),
);
// The wrapper's bottom padding is inside this height (border-box).
@@ -163,8 +173,8 @@ export function calculateChartDimensions({
);
// Without this, short grid panels hand most of their area to the legend and
// the chart — the pie donut especially — collapses to a sliver. Dropping a
// whole row beats clipping one.
// the chart — the pie donut especially — collapses to a sliver. The dropped
// row's items are clipped rather than removed, so they are scroll-only here.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,12 @@ function renderSelector(
);
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
/** Hovers an element and lets the tooltip's open delay elapse. */
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
@@ -112,17 +118,95 @@ describe('ValueSelector', () => {
});
});
describe('a dynamic variable', () => {
function renderDynamic(
complete: boolean,
relatedValues: string[],
): jest.Mock {
const onSearch = jest.fn();
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="dynamic"
multiSelect
showAllOption
selection={{ value: [], allSelected: false }}
onChange={jest.fn()}
emptyFallback={{ value: [], allSelected: false }}
testId="variable-select-env"
dynamic={{
values: OPTIONS,
relatedValues,
complete,
onSearch,
onSearchReset: jest.fn(),
}}
/>
</TooltipProvider>,
);
return onSearch;
}
it('splits related values out of the full list', async () => {
renderDynamic(true, ['checkout-service-prod']);
await openDropdown();
expect(
screen.getByRole('heading', { level: 2, name: /Related Values/ }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { level: 2, name: /All Values/ }),
).toBeInTheDocument();
});
it('still opens its dropdown in single-select', async () => {
// The shared single select spreads unknown props over its own handlers, so
// passing it an `onDropdownVisibleChange` silently kills its open state.
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="dynamic"
multiSelect={false}
showAllOption={false}
selection={{ value: '', allSelected: false }}
onChange={jest.fn()}
emptyFallback={{ value: '', allSelected: false }}
testId="variable-select-env"
dynamic={{
values: OPTIONS,
relatedValues: [],
complete: false,
onSearch: jest.fn(),
onSearchReset: jest.fn(),
}}
/>
</TooltipProvider>,
);
await openDropdown();
expect(screen.getByText('cart-service-prod')).toBeInTheDocument();
});
it('routes typing to the API search when the list is truncated', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const onSearch = renderDynamic(false, []);
await openDropdown();
await user.keyboard('pay');
expect(onSearch).toHaveBeenLastCalledWith('pay');
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);

View File

@@ -114,4 +114,149 @@ describe('useFetchedVariableOptions', () => {
await waitFor(() => expect(result.current.options).toStrictEqual(['prod']));
});
it('keeps related values as their own section and as selectable options', async () => {
mockGetFieldValues.mockResolvedValue({
data: {
normalizedValues: ['cart', 'payments'],
relatedValues: ['checkout'],
complete: true,
},
});
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const variable = dynamicVariable('env');
const { result } = renderHook(
() => useFetchedVariableOptions(variable, [variable], {}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.dynamic?.relatedValues).toStrictEqual(['checkout']),
);
expect(result.current.dynamic?.values).toStrictEqual(['cart', 'payments']);
// A related value the unscoped list never returned is still selectable.
expect(result.current.options).toStrictEqual([
'cart',
'payments',
'checkout',
]);
});
it('sends the search to the API when the list is incomplete', async () => {
mockGetFieldValues.mockImplementation((_signal, _name, searchText) =>
Promise.resolve({
data: searchText
? { normalizedValues: ['payments'], relatedValues: [], complete: false }
: { normalizedValues: ['cart'], relatedValues: [], complete: false },
}),
);
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const variable = dynamicVariable('env');
const { result } = renderHook(
() => useFetchedVariableOptions(variable, [variable], {}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.dynamic?.values).toStrictEqual(['cart']),
);
act(() => {
result.current.dynamic?.onSearch('pay');
});
await waitFor(() =>
expect(result.current.dynamic?.values).toStrictEqual(['payments']),
);
expect(mockGetFieldValues).toHaveBeenCalledWith(
undefined,
'service.name',
'pay',
1_000,
2_000,
undefined,
expect.anything(),
);
// The search narrows the dropdown only — the selectable set is the full list,
// so a pick made before searching is never reconciled away.
expect(result.current.options).toStrictEqual(['cart']);
// Clearing falls straight back to the base fetch's options — synchronously, so
// closing the dropdown cannot leave the last search's results on screen for a
// debounce interval. They come from the cache of a separate query the search
// never touched, so nothing is refetched.
act(() => {
result.current.dynamic?.onSearchReset();
});
expect(result.current.dynamic?.values).toStrictEqual(['cart']);
expect(mockGetFieldValues).toHaveBeenCalledTimes(2);
});
it('marks a client error as not retryable', async () => {
mockGetFieldValues.mockRejectedValue(
Object.assign(new Error('bad request'), { response: { status: 400 } }),
);
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const variable = dynamicVariable('env');
const { result } = renderHook(
() => useFetchedVariableOptions(variable, [variable], {}),
{ wrapper },
);
await waitFor(() => expect(result.current.isRetryable).toBe(false));
});
it('scopes the fetch by a sibling dynamic selection, skipping ALL', async () => {
mockGetFieldValues.mockResolvedValue(fieldValues(['cart']));
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const env = dynamicVariable('env');
const namespace: VariableFormModel = {
...dynamicVariable('namespace'),
dynamicAttribute: 'k8s.namespace.name',
};
const region: VariableFormModel = {
...dynamicVariable('region'),
dynamicAttribute: 'cloud.region',
};
renderHook(
() =>
useFetchedVariableOptions(env, [env, namespace, region], {
namespace: { value: ['prod'], allSelected: false },
// ALL means "no filter", so it contributes nothing to existingQuery —
// which is why the backend returns no related values for it.
region: { value: null, allSelected: true },
}),
{ wrapper },
);
await waitFor(() =>
expect(mockGetFieldValues).toHaveBeenCalledWith(
undefined,
'service.name',
undefined,
1_000,
2_000,
"k8s.namespace.name = 'prod'",
),
);
});
});

View File

@@ -4,7 +4,9 @@ import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import type { DynamicVariableOptions } from '../../hooks/useFetchedVariableOptions';
import type { VariableSelection } from '../../selectionTypes';
import { dynamicVariableOptions } from '../../utils/dynamicVariableOptions';
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
import OverflowValuesTooltip from './OverflowValuesTooltip';
@@ -24,6 +26,10 @@ interface ValueSelectorProps {
/** Option-fetch error surfaced in the dropdown, with a retry action. */
errorMessage?: string | null;
onRetry?: () => void;
/** Hides the retry action for an error that retrying cannot fix. */
isRetryable?: boolean;
/** DYNAMIC only: sectioned rendering and server-side search. */
dynamic?: DynamicVariableOptions;
}
function ValueSelector({
@@ -38,10 +44,15 @@ function ValueSelector({
testId,
errorMessage,
onRetry,
isRetryable = true,
dynamic,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
() =>
dynamic
? dynamicVariableOptions(dynamic.values, dynamic.relatedValues)
: options.map((option) => ({ label: option, value: option })),
[options, dynamic],
);
// All-selected → the full option set so CustomMultiSelect engages its "all"
@@ -119,6 +130,7 @@ function ValueSelector({
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showRetryButton={isRetryable}
showSearch
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
@@ -136,6 +148,11 @@ function ValueSelector({
)}
// Offer ALL only once options load, else a concrete value reads as "all".
enableAllSelection={showAllOption && options.length > 0}
isDynamicVariable={!!dynamic}
onSearch={dynamic?.onSearch}
showIncompleteDataMessage={
!!dynamic && !dynamic.complete && dynamic.values.length > 0
}
onDropdownVisibleChange={(open): void => {
if (open) {
setDraft(committedValues);
@@ -144,6 +161,7 @@ function ValueSelector({
}
setIsOpen(false);
dynamic?.onSearchReset();
commit(draft);
}}
onChange={(next): void => {
@@ -180,8 +198,14 @@ function ValueSelector({
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showRetryButton={isRetryable}
showSearch
placeholder="Select value"
isDynamicVariable={!!dynamic}
onSearch={dynamic?.onSearch}
showIncompleteDataMessage={
!!dynamic && !dynamic.complete && dynamic.values.length > 0
}
onChange={(next): void => {
void logEvent(
DashboardDetailEvents.VariableValueSelected,

View File

@@ -42,11 +42,8 @@ function VariableValueControl({
onChange,
onAutoSelect,
}: VariableValueControlProps): JSX.Element {
const { options, loading, errorMessage, onRetry } = useVariableOptions(
variable,
variables,
selections,
);
const { options, loading, errorMessage, onRetry, isRetryable, dynamic } =
useVariableOptions(variable, variables, selections);
useAutoSelect(variable, options, selection, onAutoSelect);
@@ -65,6 +62,8 @@ function VariableValueControl({
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
isRetryable={isRetryable}
dynamic={dynamic}
selection={selection}
onChange={onChange}
emptyFallback={emptyFallback}

View File

@@ -0,0 +1,89 @@
import { useCallback, useState } from 'react';
import { useQuery } from 'react-query';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
interface UseDynamicVariableSearchProps {
signal?: 'traces' | 'logs' | 'metrics';
attribute?: string;
startUnixMilli: number;
endUnixMilli: number;
existingQuery?: string;
/** Only a truncated list needs the API — a complete one is filtered in the dropdown. */
enabled: boolean;
}
export interface DynamicVariableSearch {
/** Results while a server search is in effect, else null — render the base options. */
results: { values: string[]; relatedValues: string[] } | null;
isSearching: boolean;
onSearch: (text: string) => void;
reset: () => void;
}
/**
* Server-side value search for a DYNAMIC variable, deliberately kept off the fetch
* engine's own query: a keystroke must not settle the variable's fetch cycle and
* re-cascade its dependent variables and panels.
*/
export function useDynamicVariableSearch({
signal,
attribute,
startUnixMilli,
endUnixMilli,
existingQuery,
enabled,
}: UseDynamicVariableSearchProps): DynamicVariableSearch {
const [searchText, setSearchText] = useState('');
const debouncedSearchText = useDebounce(searchText, DEBOUNCE_DELAY);
const isActive =
enabled && !!attribute && !!searchText && !!debouncedSearchText;
const { data, isFetching } = useQuery(
[
'dashboard-variable-dynamic-search',
signal,
attribute,
debouncedSearchText,
existingQuery,
startUnixMilli,
endUnixMilli,
],
({ signal: abortSignal }) =>
getFieldValues(
signal,
attribute,
debouncedSearchText,
startUnixMilli,
endUnixMilli,
existingQuery,
abortSignal,
),
{ enabled: isActive, refetchOnWindowFocus: false, keepPreviousData: true },
);
const reset = useCallback((): void => setSearchText(''), []);
// No results yet falls back to the base options rather than an empty dropdown:
// the select filters them locally, so the list narrows while the API answers.
const results = isActive ? data?.data : undefined;
if (!results) {
return {
results: null,
isSearching: isActive && isFetching,
onSearch: setSearchText,
reset,
};
}
return {
results: {
values: results.normalizedValues ?? [],
relatedValues: results.relatedValues ?? [],
},
isSearching: isFetching,
onSearch: setSearchText,
reset,
};
}

View File

@@ -9,6 +9,7 @@ import {
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import { isRetryableError } from 'utils/errorUtils';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
@@ -20,13 +21,29 @@ import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../utils/dynamicFilter';
import type { VariableSelectionMap } from '../selectionTypes';
import { selectionToPayload } from '../utils/selectionUtils';
import { useDynamicVariableSearch } from './useDynamicVariableSearch';
import { useVariableFetchState } from './useVariableFetchState';
export interface DynamicVariableOptions {
/** ALL VALUES section — narrowed to the API's matches while a search is active. */
values: string[];
/** RELATED VALUES section — scoped by the sibling dynamic variables' selections. */
relatedValues: string[];
/** false when the backend truncated the list, so searching has to hit the API. */
complete: boolean;
onSearch: (text: string) => void;
onSearchReset: () => void;
}
export interface VariableOptions {
options: string[];
loading: boolean;
errorMessage: string | null;
onRetry?: () => void;
/** false for a client error, where retrying the same request cannot help. */
isRetryable?: boolean;
/** DYNAMIC only: what the dropdown renders, sectioned and search-aware. */
dynamic?: DynamicVariableOptions;
}
/**
@@ -150,10 +167,68 @@ export function useFetchedVariableOptions(
return sortValuesByOrder(values, variable.sort).map(String);
}, [dynamicResult.data, variable.sort]);
const dynamicRelatedOptions = useMemo(
() =>
sortValuesByOrder(
dynamicResult.data?.data?.relatedValues ?? [],
variable.sort,
).map(String),
[dynamicResult.data, variable.sort],
);
// Related values are scoped by the sibling selections, so they can name values the
// unscoped list never returned — the selectable set is the union of both sections.
const dynamicSelectableOptions = useMemo(
() => [...new Set([...dynamicOptions, ...dynamicRelatedOptions])],
[dynamicOptions, dynamicRelatedOptions],
);
const isDynamicListComplete = dynamicResult.data?.data?.complete ?? true;
const search = useDynamicVariableSearch({
signal: signalForApi(variable.dynamicSignal),
attribute: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: variable.type === 'DYNAMIC' && !isDynamicListComplete,
});
// One stable object: the select rebuilds its whole option list whenever this
// identity changes, so it must not be a literal rebuilt on every render.
const dynamicDisplay = useMemo<DynamicVariableOptions>(() => {
const display = search.results
? {
values: sortValuesByOrder(search.results.values, variable.sort).map(
String,
),
relatedValues: sortValuesByOrder(
search.results.relatedValues,
variable.sort,
).map(String),
}
: { values: dynamicOptions, relatedValues: dynamicRelatedOptions };
return {
...display,
complete: isDynamicListComplete,
onSearch: search.onSearch,
onSearchReset: search.reset,
};
}, [
search.results,
search.onSearch,
search.reset,
isDynamicListComplete,
dynamicOptions,
dynamicRelatedOptions,
variable.sort,
]);
// Flag a variable that settled with zero options so dependent panels fall through
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
const effectiveOptions =
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
variable.type === 'DYNAMIC' ? dynamicSelectableOptions : queryOptions;
useEffect(() => {
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
return;
@@ -175,14 +250,16 @@ export function useFetchedVariableOptions(
if (variable.type === 'DYNAMIC') {
return {
options: dynamicOptions,
loading: dynamicResult.isFetching || isVariableWaiting,
options: dynamicSelectableOptions,
loading: dynamicResult.isFetching || isVariableWaiting || search.isSearching,
errorMessage: dynamicResult.error
? (dynamicResult.error as Error).message || null
: null,
onRetry: (): void => {
void dynamicResult.refetch();
},
isRetryable: !dynamicResult.error || isRetryableError(dynamicResult.error),
dynamic: dynamicDisplay,
};
}
return {
@@ -194,5 +271,6 @@ export function useFetchedVariableOptions(
onRetry: (): void => {
void queryResult.refetch();
},
isRetryable: !queryResult.error || isRetryableError(queryResult.error),
};
}

View File

@@ -0,0 +1,27 @@
import type { OptionData } from 'components/NewSelect/types';
const toOptions = (values: string[]): OptionData[] =>
values.map((value) => ({ label: value, value }));
/**
* Dropdown options for a DYNAMIC variable: values scoped by the other dynamic
* variables' selections get their own section above the unscoped list. Without
* related values there is nothing to contrast, so the list stays flat.
*/
export function dynamicVariableOptions(
values: string[],
relatedValues: string[],
): OptionData[] {
if (relatedValues.length === 0) {
return toOptions(values);
}
return [
{
label: 'Related Values',
value: 'relatedValues',
options: toOptions(relatedValues),
},
{ label: 'All Values', value: 'allValues', options: toOptions(values) },
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertFieldKeysResponse,
alertFieldValuesResponse,
alertMetricMetadataResponse,
alertMetricsResponse,
alertPreviewSeries,
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
CHANNEL_MAX,
type AlertSchema,
} from '../../AlertList/stories/__story_mockdata__/alerts';
const STORY_RULE_ID = 'rule-1';
const RULE = 'Edit rule · rule';
export const editRulesMocks = defineStoryMocks({
controls: {
alertSchema: choiceControl<AlertSchema>('Alert schema', {
group: RULE,
description:
'`classic` is the single-form page this route was built for. `v2` throws: the new form reads `CreateAlertProvider`, which only `pages/AlertDetails` mounts, so this route crashes on any rule saved on the current schema.',
options: ALERT_SCHEMAS,
value: 'classic',
}),
channels: countControl('Notification channels', {
group: RULE,
value: 5,
max: CHANNEL_MAX,
}),
previewSeries: countControl('Preview series', {
group: RULE,
description: 'Lines the chart above the condition draws.',
value: 3,
max: 6,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/rules/:id',
response.json((req) =>
alertRuleByIdResponse(String(req.params.id), {
severity: 'critical',
state: 'firing',
schema: values.alertSchema,
}),
),
),
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
}),
),
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
),
rest.get(
'http://localhost/api/v2/metrics',
response.json((req) =>
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v2/metrics/metadata',
response.json((req) =>
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/keys',
response.json((req) =>
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
alertFieldValuesResponse(
req.url.searchParams.get('name') ?? '',
req.url.searchParams.get('searchText') ?? '',
),
),
),
],
config: () => ({
route: `${ROUTES.EDIT_ALERTS}?ruleId=${STORY_RULE_ID}&relativeTime=6h`,
}),
});

View File

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

View File

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

View File

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

View File

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

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