mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-21 19:00:42 +01:00
Compare commits
141 Commits
fix/ui-tes
...
feat/add-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b442844f0b | ||
|
|
7dda74db53 | ||
|
|
8e71a96244 | ||
|
|
1d42399d8e | ||
|
|
235496bda2 | ||
|
|
31337bb3b3 | ||
|
|
2b28da96de | ||
|
|
c31b845a1d | ||
|
|
baafb7a723 | ||
|
|
558c4d7cbe | ||
|
|
c9be3c98e6 | ||
|
|
113c744395 | ||
|
|
c1daa3ec63 | ||
|
|
72b30684b4 | ||
|
|
b2f992ef5f | ||
|
|
3fc63da52c | ||
|
|
d0a91261a3 | ||
|
|
81afa641a2 | ||
|
|
e3db2d824b | ||
|
|
314bacfc7c | ||
|
|
ea698bb952 | ||
|
|
e756dec84d | ||
|
|
50a3927c87 | ||
|
|
4279d60f70 | ||
|
|
1a57cb4948 | ||
|
|
d3f38fc961 | ||
|
|
191a029cab | ||
|
|
030146b201 | ||
|
|
e243b00561 | ||
|
|
562e21e036 | ||
|
|
58e62342fe | ||
|
|
077a9e14f7 | ||
|
|
dd71f54b1a | ||
|
|
81dd301273 | ||
|
|
6ebfb44708 | ||
|
|
16a947a9e0 | ||
|
|
9edf2a19c7 | ||
|
|
40227aa010 | ||
|
|
793b1d24a5 | ||
|
|
16f70a0dba | ||
|
|
abe3e5b833 | ||
|
|
3efe81571e | ||
|
|
dfcf85d499 | ||
|
|
1de77074b1 | ||
|
|
8ae0ee45f8 | ||
|
|
80dce376d1 | ||
|
|
b6af98a0a8 | ||
|
|
191e039890 | ||
|
|
9134d0a854 | ||
|
|
5ff24cbc67 | ||
|
|
2814ec8988 | ||
|
|
f542b1de05 | ||
|
|
9f6c6af4c2 | ||
|
|
9d15f22ca6 | ||
|
|
2bbda851dd | ||
|
|
e3a2998412 | ||
|
|
b8bebadda9 | ||
|
|
f2e2327664 | ||
|
|
d12d3cb62f | ||
|
|
bfa7482548 | ||
|
|
6e4e2c8ec4 | ||
|
|
c79511637d | ||
|
|
ef72bf1b36 | ||
|
|
864539cac7 | ||
|
|
1051051dc8 | ||
|
|
eff954b49d | ||
|
|
c64e898b32 | ||
|
|
b977149cfd | ||
|
|
57c9011e39 | ||
|
|
72333a75a9 | ||
|
|
849c77d06b | ||
|
|
8ae9914143 | ||
|
|
3e2326b475 | ||
|
|
f4b1889558 | ||
|
|
2b1bb1a3c7 | ||
|
|
cbea5a528c | ||
|
|
5b13ae2ca3 | ||
|
|
c23f0a0a28 | ||
|
|
ff244f53c6 | ||
|
|
138b14d14a | ||
|
|
51fd759c71 | ||
|
|
c6155d313f | ||
|
|
8166da8158 | ||
|
|
991ca7573c | ||
|
|
09d07ca3a6 | ||
|
|
11cb979f27 | ||
|
|
8a8fe5371c | ||
|
|
9a3c1ad181 | ||
|
|
36a452d790 | ||
|
|
b053f8b255 | ||
|
|
6759d3d188 | ||
|
|
9613130cc0 | ||
|
|
9d9be599eb | ||
|
|
ca27cb3494 | ||
|
|
97f6b99707 | ||
|
|
dcf68caf6c | ||
|
|
364d6b52f1 | ||
|
|
e1ac3f7b1b | ||
|
|
a205710d7f | ||
|
|
bcf32dec1f | ||
|
|
f1f63b7fa7 | ||
|
|
81e8bff6fa | ||
|
|
e5699b1461 | ||
|
|
8882260445 | ||
|
|
3569ba7a61 | ||
|
|
f80c2b4fc4 | ||
|
|
8290a1d9fd | ||
|
|
67c650c62d | ||
|
|
255061b53f | ||
|
|
94befc2a1d | ||
|
|
19926f9e20 | ||
|
|
5e86f134d7 | ||
|
|
05a33d82ae | ||
|
|
9630622f28 | ||
|
|
b8853a0556 | ||
|
|
62aac2ac7b | ||
|
|
3167bf13ea | ||
|
|
8501d6bc5f | ||
|
|
50acd473e6 | ||
|
|
811c66af36 | ||
|
|
d023e48635 | ||
|
|
3fa5a754f3 | ||
|
|
c306fb1a6d | ||
|
|
d4affb3452 | ||
|
|
afbaad9511 | ||
|
|
43e79a249a | ||
|
|
fcea1b86ce | ||
|
|
aa93c94e0f | ||
|
|
30eb205623 | ||
|
|
9284e40cfc | ||
|
|
a3220483ff | ||
|
|
4461c07231 | ||
|
|
44bf33669f | ||
|
|
755cb5ae0f | ||
|
|
c7f65e8cb4 | ||
|
|
c2dc2e7f46 | ||
|
|
42aa496f54 | ||
|
|
a7af3761dd | ||
|
|
704a9a18b9 | ||
|
|
d86a910e24 | ||
|
|
892ef131d7 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
|
||||
41
frontend/plugins/rules/no-msw-in-story-file.mjs
Normal file
41
frontend/plugins/rules/no-msw-in-story-file.mjs
Normal 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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
48
frontend/pnpm-lock.yaml
generated
48
frontend/pnpm-lock.yaml
generated
@@ -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': {}
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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...');
|
||||
},
|
||||
};
|
||||
28
frontend/src/components/FieldsSelector/stories/FieldsSelector.stories.mocks.tsx
generated
Normal file
28
frontend/src/components/FieldsSelector/stories/FieldsSelector.stories.mocks.tsx
generated
Normal 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([]))),
|
||||
),
|
||||
];
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
156
frontend/src/components/NewSelect/stories/NewSelect.stories.tsx
Normal file
156
frontend/src/components/NewSelect/stories/NewSelect.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
15
frontend/src/components/NotFound/stories/NotFound.stories.mocks.tsx
generated
Normal file
15
frontend/src/components/NotFound/stories/NotFound.stories.mocks.tsx
generated
Normal 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' }),
|
||||
});
|
||||
@@ -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 = {};
|
||||
126
frontend/src/components/QuickFilters/stories/QuickFilters.stories.mocks.tsx
generated
Normal file
126
frontend/src/components/QuickFilters/stories/QuickFilters.stories.mocks.tsx
generated
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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] },
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
54
frontend/src/components/TableV3/stories/TableV3.stories.tsx
Normal file
54
frontend/src/components/TableV3/stories/TableV3.stories.tsx
Normal 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: [] },
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
131
frontend/src/modules/Servicemap/stories/ServiceMap.stories.mocks.tsx
generated
Normal file
131
frontend/src/modules/Servicemap/stories/ServiceMap.stories.mocks.tsx
generated
Normal 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) }),
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
354
frontend/src/modules/Servicemap/stories/__story_mockdata__/serviceMap.ts
generated
Normal file
354
frontend/src/modules/Servicemap/stories/__story_mockdata__/serviceMap.ts
generated
Normal 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]);
|
||||
47
frontend/src/modules/Usage/stories/Usage.stories.mocks.tsx
generated
Normal file
47
frontend/src/modules/Usage/stories/Usage.stories.mocks.tsx
generated
Normal 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 }),
|
||||
});
|
||||
36
frontend/src/modules/Usage/stories/Usage.stories.tsx
Normal file
36
frontend/src/modules/Usage/stories/Usage.stories.tsx
Normal 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 },
|
||||
};
|
||||
54
frontend/src/modules/Usage/stories/__story_mockdata__/usage.ts
generated
Normal file
54
frontend/src/modules/Usage/stories/__story_mockdata__/usage.ts
generated
Normal 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)),
|
||||
}));
|
||||
};
|
||||
272
frontend/src/pages/AIAssistantPage/stories/AIAssistantPage.stories.mocks.tsx
generated
Normal file
272
frontend/src/pages/AIAssistantPage/stories/AIAssistantPage.stories.mocks.tsx
generated
Normal 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,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
731
frontend/src/pages/AIAssistantPage/stories/__story_mockdata__/aiAssistant.ts
generated
Normal file
731
frontend/src/pages/AIAssistantPage/stories/__story_mockdata__/aiAssistant.ts
generated
Normal 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.',
|
||||
});
|
||||
158
frontend/src/pages/AlertList/AlertHistory/stories/AlertHistory.stories.mocks.tsx
generated
Normal file
158
frontend/src/pages/AlertList/AlertHistory/stories/AlertHistory.stories.mocks.tsx
generated
Normal 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}`,
|
||||
}),
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
216
frontend/src/pages/AlertList/AlertHistory/stories/__story_mockdata__/alertHistory.ts
generated
Normal file
216
frontend/src/pages/AlertList/AlertHistory/stories/__story_mockdata__/alertHistory.ts
generated
Normal 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 },
|
||||
},
|
||||
};
|
||||
};
|
||||
148
frontend/src/pages/AlertList/AlertOverview/stories/AlertOverview.stories.mocks.tsx
generated
Normal file
148
frontend/src/pages/AlertList/AlertOverview/stories/AlertOverview.stories.mocks.tsx
generated
Normal 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}`,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertOverviewMocks } from './AlertOverview.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type AlertOverviewArgs = PageStoryArgs<typeof alertOverviewMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertOverviewMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* One rule read only: its condition, the series it evaluates against, its state
|
||||
* and the channels it notifies.
|
||||
*
|
||||
* Route: `/alerts/overview?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Overview',
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AlertOverviewArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AlertOverviewArgs>;
|
||||
|
||||
/**
|
||||
* One alert rule opened up: the query it watches, the condition it fires on and
|
||||
* where the notification goes.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A rule written before the current schema, which opens in the classic form. */
|
||||
export const ClassicSchema: Story = {
|
||||
args: { alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/** A rule someone turned off: the toggle in the header is what turns it back on. */
|
||||
export const Disabled: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
};
|
||||
|
||||
/** A rule with no matching series in the window, so the preview has nothing to draw. */
|
||||
export const NoPreviewData: Story = {
|
||||
args: { previewSeries: 0 },
|
||||
};
|
||||
|
||||
/** The rule id in the URL does not resolve, which is where the page gives up. */
|
||||
export const RuleNotFound: Story = {
|
||||
args: { dataState: 'error' },
|
||||
// The mocked rule request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
57
frontend/src/pages/AlertList/AlertRules/stories/AlertRules.stories.mocks.tsx
generated
Normal file
57
frontend/src/pages/AlertList/AlertRules/stories/AlertRules.stories.mocks.tsx
generated
Normal 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}` }),
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
35
frontend/src/pages/AlertList/Channels/stories/Channels.stories.mocks.tsx
generated
Normal file
35
frontend/src/pages/AlertList/Channels/stories/Channels.stories.mocks.tsx
generated
Normal 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}` }),
|
||||
});
|
||||
@@ -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' },
|
||||
};
|
||||
75
frontend/src/pages/AlertList/ChannelsEdit/stories/ChannelsEdit.stories.mocks.tsx
generated
Normal file
75
frontend/src/pages/AlertList/ChannelsEdit/stories/ChannelsEdit.stories.mocks.tsx
generated
Normal 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}` }),
|
||||
});
|
||||
@@ -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' },
|
||||
};
|
||||
59
frontend/src/pages/AlertList/ChannelsNew/stories/ChannelsNew.stories.mocks.tsx
generated
Normal file
59
frontend/src/pages/AlertList/ChannelsNew/stories/ChannelsNew.stories.mocks.tsx
generated
Normal 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' }),
|
||||
});
|
||||
@@ -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$/);
|
||||
},
|
||||
};
|
||||
103
frontend/src/pages/AlertList/PlannedDowntime/stories/PlannedDowntime.stories.mocks.tsx
generated
Normal file
103
frontend/src/pages/AlertList/PlannedDowntime/stories/PlannedDowntime.stories.mocks.tsx
generated
Normal 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}`,
|
||||
}),
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
164
frontend/src/pages/AlertList/PlannedDowntime/stories/__story_mockdata__/plannedDowntime.ts
generated
Normal file
164
frontend/src/pages/AlertList/PlannedDowntime/stories/__story_mockdata__/plannedDowntime.ts
generated
Normal 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),
|
||||
),
|
||||
});
|
||||
87
frontend/src/pages/AlertList/RoutingPolicies/stories/RoutingPolicies.stories.mocks.tsx
generated
Normal file
87
frontend/src/pages/AlertList/RoutingPolicies/stories/RoutingPolicies.stories.mocks.tsx
generated
Normal 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}`,
|
||||
}),
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
96
frontend/src/pages/AlertList/RoutingPolicies/stories/__story_mockdata__/routingPolicies.ts
generated
Normal file
96
frontend/src/pages/AlertList/RoutingPolicies/stories/__story_mockdata__/routingPolicies.ts
generated
Normal 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),
|
||||
),
|
||||
});
|
||||
90
frontend/src/pages/AlertList/TriggeredAlerts/stories/TriggeredAlerts.stories.mocks.tsx
generated
Normal file
90
frontend/src/pages/AlertList/TriggeredAlerts/stories/TriggeredAlerts.stories.mocks.tsx
generated
Normal 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 },
|
||||
})),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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] } },
|
||||
};
|
||||
225
frontend/src/pages/AlertList/stories/__story_mockdata__/alertQuery.ts
generated
Normal file
225
frontend/src/pages/AlertList/stories/__story_mockdata__/alertQuery.ts
generated
Normal 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: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
744
frontend/src/pages/AlertList/stories/__story_mockdata__/alerts.ts
generated
Normal file
744
frontend/src/pages/AlertList/stories/__story_mockdata__/alerts.ts
generated
Normal 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: [],
|
||||
},
|
||||
});
|
||||
113
frontend/src/pages/AllErrors/stories/AllErrors.stories.mocks.tsx
generated
Normal file
113
frontend/src/pages/AllErrors/stories/AllErrors.stories.mocks.tsx
generated
Normal 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));
|
||||
},
|
||||
});
|
||||
114
frontend/src/pages/AllErrors/stories/AllErrors.stories.tsx
Normal file
114
frontend/src/pages/AllErrors/stories/AllErrors.stories.tsx
Normal 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' });
|
||||
},
|
||||
};
|
||||
341
frontend/src/pages/AllErrors/stories/__story_mockdata__/exceptions.ts
generated
Normal file
341
frontend/src/pages/AllErrors/stories/__story_mockdata__/exceptions.ts
generated
Normal 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 },
|
||||
);
|
||||
};
|
||||
406
frontend/src/pages/ApiMonitoring/stories/ApiMonitoringPage.stories.mocks.tsx
generated
Normal file
406
frontend/src/pages/ApiMonitoring/stories/ApiMonitoringPage.stories.mocks.tsx
generated
Normal 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(),
|
||||
}),
|
||||
});
|
||||
@@ -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' },
|
||||
};
|
||||
556
frontend/src/pages/ApiMonitoring/stories/__story_mockdata__/apiMonitoring.ts
generated
Normal file
556
frontend/src/pages/ApiMonitoring/stories/__story_mockdata__/apiMonitoring.ts
generated
Normal 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 2xx–5xx, 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,
|
||||
}));
|
||||
157
frontend/src/pages/CreateAlert/stories/CreateAlert.stories.mocks.tsx
generated
Normal file
157
frontend/src/pages/CreateAlert/stories/CreateAlert.stories.mocks.tsx
generated
Normal 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) }),
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
205
frontend/src/pages/DashboardPage/PanelEditorPage/stories/PanelEditorPage.stories.mocks.tsx
generated
Normal file
205
frontend/src/pages/DashboardPage/PanelEditorPage/stories/PanelEditorPage.stories.mocks.tsx
generated
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
98
frontend/src/pages/DashboardPage/PanelEditorPage/stories/__story_mockdata__/panelEditor.ts
generated
Normal file
98
frontend/src/pages/DashboardPage/PanelEditorPage/stories/__story_mockdata__/panelEditor.ts
generated
Normal 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,
|
||||
),
|
||||
];
|
||||
329
frontend/src/pages/DashboardPage/stories/DashboardPage.stories.mocks.tsx
generated
Normal file
329
frontend/src/pages/DashboardPage/stories/DashboardPage.stories.mocks.tsx
generated
Normal 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: [] } })),
|
||||
);
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardMocks,
|
||||
desyncedDashboardHandler,
|
||||
metricsListHandler,
|
||||
tooltipDashboardHandler,
|
||||
tooltipRoute,
|
||||
warnedPanelQueryHandler,
|
||||
} from './DashboardPage.stories.mocks';
|
||||
import { TOOLTIP_PANEL_NAME } from './__story_mockdata__/tooltipDashboard';
|
||||
|
||||
import DashboardPage from '../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* One dashboard: its variables, its sections and every panel querying
|
||||
* `query_range`, plus the lock, clone and publish actions in the header.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail',
|
||||
tags: ['role-gated', 'play'],
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
// The page reads the dashboard id out of the pathname, so it renders under
|
||||
// its own route rather than being mounted on its own.
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/**
|
||||
* A service dashboard with data: the variable bar over two titled sections, and
|
||||
* the panel kinds all drawn from the same query endpoint: time series, single
|
||||
* numbers, a table, a bar chart, a pie and a log list.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* The blank dashboard a freshly created one starts as, with the two steps that
|
||||
* populate it. A titled section renders its own add-panel state instead, so this
|
||||
* needs both no panels and no sections.
|
||||
*/
|
||||
export const Empty: Story = {
|
||||
args: { panels: 0, sectioned: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* A locked dashboard: the lock indicator sits over the grid and the edit
|
||||
* affordances are gone even for an admin.
|
||||
*/
|
||||
export const Locked: Story = {
|
||||
args: { locked: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the panels and the variable bar work, but nothing that would change
|
||||
* the dashboard is offered.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Every query ran and matched nothing, which each panel kind draws as its own
|
||||
* no-data state.
|
||||
*/
|
||||
export const NoData: Story = {
|
||||
args: { noData: true },
|
||||
};
|
||||
|
||||
/** A partial data failure: the dashboard remains visible while panel queries fail. */
|
||||
export const PanelQueryError: Story = {
|
||||
args: { dataState: 'error' },
|
||||
};
|
||||
|
||||
/** The panels mid-fetch, with the header, variable bar and grid already laid out. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the dashboard itself carries, held open at once: the title, the
|
||||
* description with its link, the public-page globe, the `+N` of tags that did
|
||||
* not fit, two panel descriptions, a panel's time-preference pill, the
|
||||
* collapsed panel search, the warning one panel's query came back with, the
|
||||
* `+N` of variables the bar hid, the values a multi-select pill stands for, the
|
||||
* add-variable `+`, and a legend's copy buttons.
|
||||
*
|
||||
* The document and one panel's query are answered by the story, so the Panels,
|
||||
* Sections, Variables and Locked controls do not reach it; the Variable options
|
||||
* control still does, and the selection comes from the route.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true, variableValues: 12 },
|
||||
parameters: {
|
||||
signoz: { route: tooltipRoute() },
|
||||
msw: { handlers: [tooltipDashboardHandler, warnedPanelQueryHandler] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* What a locked dashboard refuses, with the Actions menu open: the padlock
|
||||
* offering to unlock, the disabled Configure and New Panel buttons, and the
|
||||
* menu rows saying why they cannot be picked.
|
||||
*/
|
||||
export const TooltipsWhenLocked: Story = {
|
||||
args: { tooltipsOpen: true, locked: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// The dropdown trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.click(
|
||||
await canvas.findByRole('button', { name: 'Actions' }, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Clone dashboard');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The JSON editor's two warnings, held open: the panels the layout places
|
||||
* nowhere and the layout slots pointing at panels the spec no longer has, each
|
||||
* listing the ids behind it.
|
||||
*
|
||||
* The document is answered by the story, so the Panels, Sections, Variables and
|
||||
* Locked controls do not reach it.
|
||||
*/
|
||||
export const TooltipsInJsonDrawer: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
parameters: { msw: { handlers: [desyncedDashboardHandler] } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('edit-json', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByTestId('json-editor-dangling-warning');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Overview tab of dashboard settings, where Cross-Panel Sync explains what
|
||||
* syncing the crosshair does and links out to the docs.
|
||||
*/
|
||||
export const TooltipsInSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Sync Mode');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Variables tab of dashboard settings, where a dynamic variable's Apply to
|
||||
* all says whether it is already a filter on every panel. The row keeps its
|
||||
* actions invisible until it is hovered, which the story does first.
|
||||
*/
|
||||
export const TooltipsInVariableSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
|
||||
|
||||
// The tooltip trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.hover(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'Apply to all' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Add this variable as a filter to every panel',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel expanded into view mode, whose header carries the full panel name its
|
||||
* title truncates, over the dashboard's own tooltips behind the dialog.
|
||||
*
|
||||
* The document is answered by the story, so the Panels, Sections, Variables and
|
||||
* Locked controls do not reach it.
|
||||
*/
|
||||
export const TooltipsInViewPanelModal: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
parameters: {
|
||||
msw: { handlers: [tooltipDashboardHandler, metricsListHandler] },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId(
|
||||
'panel-actions-request-rate',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('View'));
|
||||
await screen.findByText(`${TOOLTIP_PANEL_NAME} - (View mode)`);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
|
||||
*
|
||||
* Kept last: test-runner shares one page across a file's stories, and the 404
|
||||
* this story is about can settle after the next story has already started,
|
||||
* which fails that one instead.
|
||||
*/
|
||||
export const NotFound: Story = {
|
||||
args: { notFound: true },
|
||||
// The deliberate 404 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
453
frontend/src/pages/DashboardPage/stories/__story_mockdata__/dashboard.ts
generated
Normal file
453
frontend/src/pages/DashboardPage/stories/__story_mockdata__/dashboard.ts
generated
Normal 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);
|
||||
};
|
||||
175
frontend/src/pages/DashboardPage/stories/__story_mockdata__/panelData.ts
generated
Normal file
175
frontend/src/pages/DashboardPage/stories/__story_mockdata__/panelData.ts
generated
Normal 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;
|
||||
165
frontend/src/pages/DashboardPage/stories/__story_mockdata__/tooltipDashboard.ts
generated
Normal file
165
frontend/src/pages/DashboardPage/stories/__story_mockdata__/tooltipDashboard.ts
generated
Normal 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),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
344
frontend/src/pages/DashboardsListPage/stories/DashboardsListPage.stories.mocks.tsx
generated
Normal file
344
frontend/src/pages/DashboardsListPage/stories/DashboardsListPage.stories.mocks.tsx
generated
Normal 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 row’s 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
348
frontend/src/pages/DashboardsListPage/stories/__story_mockdata__/dashboardsList.ts
generated
Normal file
348
frontend/src/pages/DashboardsListPage/stories/__story_mockdata__/dashboardsList.ts
generated
Normal 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}`,
|
||||
);
|
||||
122
frontend/src/pages/EditRules/stories/EditRules.stories.mocks.tsx
generated
Normal file
122
frontend/src/pages/EditRules/stories/EditRules.stories.mocks.tsx
generated
Normal 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`,
|
||||
}),
|
||||
});
|
||||
55
frontend/src/pages/EditRules/stories/EditRules.stories.tsx
Normal file
55
frontend/src/pages/EditRules/stories/EditRules.stories.tsx
Normal 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 },
|
||||
};
|
||||
17
frontend/src/pages/ErrorBoundaryFallback/stories/ErrorBoundaryFallback.stories.mocks.tsx
generated
Normal file
17
frontend/src/pages/ErrorBoundaryFallback/stories/ErrorBoundaryFallback.stories.mocks.tsx
generated
Normal 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 }),
|
||||
});
|
||||
@@ -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' },
|
||||
};
|
||||
100
frontend/src/pages/ErrorDetails/stories/ErrorDetails.stories.mocks.tsx
generated
Normal file
100
frontend/src/pages/ErrorDetails/stories/ErrorDetails.stories.mocks.tsx
generated
Normal 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)}`,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { errorDetailsMocks } from './ErrorDetails.stories.mocks';
|
||||
import ErrorDetails from '../index';
|
||||
|
||||
type ErrorDetailsArgs = PageStoryArgs<typeof errorDetailsMocks>;
|
||||
|
||||
const pageStory = storyMocks(errorDetailsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* One exception: its stack trace rendered for the language it came from, the span
|
||||
* it belongs to, and the occurrence before and after it.
|
||||
*
|
||||
* Route: `/error-detail?...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Exceptions/Detail',
|
||||
component: ErrorDetails,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ErrorDetailsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ErrorDetailsArgs>;
|
||||
|
||||
/**
|
||||
* One exception event as the list opens it: the type and message it was grouped
|
||||
* under, when it was seen, its stack trace, and the span and trace it came from.
|
||||
* Older and Newer walk the group's other events in place.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A Java exception stack trace, including JVM frame formatting. */
|
||||
export const JavaStack: Story = {
|
||||
args: { language: 'java' },
|
||||
};
|
||||
|
||||
/** A Python exception stack trace, including chained traceback formatting. */
|
||||
export const PythonStack: Story = {
|
||||
args: { language: 'python' },
|
||||
};
|
||||
|
||||
/** The oldest event in the group, with Older disabled. */
|
||||
export const OldestEvent: Story = {
|
||||
args: { neighbours: 'oldest' },
|
||||
};
|
||||
|
||||
/** The newest event in the group, with Newer disabled. */
|
||||
export const NewestEvent: Story = {
|
||||
args: { neighbours: 'newest' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A link to an event the backend no longer has, which is what a bookmarked
|
||||
* exception past its retention window opens.
|
||||
*/
|
||||
export const EventMissing: Story = {
|
||||
args: { found: false },
|
||||
// The mocked event lookup deliberately 404s; the resulting console error is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The page mid-fetch, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The page without a timestamp to look the event up by, which is the link the
|
||||
* page refuses and sends back to the exceptions list.
|
||||
*/
|
||||
export const IncompleteLink: Story = {
|
||||
args: { params: 'no-timestamp' },
|
||||
};
|
||||
232
frontend/src/pages/ErrorDetails/stories/__story_mockdata__/errorDetails.ts
generated
Normal file
232
frontend/src/pages/ErrorDetails/stories/__story_mockdata__/errorDetails.ts
generated
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { EXCEPTION_CATALOGUE } from 'pages/AllErrors/stories/__story_mockdata__/exceptions';
|
||||
import type { ExceptionShape } from 'pages/AllErrors/stories/__story_mockdata__/exceptions';
|
||||
import type { PayloadProps as ErrorEvent } from 'types/api/errors/getByErrorTypeAndService';
|
||||
import type { PayloadProps as NextPrevIds } from 'types/api/errors/getNextPrevId';
|
||||
|
||||
export const EXCEPTION_LANGUAGES = ['go', 'python', 'java'] as const;
|
||||
|
||||
export type ExceptionLanguage = (typeof EXCEPTION_LANGUAGES)[number];
|
||||
|
||||
export const NEIGHBOUR_STATES = [
|
||||
'surrounded',
|
||||
'oldest',
|
||||
'newest',
|
||||
'only-event',
|
||||
] as const;
|
||||
|
||||
export type NeighbourState = (typeof NEIGHBOUR_STATES)[number];
|
||||
|
||||
/** The three parameter sets the page distinguishes, in the order it reads them. */
|
||||
export const DETAIL_PARAMS = ['group', 'event', 'no-timestamp'] as const;
|
||||
|
||||
export type DetailParams = (typeof DETAIL_PARAMS)[number];
|
||||
|
||||
/**
|
||||
* The three events the group holds, oldest first. Both lookups key on the
|
||||
* timestamp, so walking with Older and Newer, which is the page rewriting the
|
||||
* `timestamp` param, is what moves between them.
|
||||
*/
|
||||
const WALK = ['older', 'event', 'newer'] as const;
|
||||
|
||||
type Step = (typeof WALK)[number];
|
||||
|
||||
/** RFC 3339 as the payload carries it, and the nanoseconds the URL carries. */
|
||||
const STEP_TIMESTAMPS: Record<Step, { at: string; nanos: string }> = {
|
||||
older: { at: '2025-04-14T18:11:04.113904221Z', nanos: '1744654264113904221' },
|
||||
event: { at: '2025-04-14T18:27:57.797616374Z', nanos: '1744655277797616374' },
|
||||
newer: { at: '2025-04-14T18:44:12.550118903Z', nanos: '1744656252550118903' },
|
||||
};
|
||||
|
||||
const stepAt = (nanos: string | null): Step =>
|
||||
WALK.find((step) => STEP_TIMESTAMPS[step].nanos === nanos) ?? 'event';
|
||||
|
||||
const eventIdAt = (step: Step, errorId: string): string =>
|
||||
step === 'event' ? errorId : `${step}-${errorId}`;
|
||||
|
||||
const STACK_TRACES: Record<ExceptionLanguage, string> = {
|
||||
go: `*errors.errorString: redis timeout
|
||||
goroutine 51 [running]:
|
||||
github.com/signoz/sample/pkg/cache.(*Client).Get(0xc000112a80, {0x1096f40, 0xc0001a2000}, {0xf8a3c1, 0x11})
|
||||
\t/app/pkg/cache/client.go:118 +0x2c5
|
||||
github.com/signoz/sample/pkg/orders.(*Service).Lookup(0xc0000b8060, {0x1096f40, 0xc0001a2000}, 0x1f4)
|
||||
\t/app/pkg/orders/service.go:64 +0x11f
|
||||
github.com/signoz/sample/internal/http.(*Handler).ServeHTTP(0xc0000ba018, {0x1094e80, 0xc00019c0e0}, 0xc0001c4000)
|
||||
\t/app/internal/http/handler.go:41 +0x1a8
|
||||
net/http.serverHandler.ServeHTTP({0xc00018e000}, {0x1094e80, 0xc00019c0e0}, 0xc0001c4000)
|
||||
\t/usr/local/go/src/net/http/server.go:2938 +0x8e`,
|
||||
python: `Traceback (most recent call last):
|
||||
File "/usr/local/lib/python3.11/site-packages/urllib3/connectionpool.py", line 715, in urlopen
|
||||
httplib_response = self._make_request(
|
||||
File "/usr/local/lib/python3.11/site-packages/urllib3/connectionpool.py", line 467, in _make_request
|
||||
raise ReadTimeoutError(self, url, "Read timed out")
|
||||
urllib3.exceptions.ReadTimeoutError: HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/app/checkout/views.py", line 82, in place_order
|
||||
response = session.post(f"{PAYMENTS_URL}/charge", json=payload, timeout=2)
|
||||
File "/usr/local/lib/python3.11/site-packages/requests/sessions.py", line 637, in post
|
||||
return self.request("POST", url, data=data, json=json, **kwargs)
|
||||
requests.exceptions.ConnectionError: HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)`,
|
||||
java: `java.net.SocketTimeoutException: Read timed out
|
||||
\tat java.base/java.net.SocketInputStream.socketRead0(Native Method)
|
||||
\tat java.base/java.net.SocketInputStream.read(SocketInputStream.java:168)
|
||||
\tat okhttp3.internal.http1.Http1ExchangeCodec.readResponseHeaders(Http1ExchangeCodec.kt:180)
|
||||
\tat okhttp3.internal.connection.Exchange.readResponseHeaders(Exchange.kt:106)
|
||||
\tat io.signoz.sample.payment.GatewayClient.authorize(GatewayClient.java:74)
|
||||
\tat io.signoz.sample.payment.PaymentService.charge(PaymentService.java:112)
|
||||
\tat io.signoz.sample.payment.PaymentController.pay(PaymentController.java:48)
|
||||
\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)`,
|
||||
};
|
||||
|
||||
interface EventShape {
|
||||
group: ExceptionShape;
|
||||
errorId: string;
|
||||
spanID: string;
|
||||
traceID: string;
|
||||
}
|
||||
|
||||
const groupOf = (exceptionType: string): ExceptionShape => {
|
||||
const found = EXCEPTION_CATALOGUE.find(
|
||||
(exception) => exception.exceptionType === exceptionType,
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new Error(`no exception group for ${exceptionType} in the catalogue`);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const EVENTS: Record<ExceptionLanguage, EventShape> = {
|
||||
go: {
|
||||
group: groupOf('*errors.errorString'),
|
||||
errorId: '7c1ba0d4e6f84a1f9d2b3c4e5f607182',
|
||||
spanID: '3a5c7e9b1d0f2468',
|
||||
traceID: 'd41f8c2a6b3e5079a1c4e6b8d0f2a468',
|
||||
},
|
||||
python: {
|
||||
group: groupOf('ConnectionError'),
|
||||
errorId: 'b2d4f60819a3c5e7092b4d6f8a0c1e35',
|
||||
spanID: '9f1d3b5a7c9e0246',
|
||||
traceID: '5e7a9c1b3d5f7911a3c5e7b9d1f30517',
|
||||
},
|
||||
java: {
|
||||
group: groupOf('java.net.SocketTimeoutException'),
|
||||
errorId: 'e6c8a0f2d4b60819273a5c7e9b1d3f50',
|
||||
spanID: '2c4e6a8b0d2f4618',
|
||||
traceID: 'a0c2e4b6d8f01325476a8c0e2b4d6f81',
|
||||
},
|
||||
};
|
||||
|
||||
/** The parameters the list links with, and the ones Older and Newer add to them. */
|
||||
export const errorDetailsSearch = (
|
||||
language: ExceptionLanguage,
|
||||
params: DetailParams,
|
||||
): string => {
|
||||
const { group, errorId } = EVENTS[language];
|
||||
const search = new URLSearchParams({ groupId: group.groupID });
|
||||
|
||||
if (params === 'no-timestamp') {
|
||||
return search.toString();
|
||||
}
|
||||
|
||||
search.set('timestamp', STEP_TIMESTAMPS.event.nanos);
|
||||
|
||||
if (params === 'event') {
|
||||
search.set('errorId', errorId);
|
||||
}
|
||||
|
||||
return search.toString();
|
||||
};
|
||||
|
||||
export interface EventLookup {
|
||||
timestamp: string | null;
|
||||
errorId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/errorFromGroupID` and `/errorFromErrorID` both answer with the event itself,
|
||||
* unwrapped: the api module hands the whole body through as the payload.
|
||||
*/
|
||||
export const errorEvent = (
|
||||
language: ExceptionLanguage,
|
||||
lookup: EventLookup,
|
||||
): ErrorEvent => {
|
||||
const event = EVENTS[language];
|
||||
const step = stepAt(lookup.timestamp);
|
||||
|
||||
return {
|
||||
errorId: lookup.errorId || eventIdAt(step, event.errorId),
|
||||
exceptionType: event.group.exceptionType,
|
||||
exceptionMessage: event.group.exceptionMessage,
|
||||
exceptionStacktrace: STACK_TRACES[language],
|
||||
exceptionEscaped: 'false',
|
||||
timestamp: STEP_TIMESTAMPS[step].at,
|
||||
spanID: event.spanID,
|
||||
traceID: event.traceID,
|
||||
serviceName: event.group.serviceName,
|
||||
groupID: event.group.groupID,
|
||||
};
|
||||
};
|
||||
|
||||
const neighbourSteps = (
|
||||
step: Step,
|
||||
state: NeighbourState,
|
||||
): { older: Step | null; newer: Step | null } => {
|
||||
if (step === 'older') {
|
||||
return { older: null, newer: 'event' };
|
||||
}
|
||||
|
||||
if (step === 'newer') {
|
||||
return { older: 'event', newer: null };
|
||||
}
|
||||
|
||||
return {
|
||||
older: state === 'surrounded' || state === 'newest' ? 'older' : null,
|
||||
newer: state === 'surrounded' || state === 'oldest' ? 'newer' : null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* An empty id is how the backend says there is nothing further in that
|
||||
* direction, and it is what the Older and Newer buttons read to disable
|
||||
* themselves. Which end of the walk the page is on comes from the timestamp it
|
||||
* asks with, so a step in either direction lands somewhere it can step back
|
||||
* from.
|
||||
*/
|
||||
export const errorNeighbours = (
|
||||
language: ExceptionLanguage,
|
||||
state: NeighbourState,
|
||||
timestamp: string | null,
|
||||
): NextPrevIds => {
|
||||
const event = EVENTS[language];
|
||||
const { older, newer } = neighbourSteps(stepAt(timestamp), state);
|
||||
|
||||
return {
|
||||
groupID: event.group.groupID,
|
||||
prevErrorID: older ? eventIdAt(older, event.errorId) : '',
|
||||
prevTimestamp: older ? STEP_TIMESTAMPS[older].at : '',
|
||||
nextErrorID: newer ? eventIdAt(newer, event.errorId) : '',
|
||||
nextTimestamp: newer ? STEP_TIMESTAMPS[newer].at : '',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What the backend answers when no event matches the group and timestamp: a 404
|
||||
* carrying its own envelope, which `ErrorResponseHandler` reduces to the
|
||||
* `errorType` the page prints. A `null` body instead of this reaches the page as
|
||||
* a TypeError, so the `idPayload === null` branch it guards for is unreachable.
|
||||
*/
|
||||
export const ERROR_EVENT_NOT_FOUND = {
|
||||
status: 'error',
|
||||
errorType: 'not_found',
|
||||
error: 'Error/Exception not found',
|
||||
data: null,
|
||||
};
|
||||
67
frontend/src/pages/ForgotPassword/stories/ForgotPassword.stories.mocks.tsx
generated
Normal file
67
frontend/src/pages/ForgotPassword/stories/ForgotPassword.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
forgotPasswordRouteState,
|
||||
ORG_MAX,
|
||||
} from './__story_mockdata__/forgotPassword';
|
||||
|
||||
const RESET = 'Forgot password · reset';
|
||||
|
||||
const REQUESTS = ['accepted', 'rejected'] as const;
|
||||
|
||||
type ResetRequest = (typeof REQUESTS)[number];
|
||||
|
||||
export const forgotPasswordMocks = defineStoryMocks({
|
||||
controls: {
|
||||
orgs: countControl('Organizations', {
|
||||
group: RESET,
|
||||
description:
|
||||
'Workspaces the email belongs to. More than one adds the organization select, because the reset is scoped to one.',
|
||||
value: 1,
|
||||
max: ORG_MAX,
|
||||
}),
|
||||
request: choiceControl<ResetRequest>('Reset request', {
|
||||
group: RESET,
|
||||
description:
|
||||
'What the reset endpoint answers when the form is sent: the check-your-inbox screen, or the error above the button.',
|
||||
options: REQUESTS,
|
||||
value: 'accepted',
|
||||
}),
|
||||
},
|
||||
handlers: (values) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v2/factor_password/forgot',
|
||||
(_req, res, ctx) =>
|
||||
values.request === 'accepted'
|
||||
? res(ctx.status(StatusCodes.NO_CONTENT))
|
||||
: res(
|
||||
ctx.status(StatusCodes.TOO_MANY_REQUESTS),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'rate_limited',
|
||||
message: 'Too many reset emails were sent. Try again in an hour.',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
route: ROUTES.FORGOT_PASSWORD,
|
||||
routeState: forgotPasswordRouteState(values.orgs),
|
||||
// Resetting a password is what a signed-out browser does, so the shell has
|
||||
// no side nav, top nav or banners to draw.
|
||||
appContext: { isLoggedIn: false },
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import ForgotPassword from '../index';
|
||||
import { forgotPasswordMocks } from './ForgotPassword.stories.mocks';
|
||||
|
||||
type ForgotPasswordArgs = PageStoryArgs<typeof forgotPasswordMocks>;
|
||||
|
||||
const pageStory = storyMocks(forgotPasswordMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The reset request form, before any session exists.
|
||||
*
|
||||
* Route: `/forgot-password`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Auth/Forgot Password',
|
||||
tags: ['play'],
|
||||
component: ForgotPassword,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ForgotPasswordArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ForgotPasswordArgs>;
|
||||
|
||||
const submit = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole('button', {
|
||||
name: /send reset link/i,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** The email the login form carried over, ready to have a reset link sent to it. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** An email in three workspaces: the reset is scoped to one, so it has to be picked. */
|
||||
export const MultipleOrganizations: Story = {
|
||||
args: { orgs: 3 },
|
||||
};
|
||||
|
||||
/** The link sent: what the page shows instead of the form. */
|
||||
export const LinkSent: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await submit(canvasElement);
|
||||
await within(canvasElement).findByRole('heading', {
|
||||
name: /check your email/i,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Asked once too often, which the backend rate-limits. */
|
||||
export const RateLimited: Story = {
|
||||
args: { request: 'rejected' },
|
||||
// The deliberate 429 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await submit(canvasElement);
|
||||
await within(canvasElement).findByText(/rate_limited/i);
|
||||
},
|
||||
};
|
||||
33
frontend/src/pages/ForgotPassword/stories/__story_mockdata__/forgotPassword.ts
generated
Normal file
33
frontend/src/pages/ForgotPassword/stories/__story_mockdata__/forgotPassword.ts
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { ForgotPasswordRouteState } from 'container/ForgotPassword';
|
||||
import type { OrgSessionContext } from 'types/api/v2/sessions/context/get';
|
||||
|
||||
export const FORGOT_PASSWORD_EMAIL = 'anna@signoz.io';
|
||||
|
||||
const ORGS: OrgSessionContext[] = [
|
||||
{ id: 'org-signoz', name: 'SigNoz' },
|
||||
{ id: 'org-acme', name: 'Acme Corp' },
|
||||
{ id: 'org-globex', name: 'Globex' },
|
||||
].map((org) => ({
|
||||
...org,
|
||||
authNSupport: { password: [{ provider: 'password' }], callback: [] },
|
||||
}));
|
||||
|
||||
export const ORG_MAX = ORGS.length;
|
||||
|
||||
/**
|
||||
* The page is reached by a push from the login form rather than by its URL, so
|
||||
* the email and the workspaces it resolved to arrive as `location.state`. With
|
||||
* none the page bounces straight back to login.
|
||||
*/
|
||||
export const forgotPasswordRouteState = (
|
||||
orgs: number,
|
||||
): ForgotPasswordRouteState => ({
|
||||
email: FORGOT_PASSWORD_EMAIL,
|
||||
orgId: orgs === 1 ? ORGS[0].id : undefined,
|
||||
orgs: ORGS.slice(0, orgs),
|
||||
});
|
||||
@@ -9,10 +9,21 @@ import HomePage from '../HomePage';
|
||||
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const pageStory = storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' });
|
||||
|
||||
/**
|
||||
* The workspace landing page: ingestion state per signal, the welcome checklist
|
||||
* while a signal is missing, then alert rules, dashboards, saved views and the
|
||||
* services table.
|
||||
*
|
||||
* Route: `/home`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
tags: ['role-gated'],
|
||||
component: HomePage,
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' }),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { clustersMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type ClustersArgs = PageStoryArgs<typeof clustersMocks>;
|
||||
|
||||
const pageStory = storyMocks(clustersMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes clusters tab: CPU, memory and pod counts per cluster, each row
|
||||
* opening the drawer.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=clusters`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Clusters',
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ClustersArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Clusters with their node readiness and pod status counts, and CPU and memory
|
||||
* against the cluster's allocatable capacity. The drawer carries the counts cards
|
||||
* that jump to the nodes and pods of the cluster.
|
||||
*/
|
||||
export const Default: StoryObj<ClustersArgs> = {};
|
||||
|
||||
/**
|
||||
* The tooltips of the cluster's details drawer: one per counts card, each naming
|
||||
* the cluster it would filter the namespaces, nodes or workloads by, the cluster
|
||||
* name above them, and on every metrics chart the header's own paragraph, its
|
||||
* docs link, the Go to Metrics Explorer button and the series names in the
|
||||
* legend. The counts cards are the clusters and namespaces tabs only, so this is
|
||||
* where they are reviewable.
|
||||
*/
|
||||
export const Tooltips: StoryObj<ClustersArgs> = {
|
||||
args: { tooltipsOpen: true, drawer: true },
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { daemonSetsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type DaemonSetsArgs = PageStoryArgs<typeof daemonSetsMocks>;
|
||||
|
||||
const pageStory = storyMocks(daemonSetsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes daemon sets tab: desired against ready nodes, with CPU and
|
||||
* memory beside them.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=daemonsets`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/DaemonSets',
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DaemonSetsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* DaemonSets by namespace: ready, current, desired and misscheduled nodes beside
|
||||
* the CPU and memory of the pods they run. The drawer adds the by-pod metrics
|
||||
* tab.
|
||||
*/
|
||||
export const Default: StoryObj<DaemonSetsArgs> = {};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { deploymentsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type DeploymentsArgs = PageStoryArgs<typeof deploymentsMocks>;
|
||||
|
||||
const pageStory = storyMocks(deploymentsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes deployments tab: desired against available replicas, with CPU and
|
||||
* memory against request and limit.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=deployments`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Deployments',
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DeploymentsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Deployments by namespace: available against desired pods, the replica count and
|
||||
* the pod status counts. The drawer adds the by-pod metrics tab, one series per
|
||||
* pod of the deployment.
|
||||
*/
|
||||
export const Default: StoryObj<DeploymentsArgs> = {};
|
||||
466
frontend/src/pages/InfrastructureMonitoring/stories/InfrastructureMonitoring.stories.mocks.tsx
generated
Normal file
466
frontend/src/pages/InfrastructureMonitoring/stories/InfrastructureMonitoring.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
VIEWS,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import type {
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
RawRow,
|
||||
TimeSeries,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockControl, StoryMocks } from '@/storybook/controls/types';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
queryRangeV5RawResponse,
|
||||
queryRangeV5ScalarResponse,
|
||||
queryRangeV5TimeSeriesResponse,
|
||||
timeSeriesPoints,
|
||||
} from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import {
|
||||
CHECKS_STATES,
|
||||
type ChecksState,
|
||||
entityForChecksType,
|
||||
entityForResource,
|
||||
entityEventRows,
|
||||
entityLogRows,
|
||||
entitySelection,
|
||||
entityTraceRows,
|
||||
GROUP_BY_KEY_BY_ENTITY,
|
||||
type InfraEntity,
|
||||
infraChecksResponse,
|
||||
infraFieldKeys,
|
||||
infraFieldValues,
|
||||
infraListResponse,
|
||||
type MetricScale,
|
||||
metricScale,
|
||||
RATIO_SCALE,
|
||||
} from './__story_mockdata__/infraMonitoring';
|
||||
|
||||
const LIST = 'Infra · list';
|
||||
const CHECKS = 'Infra · checks';
|
||||
const DRAWER = 'Infra · drawer';
|
||||
|
||||
const EMPTY_REASONS = ['no-results', 'before-retention'] as const;
|
||||
|
||||
type EmptyReason = (typeof EMPTY_REASONS)[number];
|
||||
|
||||
/**
|
||||
* The drawer tabs each entity ends up with: `K8sBaseDetailsContent` starts from
|
||||
* all four signals, Hosts turns events off, Volumes hides the strip entirely,
|
||||
* and the workload entities add the by-pod metrics tab.
|
||||
*/
|
||||
const DRAWER_TABS_BY_ENTITY: Record<InfraEntity, readonly VIEWS[]> = {
|
||||
[InfraMonitoringEntity.HOSTS]: [VIEWS.METRICS, VIEWS.LOGS, VIEWS.TRACES],
|
||||
[InfraMonitoringEntity.PODS]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
],
|
||||
[InfraMonitoringEntity.NODES]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
],
|
||||
[InfraMonitoringEntity.CLUSTERS]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
],
|
||||
[InfraMonitoringEntity.NAMESPACES]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
VIEWS.POD_METRICS,
|
||||
],
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
VIEWS.POD_METRICS,
|
||||
],
|
||||
[InfraMonitoringEntity.JOBS]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
VIEWS.POD_METRICS,
|
||||
],
|
||||
[InfraMonitoringEntity.DAEMONSETS]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
VIEWS.POD_METRICS,
|
||||
],
|
||||
[InfraMonitoringEntity.STATEFULSETS]: [
|
||||
VIEWS.METRICS,
|
||||
VIEWS.LOGS,
|
||||
VIEWS.TRACES,
|
||||
VIEWS.EVENTS,
|
||||
VIEWS.POD_METRICS,
|
||||
],
|
||||
[InfraMonitoringEntity.VOLUMES]: [VIEWS.METRICS],
|
||||
};
|
||||
|
||||
interface InfraListBody {
|
||||
filter?: { expression?: string; filterByStatus?: string };
|
||||
groupBy?: { name: string }[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface RouteValues {
|
||||
groupRows: boolean;
|
||||
drawer: boolean;
|
||||
drawerTab: VIEWS;
|
||||
}
|
||||
|
||||
const infraRoute = (
|
||||
entity: InfraEntity,
|
||||
{ groupRows, drawer, drawerTab }: RouteValues,
|
||||
): string => {
|
||||
const isHosts = entity === InfraMonitoringEntity.HOSTS;
|
||||
|
||||
const pathname = isHosts
|
||||
? ROUTES.INFRASTRUCTURE_MONITORING_HOSTS
|
||||
: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!isHosts) {
|
||||
params.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, entity);
|
||||
}
|
||||
|
||||
if (groupRows) {
|
||||
params.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY,
|
||||
JSON.stringify([GROUP_BY_KEY_BY_ENTITY[entity]]),
|
||||
);
|
||||
}
|
||||
|
||||
if (drawer) {
|
||||
const selection = entitySelection(entity);
|
||||
|
||||
params.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
|
||||
selection.selectedItem,
|
||||
);
|
||||
params.set(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, drawerTab);
|
||||
|
||||
if (selection.clusterName) {
|
||||
params.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME,
|
||||
selection.clusterName,
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.namespaceName) {
|
||||
params.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME,
|
||||
selection.namespaceName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
|
||||
return search ? `${pathname}?${search}` : pathname;
|
||||
};
|
||||
|
||||
type QuerySpec = QueryEnvelope['spec'];
|
||||
|
||||
const specOf = (body: QueryRangeRequestV5): QuerySpec | undefined =>
|
||||
body.compositeQuery?.queries?.[0]?.spec;
|
||||
|
||||
const signalOf = (body: QueryRangeRequestV5): string | undefined => {
|
||||
const spec = specOf(body);
|
||||
|
||||
return spec && 'signal' in spec ? spec.signal : undefined;
|
||||
};
|
||||
|
||||
const metricNameOf = (spec: QuerySpec): string | undefined => {
|
||||
if (!('aggregations' in spec)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [aggregation] = spec.aggregations ?? [];
|
||||
|
||||
return aggregation && 'metricName' in aggregation
|
||||
? aggregation.metricName
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The percentage widgets disable their operand queries and plot a `A/B` formula
|
||||
* over them, so a response has to answer per query name: the formula reads as a
|
||||
* ratio, the raw queries in the unit of the metric they ask for.
|
||||
*/
|
||||
const plottedQueries = (body: QueryRangeRequestV5): QueryEnvelope[] => {
|
||||
const queries = body.compositeQuery?.queries ?? [];
|
||||
|
||||
const plotted = queries.filter(
|
||||
({ spec }) => !('disabled' in spec && spec.disabled),
|
||||
);
|
||||
|
||||
return plotted.length > 0 ? plotted : queries;
|
||||
};
|
||||
|
||||
const scaleOf = ({ type, spec }: QueryEnvelope): MetricScale =>
|
||||
type === 'builder_formula' ? RATIO_SCALE : metricScale(metricNameOf(spec));
|
||||
|
||||
const groupByKeysOf = (spec: QuerySpec): string[] =>
|
||||
'groupBy' in spec ? (spec.groupBy ?? []).map(({ name }) => name) : [];
|
||||
|
||||
/**
|
||||
* A formula carries no group-by of its own: it is grouped by whatever the
|
||||
* queries it combines were, and those are the disabled ones, so its keys come
|
||||
* off every query in the request.
|
||||
*/
|
||||
const groupByKeysFor = (
|
||||
query: QueryEnvelope,
|
||||
queries: QueryEnvelope[],
|
||||
): string[] => {
|
||||
const own = groupByKeysOf(query.spec);
|
||||
|
||||
if (own.length > 0 || query.type !== 'builder_formula') {
|
||||
return own;
|
||||
}
|
||||
|
||||
return [...new Set(queries.flatMap(({ spec }) => groupByKeysOf(spec)))];
|
||||
};
|
||||
|
||||
/** Series a grouped widget draws, which is how many the by-pod charts legend. */
|
||||
const GROUPED_SERIES = 4;
|
||||
|
||||
const labelValue = (key: string, index: number): string => {
|
||||
const pool = infraFieldValues(key);
|
||||
|
||||
return pool.length > 0 ? pool[index % pool.length] : `${key}-${index + 1}`;
|
||||
};
|
||||
|
||||
const seriesFor = (
|
||||
query: QueryEnvelope,
|
||||
queries: QueryEnvelope[],
|
||||
{ start, end }: Pick<QueryRangeRequestV5, 'start' | 'end'>,
|
||||
seed: number,
|
||||
): TimeSeries[] => {
|
||||
const scale = scaleOf(query);
|
||||
const keys = groupByKeysFor(query, queries);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return [
|
||||
{ labels: [], values: timeSeriesPoints({ start, end, seed, ...scale }) },
|
||||
];
|
||||
}
|
||||
|
||||
return Array.from({ length: GROUPED_SERIES }, (_unused, index) => ({
|
||||
labels: keys.map((name) => ({
|
||||
key: { name },
|
||||
value: labelValue(name, index),
|
||||
})),
|
||||
values: timeSeriesPoints({ start, end, seed: seed + index * 3, ...scale }),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Events and logs are the same signal on the same endpoint; the events tab is
|
||||
* the one that filters on `k8s.object.kind`.
|
||||
*/
|
||||
const isEventsQuery = (body: QueryRangeRequestV5): boolean => {
|
||||
const spec = specOf(body);
|
||||
const expression =
|
||||
spec && 'filter' in spec ? spec.filter?.expression : undefined;
|
||||
|
||||
return Boolean(
|
||||
expression?.includes(INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND),
|
||||
);
|
||||
};
|
||||
|
||||
/** A type alias, not an interface: `MockControlMap` needs an index signature. */
|
||||
export type InfraControls = {
|
||||
rows: MockControl<number>;
|
||||
groupRows: MockControl<boolean>;
|
||||
warning: MockControl<boolean>;
|
||||
emptyReason: MockControl<EmptyReason>;
|
||||
checks: MockControl<ChecksState>;
|
||||
drawer: MockControl<boolean>;
|
||||
drawerTab: MockControl<VIEWS>;
|
||||
telemetryRows: MockControl<number>;
|
||||
};
|
||||
|
||||
const infraControls = (entity: InfraEntity): InfraControls => ({
|
||||
rows: countControl('Rows', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Rows the endpoint has. The table asks for as many as fit its height, so a higher count paginates.',
|
||||
value: 20,
|
||||
max: 40,
|
||||
}),
|
||||
groupRows: toggleControl('Group rows', {
|
||||
group: LIST,
|
||||
description: `Answers with a grouped_list: one expandable row per value of ${GROUP_BY_KEY_BY_ENTITY[entity]}.`,
|
||||
value: false,
|
||||
}),
|
||||
warning: toggleControl('Query warning', {
|
||||
group: LIST,
|
||||
description: 'Attaches a warning to the response, next to the pagination.',
|
||||
value: false,
|
||||
}),
|
||||
emptyReason: choiceControl<EmptyReason>('Empty state', {
|
||||
group: LIST,
|
||||
description: 'Which empty state the table shows when Rows is 0.',
|
||||
options: EMPTY_REASONS,
|
||||
value: 'no-results',
|
||||
}),
|
||||
checks: choiceControl<ChecksState>('Instrumentation checks', {
|
||||
group: CHECKS,
|
||||
description:
|
||||
'The callout above the table. `ready` and `no-checks` both hide it, which is what a fully instrumented cluster shows.',
|
||||
options: CHECKS_STATES,
|
||||
value: 'missing',
|
||||
}),
|
||||
drawer: toggleControl('Details drawer', {
|
||||
group: DRAWER,
|
||||
description: 'Opens the drawer on the first row.',
|
||||
value: false,
|
||||
}),
|
||||
drawerTab: choiceControl<VIEWS>('Drawer tab', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'The tabs this entity has. Volumes hides its tab strip, so there the drawer only ever shows metrics.',
|
||||
options: DRAWER_TABS_BY_ENTITY[entity],
|
||||
value: VIEWS.METRICS,
|
||||
}),
|
||||
telemetryRows: countControl('Drawer log and trace rows', {
|
||||
group: DRAWER,
|
||||
value: 12,
|
||||
max: 20,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The mocks both module tabs run on: the list endpoint of one entity, its
|
||||
* instrumentation checks, the attribute keys its filters offer, and the drawer's
|
||||
* metrics, logs, traces and events. The entity decides the route the story
|
||||
* starts on, so the Kubernetes tab passes its category and the Hosts tab does
|
||||
* not pass one at all.
|
||||
*/
|
||||
export const infraStoryMocks = (
|
||||
entity: InfraEntity,
|
||||
): StoryMocks<InfraControls> =>
|
||||
defineStoryMocks({
|
||||
controls: infraControls(entity),
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/infra_monitoring/checks',
|
||||
response.json((req) =>
|
||||
infraChecksResponse(
|
||||
entityForChecksType(req.url.searchParams.get('type')) ?? entity,
|
||||
values.checks,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/infra_monitoring/:resource',
|
||||
response.json(async (req) => {
|
||||
const requested = entityForResource(String(req.params.resource));
|
||||
const body = (await req.json()) as InfraListBody;
|
||||
|
||||
// The drawer asks the same endpoint for one record; everything else is
|
||||
// the table, its grouped rows or an expanded group.
|
||||
const isDetailsFetch = body.limit === 1;
|
||||
|
||||
return infraListResponse({
|
||||
entity: requested ?? entity,
|
||||
count: isDetailsFetch ? Math.max(values.rows, 1) : values.rows,
|
||||
offset: body.offset ?? 0,
|
||||
limit: body.limit ?? 10,
|
||||
groupBy: body.groupBy?.map(({ name }) => name),
|
||||
status: body.filter?.filterByStatus,
|
||||
warning: values.warning && !isDetailsFetch,
|
||||
endTimeBeforeRetention:
|
||||
values.rows === 0 && values.emptyReason === 'before-retention',
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(infraFieldKeys(entity))),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
fieldValuesResponse(infraFieldValues(req.url.searchParams.get('name'))),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const { start, end, requestType } = body;
|
||||
|
||||
if (requestType === 'raw') {
|
||||
const rows = ((): RawRow[] => {
|
||||
if (signalOf(body) === 'traces') {
|
||||
return entityTraceRows(start, end, values.telemetryRows);
|
||||
}
|
||||
|
||||
return isEventsQuery(body)
|
||||
? entityEventRows(start, end, values.telemetryRows)
|
||||
: entityLogRows(start, end, values.telemetryRows);
|
||||
})();
|
||||
|
||||
return queryRangeV5RawResponse(rows);
|
||||
}
|
||||
|
||||
const queries = body.compositeQuery?.queries ?? [];
|
||||
const plotted = plottedQueries(body);
|
||||
|
||||
if (requestType === 'scalar') {
|
||||
return queryRangeV5ScalarResponse(scaleOf(plotted[0]).base);
|
||||
}
|
||||
|
||||
return queryRangeV5TimeSeriesResponse(
|
||||
plotted.map((query, index) => ({
|
||||
queryName: 'name' in query.spec ? (query.spec.name ?? 'A') : 'A',
|
||||
series: seriesFor(query, queries, { start, end }, index),
|
||||
})),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: infraRoute(entity, values) }),
|
||||
});
|
||||
|
||||
export const infraMonitoringMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.HOSTS,
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { infraMonitoringMocks } from './InfrastructureMonitoring.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type InfraArgs = PageStoryArgs<typeof infraMonitoringMocks>;
|
||||
|
||||
const pageStory = storyMocks(infraMonitoringMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The host list, and the way into the Kubernetes tabs: the table, its quick
|
||||
* filters and the entity drawer.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/hosts`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Overview',
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<InfraArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<InfraArgs>;
|
||||
|
||||
/**
|
||||
* The Hosts tab with a full page of hosts: quick filters on the left, the status
|
||||
* toggle and group-by toolbar above the table, and an instrumentation checks
|
||||
* callout reporting an optional metric and a required attribute nobody is
|
||||
* sending yet. The Kubernetes tab is the other half of the module, one story per
|
||||
* resource under `Kubernetes`.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* Hosts grouped by one attribute: one expandable row per OS, with the active and
|
||||
* inactive counts of the hosts behind it.
|
||||
*/
|
||||
export const GroupedRows: Story = {
|
||||
args: { groupRows: true },
|
||||
};
|
||||
|
||||
/** Nothing reporting yet, which is what a fleet with no collector shows. */
|
||||
export const NoData: Story = {
|
||||
args: { rows: 0, checks: 'no-checks' },
|
||||
};
|
||||
|
||||
/** An empty range that predates retention, distinct from an uninstrumented fleet. */
|
||||
export const BeforeRetention: Story = {
|
||||
args: { rows: 0, emptyReason: 'before-retention', checks: 'no-checks' },
|
||||
};
|
||||
|
||||
/** The host list remains usable while its query reports a response warning. */
|
||||
export const QueryWarning: Story = {
|
||||
args: { warning: true },
|
||||
};
|
||||
|
||||
/** The selected host's metrics drawer, reached through the route's detail state. */
|
||||
export const HostDetailsMetrics: Story = {
|
||||
args: { drawer: true, drawerTab: VIEWS.METRICS },
|
||||
};
|
||||
|
||||
/** The selected host's logs drawer, including its independently queried rows. */
|
||||
export const HostDetailsLogs: Story = {
|
||||
args: { drawer: true, drawerTab: VIEWS.LOGS },
|
||||
};
|
||||
|
||||
/** The selected host's traces drawer, including its independently queried rows. */
|
||||
export const HostDetailsTraces: Story = {
|
||||
args: { drawer: true, drawerTab: VIEWS.TRACES },
|
||||
};
|
||||
|
||||
/** The table and the drawer widgets mid-load, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the host list carries, held open: the Options button above the
|
||||
* table, the Hostname, IOWait and load average headers sharing the generic note
|
||||
* and its docs link, the Status header's own sentence about the ten minute
|
||||
* window, and the CPU, memory and disk headers, whose tooltip is the whole
|
||||
* threshold legend rather than a sentence. The Collapse Filters control beside
|
||||
* the quick filters is an antd tooltip and stays closed.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { jobsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type JobsArgs = PageStoryArgs<typeof jobsMocks>;
|
||||
|
||||
const pageStory = storyMocks(jobsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes jobs tab: active, succeeded and failed pods per job, and how long
|
||||
* each has been running.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=jobs`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Jobs',
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<JobsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Jobs by namespace, with active, failed and successful pods against the
|
||||
* completions the job asked for. The drawer adds the by-pod metrics tab.
|
||||
*/
|
||||
export const Default: StoryObj<JobsArgs> = {};
|
||||
74
frontend/src/pages/InfrastructureMonitoring/stories/Kubernetes.stories.mocks.tsx
generated
Normal file
74
frontend/src/pages/InfrastructureMonitoring/stories/Kubernetes.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { infraStoryMocks } from './InfrastructureMonitoring.stories.mocks';
|
||||
import { infraListResponse } from './__story_mockdata__/infraMonitoring';
|
||||
|
||||
/**
|
||||
* A node name long enough that the pod's metadata tooltip has to wrap, which the
|
||||
* page's own fixture is too short to show. The Rows, Query warning and Empty
|
||||
* state controls do not reach the story using this handler.
|
||||
*/
|
||||
export const podLongNodeName = rest.post(
|
||||
'http://localhost/api/v2/infra_monitoring/pods',
|
||||
async (req, res, ctx) => {
|
||||
const body = (await req.json()) as { offset?: number; limit?: number };
|
||||
|
||||
const list = infraListResponse({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
count: 20,
|
||||
offset: body.offset ?? 0,
|
||||
limit: body.limit ?? 10,
|
||||
});
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...list,
|
||||
data: {
|
||||
...list.data,
|
||||
records: list.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: {
|
||||
...record.meta,
|
||||
[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME]:
|
||||
'ip-10-0-3-17.eu-central-1.compute.internal (spot, gpu-a10g, capacity-reservation cr-0f21a8b7)',
|
||||
},
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* One mocks module per resource the Kubernetes tab lists. They all answer the
|
||||
* same endpoints; the entity they carry is what picks the list resource, the
|
||||
* checks type, the group-by attribute and the tabs the drawer offers, and it is
|
||||
* what puts `?category=` on the route the story starts on.
|
||||
*/
|
||||
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
|
||||
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
|
||||
export const namespacesMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
);
|
||||
export const clustersMocks = infraStoryMocks(InfraMonitoringEntity.CLUSTERS);
|
||||
export const deploymentsMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
);
|
||||
export const jobsMocks = infraStoryMocks(InfraMonitoringEntity.JOBS);
|
||||
export const daemonSetsMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.DAEMONSETS,
|
||||
);
|
||||
export const statefulSetsMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
);
|
||||
export const volumesMocks = infraStoryMocks(InfraMonitoringEntity.VOLUMES);
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { namespacesMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type NamespacesArgs = PageStoryArgs<typeof namespacesMocks>;
|
||||
|
||||
const pageStory = storyMocks(namespacesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes namespaces tab: CPU and memory aggregated per namespace, each row
|
||||
* opening the drawer.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=namespaces`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Namespaces',
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<NamespacesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Namespaces with the pod counts by status behind each one. The drawer carries
|
||||
* the counts cards that jump back to this list filtered, plus the by-pod metrics
|
||||
* tab.
|
||||
*/
|
||||
export const Default: StoryObj<NamespacesArgs> = {};
|
||||
|
||||
/** The selected namespace's by-pod metrics tab, opened through its detail route. */
|
||||
export const NamespaceDetailsPodMetrics: StoryObj<NamespacesArgs> = {
|
||||
args: { drawer: true, drawerTab: VIEWS.POD_METRICS },
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { nodesMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type NodesArgs = PageStoryArgs<typeof nodesMocks>;
|
||||
|
||||
const pageStory = storyMocks(nodesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes nodes tab: allocatable against used CPU and memory, and the
|
||||
* node's condition.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=nodes`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Nodes',
|
||||
tags: ['play'],
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<NodesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Nodes of the cluster, with the Ready and Not Ready condition counts, the pods
|
||||
* scheduled on each and CPU and memory read against what the node has left to
|
||||
* allocate.
|
||||
*/
|
||||
export const Default: StoryObj<NodesArgs> = {};
|
||||
|
||||
/**
|
||||
* Every tooltip the node list carries, held open over a hovered row: the Pod
|
||||
* Status cell, whose tooltip is a count per phase and only exists while the row
|
||||
* is hovered, beside the column headers sharing the generic note and the
|
||||
* Collapse Filters and Options buttons. The pod list renders its own count cells
|
||||
* only once grouped, so this is where that breakdown is reviewable.
|
||||
*/
|
||||
export const Tooltips: StoryObj<NodesArgs> = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.hover(
|
||||
await canvas.findByText('ip-10-0-1-24-1', {}, { timeout: 10000 }),
|
||||
);
|
||||
// The pod-status breakdown is only rendered for the hovered row.
|
||||
await screen.findByText('Running: 8', {}, { timeout: 10000 });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { podLongNodeName, podsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
|
||||
const pageStory = storyMocks(podsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes pods tab: status, restarts and age beside CPU and memory read
|
||||
* against the pod's own request and limit. The drawer adds the pod's events.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=pods`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Pods',
|
||||
tags: ['play'],
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PodsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Pods across every namespace: the status pill, restart count and age beside the
|
||||
* CPU and memory columns, each of those reading against the pod's own request and
|
||||
* limit. The drawer adds the events tab, which is the pod's Kubernetes events.
|
||||
*/
|
||||
export const Default: StoryObj<PodsArgs> = {};
|
||||
|
||||
/** The selected pod's Kubernetes events tab, opened through its detail route. */
|
||||
export const PodDetailsEvents: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true, drawerTab: VIEWS.EVENTS },
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the pod list carries, held open: Collapse Filters beside the
|
||||
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
|
||||
* headers sharing the generic note and its docs link, and the CPU and memory
|
||||
* limit headers, whose tooltip is the whole threshold legend rather than a
|
||||
* sentence. The matching request columns are hidden until the Options panel adds
|
||||
* them, and their legend is the same shape.
|
||||
*/
|
||||
export const Tooltips: StoryObj<PodsArgs> = {
|
||||
args: { tooltipsOpen: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The tooltips of the pod's details drawer on its logs tab: the namespace,
|
||||
* cluster and node metadata values, the node one long enough to wrap, and the
|
||||
* Go to Logs Explorer button beside the tab strip, with the list's own header
|
||||
* tooltips still open behind the drawer.
|
||||
*/
|
||||
export const TooltipsInDetailsDrawer: StoryObj<PodsArgs> = {
|
||||
args: { tooltipsOpen: true, drawer: true, drawerTab: VIEWS.LOGS },
|
||||
parameters: { msw: { handlers: [podLongNodeName] } },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Options panel over the list: the tooltip on the disabled switch of the
|
||||
* column the table cannot do without, and, because the panel lists the columns
|
||||
* the table is hiding, the CPU and memory request threshold legends the list
|
||||
* itself never renders.
|
||||
*/
|
||||
export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId(
|
||||
'k8s-table-options-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByText('Columns');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { statefulSetsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type StatefulSetsArgs = PageStoryArgs<typeof statefulSetsMocks>;
|
||||
|
||||
const pageStory = storyMocks(statefulSetsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Kubernetes stateful sets tab: desired against ready replicas, with CPU and
|
||||
* memory beside them.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=statefulsets`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/StatefulSets',
|
||||
component: InfrastructureMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<StatefulSetsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* StatefulSets by namespace: current against desired pods, the replica count and
|
||||
* the pod status counts. The drawer adds the by-pod metrics tab.
|
||||
*/
|
||||
export const Default: StoryObj<StatefulSetsArgs> = {};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user