mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-01 00:50:42 +01:00
Compare commits
119 Commits
main
...
feat/add-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f9cdb7a5 | ||
|
|
f459000855 | ||
|
|
687fbdc63b | ||
|
|
8fa126993d | ||
|
|
cb09b36121 | ||
|
|
777349217c | ||
|
|
76ebc93422 | ||
|
|
d5a479e7f3 | ||
|
|
ab06efc01b | ||
|
|
d88fa23cb5 | ||
|
|
a10e29e4d5 | ||
|
|
9daf662b97 | ||
|
|
e0a7cf63e4 | ||
|
|
962008ab92 | ||
|
|
67798123ea | ||
|
|
6479272f0c | ||
|
|
79a8532b0f | ||
|
|
e734e27625 | ||
|
|
1622e420ec | ||
|
|
dee1a07664 | ||
|
|
20bd6942f8 | ||
|
|
0b011d27b0 | ||
|
|
47c3916d3a | ||
|
|
f37dfa44fc | ||
|
|
df3acf9cc9 | ||
|
|
e111bf2b12 | ||
|
|
ff5486523a | ||
|
|
ccba61655c | ||
|
|
65f97a1343 | ||
|
|
2af3537bdf | ||
|
|
1f524929d6 | ||
|
|
3beaac8930 | ||
|
|
1fa629a74a | ||
|
|
dbc79de61f | ||
|
|
874248f0e3 | ||
|
|
7a0f6764b7 | ||
|
|
f2c66cb51e | ||
|
|
36f90157a1 | ||
|
|
642b192de6 | ||
|
|
590de4d77f | ||
|
|
198f565ca9 | ||
|
|
f718267996 | ||
|
|
61a26066ac | ||
|
|
380bbf69e3 | ||
|
|
ef143d36a3 | ||
|
|
68523bdc87 | ||
|
|
9a223f6f83 | ||
|
|
97fe31a482 | ||
|
|
85b237668a | ||
|
|
e722a1e77b | ||
|
|
1ac19e7ce9 | ||
|
|
3c4e40f446 | ||
|
|
38e34fc5a6 | ||
|
|
1c1d573269 | ||
|
|
9cd8a6e276 | ||
|
|
6cd390b3c8 | ||
|
|
05d9ea38da | ||
|
|
01d1b37f2a | ||
|
|
27a6923b85 | ||
|
|
7862884059 | ||
|
|
8590ef641e | ||
|
|
06372b4583 | ||
|
|
f8b8597a13 | ||
|
|
5c33cd7f46 | ||
|
|
2ea5e49e57 | ||
|
|
fb9d0d2b4d | ||
|
|
808bc1b6ba | ||
|
|
0a470e2da8 | ||
|
|
f8e952a296 | ||
|
|
faa99f41ca | ||
|
|
fd0a4b706a | ||
|
|
5114573b83 | ||
|
|
db599d7d5c | ||
|
|
74c6ed1fe6 | ||
|
|
33bad3c685 | ||
|
|
034fd31cec | ||
|
|
8118a2288c | ||
|
|
32bdc41398 | ||
|
|
f9a67881aa | ||
|
|
a4185d828e | ||
|
|
a3dbcd0ea3 | ||
|
|
afda5e737a | ||
|
|
6fbf40398d | ||
|
|
d1e0de3cbf | ||
|
|
c64572a087 | ||
|
|
0d5b4d8ebd | ||
|
|
5cede42232 | ||
|
|
7e190fe345 | ||
|
|
7a64f524d0 | ||
|
|
691240d7aa | ||
|
|
0a3e338088 | ||
|
|
f39dfbbaa7 | ||
|
|
7ee5581acc | ||
|
|
0ec4ab9697 | ||
|
|
f4a70f99f5 | ||
|
|
eaf9886f3f | ||
|
|
9669593935 | ||
|
|
d11162699f | ||
|
|
19ab1044c0 | ||
|
|
0978cc4532 | ||
|
|
19ce2a8515 | ||
|
|
7e2fbaacaa | ||
|
|
dab021b061 | ||
|
|
49151e999f | ||
|
|
85b7bc9dbf | ||
|
|
e4d0652d81 | ||
|
|
292f48d88b | ||
|
|
739e2de4b4 | ||
|
|
00fe541f09 | ||
|
|
4bfe2dadff | ||
|
|
7b6b41a20e | ||
|
|
f8310797b6 | ||
|
|
a317af08ba | ||
|
|
8e5f0b3e75 | ||
|
|
6dfa8fd985 | ||
|
|
ec28feabaf | ||
|
|
eb0b3eb540 | ||
|
|
97b50d67e1 | ||
|
|
899a0dc366 |
206
.claude/skills/signoz-page-story/SKILL.md
Normal file
206
.claude/skills/signoz-page-story/SKILL.md
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
name: signoz-page-story
|
||||
description: Explore a SigNoz page, map the endpoints, states and query params it has, then write its Storybook page story with control-driven msw mocks that reach every state. Use when asked to create, extend or review a Storybook story for a page under frontend/src/pages, to add controls to an existing page story, or to write defineStoryMocks handlers and mock data for a page.
|
||||
---
|
||||
|
||||
# SigNoz page stories
|
||||
|
||||
A page story renders the real page inside the real app shell against msw, and its
|
||||
controls panel can reach every state the page has. The panel is the deliverable,
|
||||
not the story list.
|
||||
|
||||
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
|
||||
control builders, module mocks, navigation). Read it first; this skill is the
|
||||
process on top of it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
|
||||
the inventory (endpoints, states, params, permissions, caps) before writing
|
||||
code. No inventory, no story.
|
||||
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
|
||||
names the endpoints step 1 missed.
|
||||
3. **Inventory to controls**: [references/controls.md](references/controls.md).
|
||||
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's
|
||||
mocks module.
|
||||
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
|
||||
report the story as done without it.
|
||||
|
||||
## Where it lands in the sidebar
|
||||
|
||||
The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`), so
|
||||
a page sits where someone would click it in the product. Four things decide that,
|
||||
and all four are part of writing the story, not a follow-up.
|
||||
|
||||
**Title.** `Pages/<Area>/<Page>`, where `<Area>` is the nav section and `<Page>`
|
||||
is the label the nav gives it.
|
||||
|
||||
- The leaf is the product's label, never the component's name: `MetricsExplorer`
|
||||
is `Metrics/Explorer`, `MeterExplorer` is `Metering/Cost Meter`,
|
||||
`AIAssistantPage` is `Noz`.
|
||||
- Never repeat the area in the leaf: `Alerts/Rules`, not `Alerts/AlertRules`.
|
||||
- A leaf never shares its name with a sibling folder. The folder wins and the
|
||||
page becomes `List`, or `Overview` for a tab strip: `Services/List` beside
|
||||
`Services/Detail`.
|
||||
- Title Case with spaces. No camelCase, no kebab.
|
||||
- Four levels is the floor to stay under: `Pages/Alerts/Channels/New` is as deep
|
||||
as it goes.
|
||||
- Pages nobody navigates to on purpose go under `Pages/System` (`Status`,
|
||||
`Unauthorized`, `Workspace Locked`), and the pre-session pages under
|
||||
`Pages/Auth`.
|
||||
|
||||
**Order.** `src/storybook/docs/sidebarOrder.ts` carries the order for every
|
||||
level. A new page in an existing area is appended to that area's array, in the
|
||||
order the product lists it; a new area goes where the side nav puts it. Missing
|
||||
entries fall to the end of their level rather than disappearing, so a forgotten
|
||||
edit is a page at the bottom of its area, not a broken sidebar.
|
||||
|
||||
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag
|
||||
filter answers questions with. Only these:
|
||||
|
||||
| Tag | When |
|
||||
| --- | --- |
|
||||
| `authz` | The page gates UI on permission checks through `lib/authz` (`AuthZButton`, `AuthZGuard`, `useAuthZ`). |
|
||||
| `role-gated` | The page still branches on the legacy role (`user.role`, `hasEditPermission`) and has no authz check. |
|
||||
| `beta` | `isBeta` on its nav entry. Drop the tag when the product drops the badge. |
|
||||
| `legacy` | Superseded by another page but still routed. The doc comment names the page to start from instead. |
|
||||
| `play` | The story file has a `play` function, so at least one state is reached by an interaction. |
|
||||
|
||||
`autodocs` comes from `preview.tsx` and is never written on a meta.
|
||||
|
||||
**Doc comment on the meta.** What the page is, in the page's own terms, then a
|
||||
blank line, then the route:
|
||||
|
||||
```tsx
|
||||
const pageStory = storyMocks(logsExplorerMocks, { route: explorerRoute('explorer') });
|
||||
|
||||
/**
|
||||
* The logs explorer: the query builder, the list, the frequency chart and the log
|
||||
* detail drawer, with quick filters and saved views beside them.
|
||||
*
|
||||
* Route: `/logs/logs-explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Logs/Explorer',
|
||||
tags: ['play'],
|
||||
component: LogsModulePage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<LogsExplorerArgs>;
|
||||
```
|
||||
|
||||
The `pageStory` const and the trailing `parameters` line are what make the doc
|
||||
comment safe. The comment compiles to a `parameters` property that the csf plugin
|
||||
appends after the spread, so a meta that spreads `storyMocks(...)` and stops
|
||||
there loses `parameters.signoz` and renders the page against the global handlers
|
||||
alone: every one of the page's endpoints misses. Restating `parameters` as a
|
||||
literal gives the plugin something to merge into. `resolveStory` logs the
|
||||
combination that says it happened, so the console names it rather than leaving it
|
||||
to be found by reading the page.
|
||||
|
||||
It is the description on the page's Docs page, which is the only place a reader
|
||||
who is not in the code finds out what the page is for. Two or three sentences:
|
||||
what it shows, what drives it, and the gating worth knowing about (`Gated on
|
||||
authz permissions`, `follows the legacy editor role`). A control-driven route
|
||||
says so instead of a path: ``Route: `/metrics-explorer/*`, the tab control picks
|
||||
which``.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
|
||||
every widget carrying data. Empty, loading and failed are variants or control
|
||||
values, never the default.
|
||||
- **A control is a knob on a response**, resolved through `handlers`, `config` or
|
||||
`effect`. Never a component prop, never a module mock added for one story.
|
||||
- **Every branch in the inventory is reachable from the panel.** A state that
|
||||
needs a code edit to see is a missing control.
|
||||
- **Never re-declare what every story already has**: banner, side nav, data state
|
||||
(loaded/loading/error), access preset, permissions, check state.
|
||||
- **A variant earns a story only when it is worth linking to**: an empty
|
||||
workspace, a viewer, a page mid-load. Everything else stays a control.
|
||||
- **Endpoints the page owns go through `response.json`**, so the Data control
|
||||
covers loaded, loading and failed in one declaration. Endpoints the page cannot
|
||||
render without (ingestion detection, preferences, feature payloads) take a
|
||||
plain resolver so the shell survives the loading and error states.
|
||||
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param
|
||||
navigation works inside a story; a different pathname is blocked and reported
|
||||
by the overlay. A control for a param is worth it only when the param is a page
|
||||
mode someone would want to flip.
|
||||
- **File layout**: `src/pages/<Page>/<Page>.stories.tsx`,
|
||||
`src/pages/<Page>/<Page>.stories.mocks.tsx`, payload builders in
|
||||
`src/pages/<Page>/__story_mockdata__/<page>.ts`. Nothing page-specific in
|
||||
`src/storybook/controls/`. A page that is a tab strip over several routes gets
|
||||
one story file per tab, in its own folder under the module page
|
||||
(`LogsModulePage/Pipelines/Pipelines.stories.tsx`), each with its own mocks and
|
||||
`__story_mockdata__/`; the builders more than one tab needs stay in the module
|
||||
page's own. Every one of them renders the module page, so the tab strip is
|
||||
there, and the `route` its mocks return decides which tab is open.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a
|
||||
control: a drawer, a modal, an edit mode the page holds in component state.
|
||||
Drive it with `userEvent` and the queries from `storybook/test`, take the first
|
||||
of a repeated row action, and wait on the state's own text. The page fetches
|
||||
before it renders a row, so the finder needs a timeout past the 1s default. A
|
||||
state the app drops again on its own, such as one keyed on an array identity
|
||||
that a refetch replaces, does not get a story: it would not survive being
|
||||
looked at. A *sequence* of such states, a wizard's steps or a
|
||||
questionnaire's pages, is still a control: declare the steps in the mocks
|
||||
module and walk them from a `play` on the meta that destructures `mount`, which
|
||||
is what makes Storybook replay it on an arg change. See
|
||||
[references/controls.md](references/controls.md).
|
||||
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file
|
||||
under a `__story_mockdata__/` open with this banner, above the imports:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
```
|
||||
|
||||
The root `.gitattributes` marks both paths `linguist-generated=true`, so the
|
||||
reviewer gets them collapsed and spends the attention on the rendered page. The
|
||||
story file is the human surface and never carries the banner. A file with the
|
||||
banner has to stay regenerable from the page alone: no page knowledge in
|
||||
`src/storybook/`, and builders typed from `src/api/generated` where the endpoint
|
||||
has types, so a contract change is a compile error instead of a mock that lies.
|
||||
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
|
||||
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
|
||||
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed
|
||||
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
|
||||
writing a response shape inline, check if a builder exists; if not and the
|
||||
shape will repeat, add it there. Page-specific builders stay in the page's
|
||||
`__story_mockdata__/`.
|
||||
- **The story's own doc comment is per state.** Every `export const` gets one:
|
||||
what that state shows, not how it is built. It renders in the States list on
|
||||
the page's Docs page, so `Undocumented.` there is a story nobody described.
|
||||
- **Story names come from a fixed vocabulary** where one fits: `Default`,
|
||||
`Viewer`, `Empty`, `Loading`, `Error`. Page-specific states get page-specific
|
||||
names (`NoIngestion`, `Unlicensed`), never a second spelling of one of those
|
||||
(`ViewerAccess`, `NonAdmin`).
|
||||
- **No comment is the default.** Write one only for what the code cannot show:
|
||||
a shape the backend dictates, an app bug the mock reproduces, an ordering or
|
||||
cap the page depends on, a workaround and the reason for it. Never restate a
|
||||
name, a type, or what a builder plainly builds; if the sentence reads as the
|
||||
signature in prose, delete it. Nothing addressed to a reviewer. The one
|
||||
comment a story always gets is its own doc comment: what it shows, in the
|
||||
page's own terms.
|
||||
|
||||
## Done means
|
||||
|
||||
- [ ] `Default` shows the page with data, checked in dark and light
|
||||
- [ ] title follows the sidebar rules, tags declared, and the page's entry added
|
||||
to `src/storybook/docs/sidebarOrder.ts`
|
||||
- [ ] the meta carries its doc comment with the `Route:` line, the meta restates
|
||||
`parameters: { ...pageStory.parameters }` after the spread, and every story
|
||||
export carries its own doc comment
|
||||
- [ ] the page's Docs page renders: description, controls table, and one row per
|
||||
state with no `Undocumented.`
|
||||
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
|
||||
- [ ] every control flipped once, its effect seen on screen
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled
|
||||
request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
|
||||
`pnpm exec oxfmt --check <files>` all clean. The repo has no
|
||||
prettier: `pnpm exec prettier` prints a pass while exiting 254
|
||||
227
.claude/skills/signoz-page-story/references/controls.md
Normal file
227
.claude/skills/signoz-page-story/references/controls.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Turning the inventory into controls
|
||||
|
||||
Every row of the inventory becomes a control, a global control that already
|
||||
exists, or a documented reason it cannot be one.
|
||||
|
||||
## Imports
|
||||
|
||||
Paths written as `src/storybook/...` in prose are repo paths, not import
|
||||
specifiers. Stories import through the `@/` alias (`@/*` → `./src/*`); modules
|
||||
inside `src/storybook/` import each other relatively.
|
||||
|
||||
| Import | From |
|
||||
| --- | --- |
|
||||
| `toggleControl`, `countControl`, `choiceControl`, `multiChoiceControl` | `../controls/controls` |
|
||||
| `defineStoryMocks`, `storyMocks` | `../controls/defineStoryMocks` |
|
||||
| `PageStoryArgs` | `../controls/resolveStoryMocks` |
|
||||
| `MockRequest`, `MockResponse` | `../controls/types` |
|
||||
| `withAppLayout` | `@/storybook/decorators/withAppLayout` |
|
||||
| the page's mocks, from the story | `./<Page>.stories.mocks` |
|
||||
| `queryRangeV5ScalarResponse`, `queryRangeV5RawResponse`, etc. | `@/storybook/msw/__story_mockdata__/queryRange` |
|
||||
|
||||
## Which builder
|
||||
|
||||
`src/storybook/controls/controls.ts`:
|
||||
|
||||
| The state is | Builder |
|
||||
| --- | --- |
|
||||
| on or off (a signal ingesting, a feature present) | `toggleControl` |
|
||||
| how many rows a list has | `countControl` |
|
||||
| one of several modes (tab, visibility, plan, severity filter) | `choiceControl` |
|
||||
| a subset (steps skipped, columns shown, signals selected) | `multiChoiceControl` |
|
||||
|
||||
Rules that come with them:
|
||||
|
||||
- `countControl` `max` goes past what the page renders, so a story can show the
|
||||
cap being hit. `0` is the empty state, which is why an empty list rarely needs
|
||||
its own story. When the cap is in the *request* (`?limit=5`) rather than the
|
||||
renderer, stop `max` at the limit: a longer response is a body the backend
|
||||
cannot send.
|
||||
- `choiceControl` options come from a `const` array typed with
|
||||
`(typeof X)[number]`, not from string literals scattered in the handlers.
|
||||
- Defaults describe the fully-populated page. The panel starts where `Default`
|
||||
starts.
|
||||
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
|
||||
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
|
||||
control.
|
||||
- `description` only when the name does not carry the effect (what dismissing
|
||||
does, what the cap is, which widget it feeds).
|
||||
|
||||
## Which hook
|
||||
|
||||
`defineStoryMocks` takes three, all optional:
|
||||
|
||||
- `handlers(values, response)`: the page's endpoints. Everything the page owns
|
||||
goes through `response.json`, so the global Data control turns the whole page
|
||||
into loading or failed without a second declaration. An endpoint the page
|
||||
cannot render at all without (ingestion detection, preferences, license
|
||||
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
|
||||
visible while the rest hangs or fails.
|
||||
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
|
||||
`appContext`, `reduxState`, `queryBuilder`, `theme`.
|
||||
- `effect(values)`: module-level state no provider exposes.
|
||||
|
||||
One endpoint feeding several widgets stays one handler that reads the request.
|
||||
`response.json` hands the request to the builder and awaits it, so reading a
|
||||
query param, or a POST body, does not cost the Data control:
|
||||
|
||||
```ts
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) =>
|
||||
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
```ts
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const signal = body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
return countResponse(values[`${signal}Ingestion`] ? 4213 : 0);
|
||||
}),
|
||||
),
|
||||
```
|
||||
|
||||
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
|
||||
endpoint has to keep answering while the Data control is on `loading` or
|
||||
`error`: detection calls the page cannot render without.
|
||||
|
||||
## Mutations
|
||||
|
||||
A control drives the response, so a write the page makes against state a control
|
||||
owns does not stick: the refetch answers with the control's value and the button
|
||||
appears to do nothing. Two honest options: leave it declarative and say so in
|
||||
the PR, or move the state into `effect` so the handler can read what the page
|
||||
wrote. Never fake the write by mutating a builder's module state without saying
|
||||
where the state lives.
|
||||
|
||||
## Wiring it up
|
||||
|
||||
```ts
|
||||
// src/pages/Services/Services.stories.mocks.tsx
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
export const servicesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
services: countControl('Services', { group: LISTS, value: 8, max: 12 }),
|
||||
apdex: choiceControl<ApdexState>('Apdex', {
|
||||
group: HEALTH,
|
||||
options: APDEX_STATES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => buildServices(values.services, values.apdex)),
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/Services/Services.stories.tsx
|
||||
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
|
||||
|
||||
const pageStory = storyMocks(servicesMocks, { route: ROUTES.APPLICATION });
|
||||
|
||||
/**
|
||||
* Every instrumented service with its p99, error rate and throughput.
|
||||
*
|
||||
* Route: `/services`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Services/List',
|
||||
component: Services,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ServicesArgs>;
|
||||
```
|
||||
|
||||
`PageStoryArgs` folds in the global controls, so a story's `args` can set
|
||||
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
|
||||
|
||||
## A step the page keeps in component state
|
||||
|
||||
A wizard's step, a questionnaire's page, a picker's next question: the page holds
|
||||
it in `useState` and nothing in the URL says which one is open. It is still a
|
||||
control. Declare the steps in the mocks module and drive them from a `play` on
|
||||
the **meta**, so every story of the page inherits the walk and only sets `args`:
|
||||
|
||||
```tsx
|
||||
// <Page>.stories.mocks.tsx
|
||||
export const SETUP_STEPS = ['pick-source', 'pick-framework', 'configure'] as const;
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number];
|
||||
|
||||
controls: {
|
||||
step: choiceControl<SetupStep>('Setup step', { group: SETUP, options: SETUP_STEPS, value: 'pick-source' }),
|
||||
},
|
||||
```
|
||||
|
||||
```tsx
|
||||
// <Page>.stories.tsx
|
||||
const meta = {
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
},
|
||||
...storyMocks(pageMocks),
|
||||
} satisfies Meta<PageArgs>;
|
||||
|
||||
export const Configure: Story = { args: { step: 'configure' } };
|
||||
```
|
||||
|
||||
**Destructuring `mount` is what makes it a control.** Storybook re-runs a play
|
||||
function on an arg change only for a story whose play asks to be remounted
|
||||
(`usesMount`); otherwise it re-renders the tree the previous walk left behind and
|
||||
the panel looks broken. With `mount` destructured, the story renders when `play`
|
||||
calls it, and every arg change replays the walk from a fresh mount.
|
||||
|
||||
The walk itself:
|
||||
|
||||
- one `answer` function per step, in an array indexed the same as the step list,
|
||||
so reaching step *n* is `answers.slice(0, STEPS.indexOf(step))`;
|
||||
- answer each step with the least its Next button accepts, and prefer a "do this
|
||||
later" over filling a slider;
|
||||
- run them sequentially (`reduce` over a promise), since each answer is what
|
||||
renders the step the next one reads;
|
||||
- bail out when the page did not start where the walk expects, such as a source
|
||||
deep-linked past the questions. Check for the first step's own text rather than
|
||||
reading another control's value.
|
||||
|
||||
An endpoint that only settles the transition between two steps (the profile a
|
||||
questionnaire saves before its last page) takes a plain resolver, or the Data
|
||||
control on `loading` strands the walk halfway.
|
||||
|
||||
## Not a control
|
||||
|
||||
- Anything the global controls already cover: banner, side nav, data state,
|
||||
access preset, permissions, check state.
|
||||
- A knob whose effect nobody can see on the page. Delete it or find the widget it
|
||||
was supposed to drive.
|
||||
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
|
||||
`viewer`), and the builder turns intent into the payload.
|
||||
- Anything that needs a module mock or a component prop to work. If the state
|
||||
cannot be produced from a response, config or module state, say so in the PR
|
||||
instead of faking it.
|
||||
|
||||
## Control or story
|
||||
|
||||
Default to a control. Write a story when the state is worth a link:
|
||||
|
||||
- the fresh workspace, because that is what a new user sees
|
||||
- the restricted user, when permissions visibly change the page
|
||||
- a page-defining mode (a tab, a category) that has its own layout
|
||||
|
||||
Combinations of controls do not need stories, which is what the panel is for.
|
||||
|
||||
Each story gets one prose doc comment: what it shows, in the page's own terms.
|
||||
Everywhere else the comment rule in SKILL.md applies: write one only for what
|
||||
the code cannot show.
|
||||
64
.claude/skills/signoz-page-story/references/discovery.md
Normal file
64
.claude/skills/signoz-page-story/references/discovery.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Mapping a page
|
||||
|
||||
Two passes: read the code, then let the running story correct you. Write the
|
||||
inventory down: it is what the controls are derived from, and the only
|
||||
protection against a story that renders one state and calls it a page.
|
||||
|
||||
## Pass 1: read the page
|
||||
|
||||
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts
|
||||
(`src/container/<Feature>/`), the hooks those use, the components with their own
|
||||
fetches. Stop at leaf components that take props only.
|
||||
|
||||
Grep recipes, run against the page's directories:
|
||||
|
||||
| Looking for | Grep |
|
||||
| --- | --- |
|
||||
| endpoints | `useQuery\|useMutation\|useInfiniteQuery`, then the `api/` module it calls |
|
||||
| endpoint URLs | the api module's `axios.get\|post` |
|
||||
| endpoint URLs behind a generated hook | the hook lives in `src/api/generated/services/<name>/index.ts` and the URL only appears in the fetcher body: `rg 'url: \`' src/api/generated/services/<name>/` |
|
||||
| url state | `useUrlQuery\|useUrlQueryData\|useUrlSearchState\|useQueryState\|QueryParams\.` |
|
||||
| navigation | `useSafeNavigate\|history.push\|<Link` |
|
||||
| permissions | `useAuthZ\|AuthZGuard\|AuthZButton\|hasEditPermission\|routePermission` |
|
||||
| flags and prefs | `useFeatureFlag\|FeatureKeys\.\|USER_PREFERENCES\.\|userPreferences` |
|
||||
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
|
||||
| render caps | `slice(0,\|PAGE_SIZE\|pageSize\|limit` |
|
||||
|
||||
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names,
|
||||
`src/lib/authz/README.md` how a permission check resolves.
|
||||
|
||||
## The inventory
|
||||
|
||||
One table, in the story's PR or scratch notes:
|
||||
|
||||
| Endpoint | Feeds | States it can be in |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/x` | the header count | populated, zero, error |
|
||||
|
||||
Plus four short lists:
|
||||
|
||||
- **Query params** the page reads, and what each one switches.
|
||||
- **Permission checks** the page makes, and what disappears when each is denied.
|
||||
- **Preferences and flags** that change layout (dismissed banners, onboarding
|
||||
checklists, opt-in views).
|
||||
- **Caps**: how many rows each list renders before it truncates or paginates.
|
||||
|
||||
A state that appears in this inventory and not in the controls panel is a bug in
|
||||
the story.
|
||||
|
||||
## Pass 2: let it run
|
||||
|
||||
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the
|
||||
route, add `withAppLayout`, then open it (see verify.md). The console is the
|
||||
oracle:
|
||||
|
||||
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1
|
||||
missed. Add it to the inventory.
|
||||
- an msw unhandled-request warning: a request going to an origin the handlers do
|
||||
not answer on. handlers are declared against `http://localhost`.
|
||||
- a spinner that never resolves with the Data control on `loaded`: a handler
|
||||
whose URL does not match what the page calls.
|
||||
- the navigation overlay on mount: the page redirects, usually because `route`
|
||||
is wrong or a guard is failing on a permission the controls have not granted.
|
||||
|
||||
Repeat until the console is silent. Only then start declaring controls.
|
||||
111
.claude/skills/signoz-page-story/references/verify.md
Normal file
111
.claude/skills/signoz-page-story/references/verify.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Verifying a page story
|
||||
|
||||
A story is not done because it compiles. It is done when each control has been
|
||||
seen changing the page and the console is silent.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm storybook --ci --quiet # :6006, background it
|
||||
```
|
||||
|
||||
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
|
||||
already-running server; an empty first poll is not a broken `stories` glob.
|
||||
|
||||
Story ids come from the meta title: `Pages/Services/List` →
|
||||
`pages-services-list`, plus the story export in kebab-case. Render one story on
|
||||
its own:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
|
||||
```
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
Args are settable in the iframe URL, so a whole sweep runs headless without
|
||||
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
|
||||
indexed, several separated by `;`, and the theme through `globals`:
|
||||
|
||||
```
|
||||
&args=services:0;apdex:poor;access:viewer;dataState:loading
|
||||
&args=signals[0]:logs;signals[1]:traces
|
||||
&globals=theme:light
|
||||
```
|
||||
|
||||
That is the cheap way to check a control does something: load with and without
|
||||
it, diff the page text.
|
||||
|
||||
## Drive it
|
||||
|
||||
Playwright lives in the repo's e2e workspace, so a scratch script can use it
|
||||
directly:
|
||||
|
||||
```js
|
||||
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
|
||||
const { chromium } = pw;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error' || m.type() === 'warning') problems.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => problems.push(e.message));
|
||||
|
||||
await page.goto(`${story}&args=services:0`, { waitUntil: 'networkidle' });
|
||||
await page.locator('body').waitFor();
|
||||
console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
Screenshots are worth taking for `Default` in both themes
|
||||
(`&globals=theme:light`): text extraction does not catch an unstyled page.
|
||||
|
||||
## Gates
|
||||
|
||||
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
|
||||
msw unhandled-request warning, a React key or state warning: assume the story
|
||||
is wrong first. A warning that survives is sometimes the app's. Prove it by
|
||||
turning off the control that renders the widget and watching the warning go
|
||||
with it, and by finding the same component elsewhere doing it right. Then
|
||||
report the app bug in the PR. Never invent a field the API does not return to
|
||||
silence a warning.
|
||||
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
|
||||
means the page is trying to leave: wrong `route`, or a guard denying on a
|
||||
permission the controls did not grant.
|
||||
- **Every control moves something.** Sweep them one at a time from the URL and
|
||||
diff the page text. A control with no diff is either wired to nothing or aimed
|
||||
at a widget that is not rendering. Some only show their effect after an
|
||||
interaction, such as a tab that has to be clicked or a select that has to be
|
||||
opened. Drive that interaction rather than calling the control unobservable.
|
||||
- **Both themes render styled.** An unstyled page means the story is not inside
|
||||
the provider decorator, or `<body data-theme>` was lost.
|
||||
- **Roles agree.** `<body data-signoz-story-role>` and
|
||||
`<body data-signoz-context-role>` disagreeing means the page reads a different
|
||||
`AppContext` than the story config fills.
|
||||
- **The page's own navigation works.** Tabs, filters and pagination that write
|
||||
query params should re-render the page in place; only leaving the page belongs
|
||||
in the overlay.
|
||||
|
||||
## Then the usual
|
||||
|
||||
```bash
|
||||
pnpm tsgo --noEmit
|
||||
pnpm exec oxlint <changed files>
|
||||
pnpm exec oxfmt --check <changed files>
|
||||
```
|
||||
|
||||
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
|
||||
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
|
||||
not one.
|
||||
|
||||
## Common failures
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| endless spinner with Data on `loaded` | handler URL does not match the call; handlers answer on `http://localhost` |
|
||||
| page renders but empty | response shape wrong; compare against the api module's type, not a guess |
|
||||
| 501 in the console | endpoint nobody mocked; the catch-all is answering |
|
||||
| new control missing from the panel | project-level control added; the tab needs a reload |
|
||||
| control flips but nothing changes | the widget is gated by something else: a permission, a flag, a preference |
|
||||
| shell disappears in `loading` | an endpoint the shell needs went through `response.json`; give it a plain resolver |
|
||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,3 @@
|
||||
*.css linguist-detectable=false
|
||||
*.css linguist-detectable=false
|
||||
*.stories.mocks.tsx linguist-generated=true
|
||||
**/__story_mockdata__/** linguist-generated=true
|
||||
|
||||
6
frontend/.gitignore
vendored
6
frontend/.gitignore
vendored
@@ -28,4 +28,8 @@ e2e/test-plan/saved-views/
|
||||
e2e/test-plan/service-map/
|
||||
e2e/test-plan/services/
|
||||
e2e/test-plan/traces/
|
||||
e2e/test-plan/user-preferences/
|
||||
e2e/test-plan/user-preferences/
|
||||
|
||||
# Storybook
|
||||
/storybook-static/
|
||||
debug-storybook.log
|
||||
|
||||
102
frontend/.storybook/main.ts
Normal file
102
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import type { Plugin, PluginOption } from 'vite';
|
||||
|
||||
const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
||||
|
||||
/**
|
||||
* Modules replaced for every story. Same idea as `moduleNameMapper` in
|
||||
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
|
||||
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
|
||||
* not catch `lib/historyUtils`.
|
||||
*
|
||||
* Each replacement is typed as the module it stands in for, so drift is a
|
||||
* compile error rather than a story that fails at render. The `jest` note on
|
||||
* each entry is where the same import lands under the other runner. The two
|
||||
* only diverge where the runner needs them to.
|
||||
*/
|
||||
const mockAliases = [
|
||||
{
|
||||
// jest: not replaced, jsdom drives a real browser history.
|
||||
find: /^(?:src\/)?lib\/history$/,
|
||||
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
|
||||
},
|
||||
{
|
||||
// jest: src/__tests__/logEventMock.ts
|
||||
find: /^(?:src\/)?api\/common\/logEvent$/,
|
||||
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, the suite mounts a mock store per test.
|
||||
find: /^(?:src\/)?store$/,
|
||||
replacement: `${srcPath}/storybook/mocks/store.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
|
||||
// resolves a relative `/api/...` against `http://localhost`.
|
||||
find: /^(?:src\/)?constants\/env$/,
|
||||
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Plugins from `vite.config.ts` that either target the app's `index.html` or
|
||||
* only pay off in a production build.
|
||||
*/
|
||||
const EXCLUDED_PLUGINS = [
|
||||
'vite-plugin-checker',
|
||||
'dev-base-path',
|
||||
'dev-boot-data',
|
||||
'vite-plugin-image-optimizer',
|
||||
'vite-plugin-compression',
|
||||
];
|
||||
|
||||
const isExcluded = (plugin: PluginOption): boolean =>
|
||||
!!plugin &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
EXCLUDED_PLUGINS.includes((plugin as Plugin).name);
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/storybook/docs/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
|
||||
// `../public` carries the fonts, icons and i18n bundles the app expects at
|
||||
// the root; `./public` carries the msw worker, which must not ship in a
|
||||
// production build.
|
||||
staticDirs: ['../public', './public'],
|
||||
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
|
||||
core: { disableTelemetry: true },
|
||||
viteFinal: async (viteConfig) => {
|
||||
const plugins = (viteConfig.plugins ?? [])
|
||||
.flat(Infinity as 1)
|
||||
.filter((plugin) => !isExcluded(plugin as PluginOption));
|
||||
|
||||
const existingAlias = viteConfig.resolve?.alias;
|
||||
const normalizedAlias = Array.isArray(existingAlias)
|
||||
? existingAlias
|
||||
: Object.entries(existingAlias ?? {}).map(([find, replacement]) => ({
|
||||
find,
|
||||
replacement: replacement as string,
|
||||
}));
|
||||
|
||||
return {
|
||||
...viteConfig,
|
||||
build: {
|
||||
...viteConfig.build,
|
||||
// `vite.config.ts` sets this for the app; Storybook's builder replaces
|
||||
// `build` wholesale, which leaves rolldown-vite on its default
|
||||
// lightningcss. That one rejects `:global()` in a plain stylesheet, which
|
||||
// the app has, and the static build dies in CSS minification.
|
||||
cssMinify: 'esbuild',
|
||||
},
|
||||
plugins,
|
||||
resolve: {
|
||||
...viteConfig.resolve,
|
||||
alias: [...mockAliases, ...normalizedAlias],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
24
frontend/.storybook/preview-head.html
Normal file
24
frontend/.storybook/preview-head.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
// i18n's language detector would pick the browser locale (e.g. `en-US`), which
|
||||
// /public/locales does not ship; pin it to the bundled language instead.
|
||||
window.localStorage.setItem('i18nextLng', 'en');
|
||||
|
||||
// The Go backend injects this at boot; every integration it enables is off in
|
||||
// Storybook so no third-party script loads inside the iframe.
|
||||
window.signozBootData = {
|
||||
settings: {
|
||||
posthog: { enabled: false, apiHost: '', key: '', uiHost: '' },
|
||||
appcues: { enabled: false, appId: '' },
|
||||
sentry: { enabled: false, dsn: '', tunnel: '' },
|
||||
pylon: { enabled: false, appId: '', identitySecret: '' },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
230
frontend/.storybook/preview.tsx
Normal file
230
frontend/.storybook/preview.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
import PageDocs from '../src/storybook/docs/PageDocs';
|
||||
import ThemedDocsContainer from '../src/storybook/docs/ThemedDocsContainer';
|
||||
import { withProviders } from '../src/storybook/decorators/withProviders';
|
||||
import { globalMocks } from '../src/storybook/globals';
|
||||
import { resetStoryHistory } from '../src/storybook/navigation/containment';
|
||||
import { clearBlockedNavigations } from '../src/storybook/navigation/blockedNavigationStore';
|
||||
import {
|
||||
resolveStory,
|
||||
type StoryRuntimeContext,
|
||||
} from '../src/storybook/runtime/resolveStory';
|
||||
|
||||
import '../src/ReactI18';
|
||||
|
||||
// `src/index.tsx` does this at boot: without it `@monaco-editor/react` falls back
|
||||
// to its loader default and pulls Monaco from cdn.jsdelivr.net, which msw does
|
||||
// not report because the requests look like static assets.
|
||||
import '../src/lib/monaco/setup';
|
||||
|
||||
import '../src/styles.scss';
|
||||
|
||||
import '../src/storybook/storybook-root.scss';
|
||||
|
||||
interface StorybookWorkerHolder {
|
||||
__signozStorybookWorker?: StorybookWorker;
|
||||
}
|
||||
|
||||
const holder = window as unknown as StorybookWorkerHolder;
|
||||
|
||||
/**
|
||||
* One worker per page, even if this module is re-executed by HMR. Two live
|
||||
* workers both answer the service worker and the story gets whichever replies
|
||||
* first.
|
||||
*/
|
||||
interface StorybookWorker {
|
||||
worker: SetupWorker;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
|
||||
const { worker, ready } = (holder.__signozStorybookWorker ??=
|
||||
((): StorybookWorker => {
|
||||
const instance = setupWorker();
|
||||
|
||||
return {
|
||||
worker: instance,
|
||||
ready: instance.start({
|
||||
serviceWorker: { url: './mockServiceWorker.js' },
|
||||
// Storybook's own traffic (index.json, HMR, telemetry) goes unhandled by
|
||||
// design; only flag the app's API calls so a missing handler is obvious.
|
||||
onUnhandledRequest: (request, print): void => {
|
||||
const url = new URL(request.url.href);
|
||||
const isStaticAsset =
|
||||
/\.(?:woff2?|ttf|otf|css|js|map|png|jpe?g|svg|webp|ico)$/.test(
|
||||
url.pathname,
|
||||
);
|
||||
const isAppRequest =
|
||||
!isStaticAsset &&
|
||||
(url.pathname.startsWith('/api/') || url.host !== window.location.host);
|
||||
|
||||
if (isAppRequest) {
|
||||
print.warning();
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
})());
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { expanded: true },
|
||||
// The sidebar order, mirroring the app's own side nav
|
||||
// (`container/SideNav/menuItems.tsx`), so a page sits where someone would
|
||||
// click it in the product. Storybook's default is the order the story files
|
||||
// happen to be globbed in, which puts `src/modules` first. Anything missing
|
||||
// from a level lands after the entries listed for it, in file order, so a new
|
||||
// story shows up at the end of its area rather than disappearing. Stories
|
||||
// inside a file are never listed, so they keep the order they are declared
|
||||
// in, `Default` first. Storybook parses this out of the file, so it has to
|
||||
// stay an inline literal.
|
||||
options: {
|
||||
storySort: {
|
||||
order: [
|
||||
'Docs',
|
||||
'Pages',
|
||||
[
|
||||
'Home',
|
||||
'Alerts',
|
||||
[
|
||||
'Rules',
|
||||
'Triggered',
|
||||
'Overview',
|
||||
'History',
|
||||
'Create',
|
||||
'Edit',
|
||||
'Planned Downtime',
|
||||
'Routing Policies',
|
||||
'Channels',
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
['List', 'Detail', 'Panel Editor', 'Widget Editor', 'Public'],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
[
|
||||
'Explorer',
|
||||
'Live Tail',
|
||||
'Saved Views',
|
||||
'Pipelines',
|
||||
'Settings',
|
||||
'Legacy Explorer',
|
||||
],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', 'Funnel Details', 'Legacy Explorer'],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
[
|
||||
'Overview',
|
||||
'Kubernetes',
|
||||
[
|
||||
'Clusters',
|
||||
'Nodes',
|
||||
'Namespaces',
|
||||
'Pods',
|
||||
'Deployments',
|
||||
'DaemonSets',
|
||||
'StatefulSets',
|
||||
'Jobs',
|
||||
'Volumes',
|
||||
],
|
||||
],
|
||||
'Integrations',
|
||||
['List', 'Details', 'Cloud Account'],
|
||||
'Exceptions',
|
||||
['List', 'Detail'],
|
||||
'External APIs',
|
||||
'AI Observability',
|
||||
['Overview', 'Model Pricing', 'Attribute Mapping'],
|
||||
'Noz',
|
||||
'Metering',
|
||||
['Cost Meter', 'Usage Explorer'],
|
||||
'Messaging Queues',
|
||||
['Overview', 'Kafka', 'Kafka Detail', 'Celery'],
|
||||
'Onboarding',
|
||||
['Questionnaire', 'Add Data Source'],
|
||||
'Settings',
|
||||
[
|
||||
'Workspace',
|
||||
'Account',
|
||||
'Billing',
|
||||
'MCP Server',
|
||||
'Roles',
|
||||
'Role Details',
|
||||
'Role Editor',
|
||||
'Members',
|
||||
'Service Accounts',
|
||||
'Ingestion',
|
||||
'Single Sign-on',
|
||||
'Keyboard Shortcuts',
|
||||
],
|
||||
'Auth',
|
||||
['Login', 'Sign Up', 'Forgot Password', 'Reset Password'],
|
||||
'System',
|
||||
[
|
||||
'Status',
|
||||
'Support',
|
||||
'License',
|
||||
'Not Found',
|
||||
'Unauthorized',
|
||||
'Error Fallback',
|
||||
'Workspace Locked',
|
||||
'Workspace Suspended',
|
||||
'Workspace Access Restricted',
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
docs: { page: PageDocs, container: ThemedDocsContainer },
|
||||
},
|
||||
// Every page story gets a docs page: the descriptions on the meta and on each
|
||||
// story are the page's documentation, and without this they render nowhere.
|
||||
tags: ['autodocs'],
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'SigNoz color scheme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
items: [
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'light', title: 'Light' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: { theme: 'dark' },
|
||||
// Controls every story carries: permissions, banners, and whether the page's
|
||||
// own endpoints answer, hang or fail.
|
||||
args: globalMocks.args,
|
||||
argTypes: globalMocks.argTypes,
|
||||
decorators: [withProviders],
|
||||
loaders: [
|
||||
// Runs on every render, args changes included, and ahead of the decorators:
|
||||
// the whole story world is put in place here, so the provider tree only has
|
||||
// to read it. Re-registering the handlers per render also means an edit to a
|
||||
// handler module takes effect on the next render instead of leaving the
|
||||
// worker on the set it was created with.
|
||||
async (context): Promise<void> => {
|
||||
const world = resolveStory(context as unknown as StoryRuntimeContext);
|
||||
|
||||
world.apply();
|
||||
world.install(worker);
|
||||
|
||||
await ready;
|
||||
},
|
||||
],
|
||||
beforeEach: () => {
|
||||
clearBlockedNavigations();
|
||||
resetStoryHistory();
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
309
frontend/.storybook/public/mockServiceWorker.js
Normal file
309
frontend/.storybook/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,309 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (1.3.2).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// msw bypasses server-sent events here, because it answers a request in one
|
||||
// piece and has no stream to hand back. A story is not a live connection
|
||||
// either: it wants the backlog a page renders, and one response carries that
|
||||
// fine. Left bypassed, `/api/v3/logs/livetail` reaches the real network and
|
||||
// the live tail story is a spinner over ERR_CONNECTION_REFUSED. Restore the
|
||||
// bypass and re-check `Pages/Logs/Live Tail` if msw regenerates this file.
|
||||
//
|
||||
// if (accept.includes('text/event-stream')) {
|
||||
// return
|
||||
// }
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = Math.random().toString(16).slice(2)
|
||||
|
||||
event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn(
|
||||
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const clonedResponse = response.clone()
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body:
|
||||
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
const clonedRequest = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||
|
||||
// Remove MSW-specific request headers so the bypassed requests
|
||||
// comply with the server's CORS preflight check.
|
||||
// Operate with the headers as an object because request "Headers"
|
||||
// are immutable.
|
||||
delete headers['x-msw-bypass']
|
||||
|
||||
return fetch(clonedRequest, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.text(),
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
})
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.data
|
||||
const networkError = new Error(message)
|
||||
networkError.name = name
|
||||
|
||||
// Rejecting a "respondWith" promise emulates a network error.
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(timeMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, timeMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
await sleep(response.delay)
|
||||
return new Response(response.body, response)
|
||||
}
|
||||
@@ -88,6 +88,17 @@ pnpm test
|
||||
pnpm tsgo --noEmit
|
||||
```
|
||||
|
||||
## Storybook
|
||||
|
||||
```bash
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
Opens [http://localhost:6006](http://localhost:6006). Pages run against msw
|
||||
mocks with no backend; query-param navigation works inside a story, leaving the
|
||||
page is blocked. See [`src/storybook/README.md`](src/storybook/README.md) for the
|
||||
override surface.
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
|
||||
495
frontend/docs/storybook-page-stories-tdd.md
Normal file
495
frontend/docs/storybook-page-stories-tdd.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# Storybook page stories
|
||||
|
||||
> Frontend Technical Design Document
|
||||
|
||||
---
|
||||
|
||||
## Document Info
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Author** | TODO |
|
||||
| **Reviewers** | TODO |
|
||||
| **Status** | In Review |
|
||||
| **Created** | 2026-08-24 |
|
||||
| **Last Updated** | 2026-08-24 |
|
||||
| **Effort Estimate** | TODO |
|
||||
| **Owning Team** | Frontend Platform |
|
||||
| **PR** | [SigNoz/signoz#12639](https://github.com/SigNoz/signoz/pull/12639) |
|
||||
| **Issue** | SigNoz/engineering-pod#5951 |
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Run whole SigNoz pages in Storybook with no backend: msw answers every request,
|
||||
and the state a page is in becomes a row in the controls panel. The unit is the
|
||||
page inside its real shell, not a component in isolation, so the artifact is a
|
||||
picture of what a user sees. Phase 2 points Chromatic at that picture set and
|
||||
makes visual diffs a blocking, human-accepted check.
|
||||
|
||||
The mocks that feed those pages are written and maintained by AI through the
|
||||
`signoz-page-story` skill. Nobody reviews them line by line, so the design is
|
||||
built around that: mocks are cheap to regenerate, typed against the app's own
|
||||
modules, and marked as generated in the diff.
|
||||
|
||||
---
|
||||
|
||||
## 2. Context & Problem Statement
|
||||
|
||||
### Current state
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Stories in `frontend/` before this work | 0 |
|
||||
| Routes in `src/AppRoutes/routes.ts` | 71 |
|
||||
| Page directories under `src/pages/` | 60 |
|
||||
| Jest test files | 786 |
|
||||
| E2E feature suites | 10 |
|
||||
|
||||
Every layer of the current test stack looks at something other than the page.
|
||||
Jest mounts fragments with mocked providers. E2E drives real flows but needs a
|
||||
running backend and reads assertions, not pixels. Neither answers "what does
|
||||
this page look like when the workspace is empty, the user is a viewer, and the
|
||||
license expired".
|
||||
|
||||
### Problems
|
||||
|
||||
- **Hidden states are unreachable.** Empty workspace, denied permission, expired
|
||||
license, mid-load, failed request. Seeing any of them today means finding an
|
||||
environment in that state or editing code.
|
||||
- **Visual review is manual.** A change to a shared component or a design token
|
||||
is checked by whoever remembers which pages use it. A regression two pages
|
||||
away from the diff ships.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
- Render any page, in any state, with no backend and no environment.
|
||||
- Make every state a page can be in reachable from the controls panel.
|
||||
- Catch visual regressions before merge (Phase 2, Chromatic).
|
||||
- Keep mock authorship an AI task, at a cost low enough to keep adding pages.
|
||||
- A story mounts the provider tree the app mounts, not a second approximation of
|
||||
it.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- **Component stories.** A page story with controls covers what a component
|
||||
story would, in context. Component-level stories are not the pattern here.
|
||||
- **Behaviour testing.** A wrongly migrated component, a broken handler, a bad
|
||||
request payload: that is E2E's job. A story renders, it does not assert.
|
||||
- **Replacing jest.** Stories are not reused as jest tests (no portable stories,
|
||||
no `composeStories`).
|
||||
- **Interaction coverage.** No play functions for now (see 6.3).
|
||||
- **A published static build.** Possible later, not in scope.
|
||||
- **msw v2.** The jest suite is on msw v1; upgrading is its own project.
|
||||
|
||||
---
|
||||
|
||||
## 4. Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- A page renders inside the real `AppLayout` (side nav, top nav, banners).
|
||||
- Every endpoint the page calls is answered locally, and an unanswered one fails
|
||||
loudly instead of hanging.
|
||||
- Loaded, loading and failed reachable for the page's own endpoints from one
|
||||
control.
|
||||
- Access, license, banner and side nav state selectable on every story.
|
||||
- Query-param navigation (tabs, filters, pagination, time range) works inside a
|
||||
story; leaving the page is blocked and reported.
|
||||
- Both themes render fully styled.
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- **Isolation:** no state, cache or override bleeds between stories.
|
||||
- **Determinism:** the same story renders the same pixels on every run (Phase 2
|
||||
prerequisite, see 6.2).
|
||||
- **Authoring cost:** a new page story is a skill run, not a project.
|
||||
- **No production impact:** nothing in `src/storybook/` or the msw worker reaches
|
||||
the app bundle.
|
||||
|
||||
---
|
||||
|
||||
## 5. Success Metrics
|
||||
|
||||
The metric is qualitative on purpose: this exists to make hidden UI states
|
||||
visible and to make small UI changes trustworthy without a manual sweep.
|
||||
|
||||
| Metric | Baseline | Target | Measurement |
|
||||
|--------|----------|--------|-------------|
|
||||
| Pages whose states can be seen without an environment | 0 | grows every sprint, no end date | stories under `src/pages/**` |
|
||||
| States reachable per page | n/a | every branch in the page's inventory | skill's discovery inventory vs the panel |
|
||||
| Small UI change reviewed without a manual sweep | 0% | the common case | Chromatic diff accepted in the PR |
|
||||
| Regressions caught before merge | 0 | > 0 and rising | Chromatic build history |
|
||||
|
||||
A feature change still needs a recording of the real UI. Stories cover tiny
|
||||
changes and regressions, not "does the feature work".
|
||||
|
||||
---
|
||||
|
||||
## 6. Proposed Design
|
||||
|
||||
### 6.1 Architecture overview
|
||||
|
||||
Three layers: the app's provider tree extracted so a runner can mount it, a
|
||||
story runtime that turns controls into a mocked world, and per-page mock modules
|
||||
that live with the page.
|
||||
|
||||
Only the third layer is authored per page, and it is three files next to the
|
||||
page:
|
||||
|
||||
```
|
||||
src/pages/<Page>/
|
||||
├── <Page>.stories.tsx # human-owned: title, route, named variants
|
||||
├── <Page>.stories.mocks.tsx # AI-owned: controls, handlers, config
|
||||
└── __story_mockdata__/<page>.ts # AI-owned: payload builders
|
||||
```
|
||||
|
||||
The story file is the whole human surface. Everything else the page needs
|
||||
(providers, shell, access, msw, containment) arrives from the global decorator:
|
||||
|
||||
```tsx
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME }),
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
/** Every widget carrying data. */
|
||||
export const Default: StoryObj<HomeArgs> = {};
|
||||
|
||||
/** Fresh workspace: nothing ingested, so the welcome checklist takes over. */
|
||||
export const NoIngestion: StoryObj<HomeArgs> = {
|
||||
args: { logsIngestion: false, tracesIngestion: false, dashboards: 0 },
|
||||
};
|
||||
```
|
||||
|
||||
A named story exists when the state is worth linking to. Everything else is a
|
||||
control someone turns.
|
||||
|
||||
`src/storybook/` holds the rest: the story runtime, the control builders, the
|
||||
global mock modules, the access grant, the msw stack, the navigation
|
||||
containment and the two decorators. It is infrastructure, edited when the
|
||||
mechanism changes, not when a page is added.
|
||||
|
||||
### 6.2 Key technical decisions
|
||||
|
||||
#### The page is the unit, not the component
|
||||
|
||||
**Decision:** stories render pages. A component's states are reached by turning
|
||||
a control on the page that renders it.
|
||||
|
||||
**Rationale:** a component out of context proves nothing about the page. The
|
||||
picture that matters is the one with the shell, the real spacing and the real
|
||||
data around it. It also keeps the story count near the page count instead of
|
||||
near the component count.
|
||||
|
||||
#### Controls are knobs on responses, never props
|
||||
|
||||
**Decision:** a control resolves through `handlers`, `config` or `effect`. A
|
||||
control change re-registers the msw handlers and remounts the story with an
|
||||
empty query cache.
|
||||
|
||||
**Rationale:** a prop-driven control shows a state the page cannot actually be
|
||||
in. Driving the response means the page fetches, decides and renders the way it
|
||||
does in production, including its own loading and empty branches.
|
||||
|
||||
#### Mocks are AI-owned, and marked as such
|
||||
|
||||
**Decision:** `*.stories.mocks.tsx` and `__story_mockdata__/**` carry a
|
||||
generated banner enforced by the skill, and the root `.gitattributes` marks
|
||||
those paths `linguist-generated=true` so GitHub collapses them in the diff.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
```
|
||||
|
||||
The folder is `__story_mockdata__`, not `__mockdata__`: `src/mocks-server/__mockdata__`
|
||||
already exists and is human-owned jest fixture data, so one glob cannot mark
|
||||
both without collapsing files nobody agreed to hand over.
|
||||
|
||||
**Rationale:** the review target is the rendered page and the Chromatic diff. A
|
||||
reviewer reading 300 lines of payload builders is spending attention on the one
|
||||
part of the change that no human is expected to keep correct.
|
||||
|
||||
**Consequence:** mocks must be regenerable from the page alone. Nothing
|
||||
page-specific goes into `src/storybook/`, and payload builders take their types
|
||||
from `src/api/generated` where the endpoint has them, so a contract change is a
|
||||
compile error rather than a mock that quietly lies.
|
||||
|
||||
#### A story is one page, and stays there
|
||||
|
||||
**Decision:** `lib/history` is aliased to a proxy over a memory history. A
|
||||
navigation that keeps the pathname (query or hash) is applied; a different
|
||||
pathname, an off-site href, `window.open` or a relative `go`/`goBack` is
|
||||
swallowed and listed in `NavigationBlockedOverlay`.
|
||||
|
||||
**Rationale:** leaving the page unmounts the story, and following a real href
|
||||
navigates the iframe out of Storybook. Tabs, filters and pagination are
|
||||
query-param navigation, so they keep working, which is most of what a page story
|
||||
needs. Nothing is dropped silently.
|
||||
|
||||
**Gap:** nuqs runs on its testing adapter with its own copy of the query string.
|
||||
A page that writes params through both `useQueryState` and
|
||||
`history.push({ search })` sees the two diverge inside a story.
|
||||
|
||||
#### Determinism is a Chromatic prerequisite
|
||||
|
||||
**Decision:** before Chromatic is turned on, mock data uses frozen dates and
|
||||
charts render from static data with animation disabled.
|
||||
|
||||
**Rationale:** a snapshot has to be byte-identical between runs. Two live
|
||||
sources of drift today: `appShellMocks` computes trial and payment-failed dates
|
||||
from `Date.now()`, and uPlot charts animate in. Charts stay in the picture,
|
||||
they just have to be the same picture every time.
|
||||
|
||||
#### Module aliases are typed against the real module
|
||||
|
||||
**Decision:** three modules are replaced for every story, each annotated
|
||||
`typeof import('<real module>')`.
|
||||
|
||||
| Module | Replacement | Why |
|
||||
|--------|-------------|-----|
|
||||
| `lib/history` | `navigation/history.alias.ts` | containment, above |
|
||||
| `api/common/logEvent` | `mocks/logEvent.mock.ts` | analytics never leave the iframe |
|
||||
| `constants/env` | `mocks/env.mock.ts` | pins the API origin the handlers answer on |
|
||||
|
||||
**Rationale:** the annotation makes drift a compile error instead of a story that
|
||||
fails at render in whichever component imports it. Same mechanism as jest's
|
||||
`moduleNameMapper`, and each alias notes where the same import lands under jest.
|
||||
|
||||
### 6.3 Edge cases & failure modes
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|----------|-----------|
|
||||
| Endpoint nobody mocked | catch-all logs `[storybook] no msw handler`, answers 501 |
|
||||
| Page redirects on mount | navigation overlay on load, usually a wrong `route` or a guard denying |
|
||||
| Shell disappears in `loading` | a shell endpoint went through `response.json`; it needs a plain resolver |
|
||||
| Leftover AuthZ dev override | cleared on every render unless Access is `dev-tools` |
|
||||
| A story sets a role the grant denies | impossible: `role` is derived from the grant |
|
||||
| Project-level control added | the tab needs a reload; Vite hot-updates `preview.tsx` without re-preparing open stories |
|
||||
| AuthZ dev overrides in a static build | inert, they need `IS_DEV`. Dev server only |
|
||||
| State only reachable by interaction | not covered. See below |
|
||||
|
||||
**Play functions.** `storybook/test` is available and the module mocks are `fn()`
|
||||
spies, so a `play` could open a dropdown, click a tab or assert on `logEvent`,
|
||||
and Chromatic snapshots after `play` completes. That is the mechanism for states
|
||||
no control can reach. Not used for now: controls only, deliberately, to keep the
|
||||
authoring cost of a page at one skill run.
|
||||
|
||||
---
|
||||
|
||||
## 7. Alternatives considered
|
||||
|
||||
| Option | Pros | Cons | Why not chosen |
|
||||
|--------|------|------|----------------|
|
||||
| Component stories | small, cheap, conventional | proves nothing about the page; story count tracks component count; still needs mocked providers | a page story with controls covers the same states in context |
|
||||
| Presentational `PageView` + args | no msw, no providers, no query cache; a state is one `args` object | requires splitting every page into a container and a view first; the page's own fetching, loading and empty branches are never exercised; each state is a named story, so the count grows fast | the split is a refactor of the 60 directories in `src/pages/` before the first story, and it moves the fetching the page actually does out of the picture |
|
||||
| Jest snapshot tests | already in the repo | serialised DOM is not a picture; a token or CSS change is invisible | not a visual test |
|
||||
|
||||
### Prior art
|
||||
|
||||
Page-level stories are an established pattern, and the teams doing it split into
|
||||
two camps over where the data comes from. The list below is what could be
|
||||
verified in public repos and docs.
|
||||
|
||||
**The real connected page against msw**, which is what this proposal does:
|
||||
|
||||
| Who | What they do | Where |
|
||||
|-----|--------------|-------|
|
||||
| PostHog | the closest match to this proposal, and the largest. 104 `.stories.tsx` files import `scenes/App`: the story mounts the whole app and pushes a route (`router.actions.push(urls.logs())`), so it gets the real shell, not a page in isolation. `mswDecorator` maps each endpoint to a mock at the meta level, `layout: 'fullscreen'`, `mockDate` freezes the clock, and a seeded LCG supplies the random data. Feature flags are a story parameter. Fixtures are sized past the page's cap on purpose, so pagination and the truncation banner render | [`LogsScene.stories.tsx`](https://github.com/PostHog/posthog/blob/master/products/logs/frontend/LogsScene.stories.tsx) |
|
||||
| Teleport (Gravitational) | the real page inside `TeleportProviderBasic`, with `beforeEach({ msw })` per story: `successGetUsers`, `handleGetUsers(() => delay('infinite'))` for loading, `errorGetUsers` for failed. The handlers come from `teleport/test/helpers/`, shared with the test suite. The preview starts one msw worker in a loader, clears the query cache per story, sets `retry: false`, and toggles the auth provider from an arg. 160 story files under `web/packages/teleport/src/` | [`Users.story.tsx`](https://github.com/gravitational/teleport/blob/master/web/packages/teleport/src/Users/Users.story.tsx), [`preview.tsx`](https://github.com/gravitational/teleport/blob/master/web/.storybook/preview.tsx) |
|
||||
| Metabase | msw globally through `msw-storybook-addon`, plus explicit determinism work: `METABASE_REMOVE_DELAYS`, every font forced to load before render, and the lazy echarts chunk pulled into the bundle so a snapshot cannot catch a Suspense skeleton. Visual regression through Loki | [`preview.tsx`](https://github.com/metabase/metabase/blob/master/.storybook/preview.tsx) |
|
||||
| Prefect | one msw worker in the preview, seeded from `@tests/utils/handlers`, the same handler set the tests use | [`preview.ts`](https://github.com/PrefectHQ/prefect/blob/main/ui-v2/.storybook/preview.ts) |
|
||||
| Mealdrop (Yann Braga, Storybook maintainer) | the reference project for this pattern: the real routed page, a `withDeeplink` decorator, msw handlers, `Loading` via infinite delay, `NotFound` and `Error` via 404 and 500, and `play` functions driving the interactions | [`RestaurantDetailPage.stories.tsx`](https://github.com/yannbf/mealdrop/blob/main/src/pages/RestaurantDetailPage/RestaurantDetailPage.stories.tsx) |
|
||||
|
||||
**The presentational split**, where a `PageView` takes the data as props and the
|
||||
fetching lives in a wrapper outside Storybook:
|
||||
|
||||
| Who | What they do | Where |
|
||||
|-----|--------------|-------|
|
||||
| Coder | 76 `*PageView.stories.tsx` files, part of 290 story files under `site/src/pages/`. `WorkspacesPageView` alone carries 23 named stories driven by args (`CannotCreateWorkspace`, `Loading`, `NoSearchResults`, `UnhealthyWorkspace`, `InvalidPageNumber`), with providers supplied by `withAuthProvider`, `withDashboardProvider`, `withProxyProvider()`. Chromatic gates the diffs | [`WorkspacesPageView.stories.tsx`](https://github.com/coder/coder/blob/main/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx) |
|
||||
| Netlify | stories for a whole page (Team Overview) next to stories for its cards, reusing the fixture data their integration and unit tests already maintain, with Chromatic for the visual diff | [blog post](https://www.netlify.com/blog/storybook-visual-regression-testing/) |
|
||||
| BBC, The Guardian, the Storybook maintainers | named in Storybook's own guidance as teams that keep components presentational up to the screen level and put the connected logic in one wrapper component outside Storybook | [Building pages with Storybook](https://storybook.js.org/docs/writing-stories/build-pages-with-storybook) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Trade-offs & risks
|
||||
|
||||
### Trade-offs accepted
|
||||
|
||||
- Mocks can lie. Nothing checks a payload against the real API at runtime.
|
||||
Typing builders from `src/api/generated` narrows it, it does not close it.
|
||||
- Controls only. States behind an interaction are not covered until play
|
||||
functions are adopted.
|
||||
- msw v1 pinned by the jest suite.
|
||||
- `.storybook/main.ts` carries an exclusion list of `vite.config.ts` plugins,
|
||||
maintained by hand.
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Mock drift: story green, production broken | High | Medium | builders typed from `src/api/generated`; E2E owns the real contract; drift shows up as a Chromatic diff on the next regeneration |
|
||||
| Snapshot cost grows with pages × states × 2 themes | High | Medium | per-story `chromatic.disableSnapshot` to trim; snapshot the states worth watching |
|
||||
| Flaky diffs from non-deterministic pixels | High | Medium | frozen dates, animations off, static chart data (Phase 2 prerequisite) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility plan
|
||||
|
||||
`@storybook/addon-a11y` is in the addon list. It runs axe on the rendered
|
||||
story, so a page gets an a11y pass in every state its controls can reach, which
|
||||
is more than the app gets today.
|
||||
|
||||
Informational, not a gate: the addon reports on existing pages that were never
|
||||
audited, so making it blocking would block on debt this work did not create.
|
||||
Chromatic's review check is the only blocking gate.
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance plan
|
||||
|
||||
- **No production impact.** `src/storybook/` and `src/harness/` are only reached
|
||||
by stories. The msw worker is served from `.storybook/public/`, kept out of
|
||||
`../public` so it cannot ship in a production build.
|
||||
- **Per-story cost.** A control change remounts the tree with a fresh query
|
||||
cache, which is the point: the page refetches. Remount is keyed on theme plus
|
||||
the resolved control values, so nothing remounts without a reason.
|
||||
- **`retry: false`** on the story query client, so a forced failure renders its
|
||||
error state immediately instead of after three retries.
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollout plan
|
||||
|
||||
### Phases
|
||||
|
||||
| Phase | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| **Phase 1: Home** | the story runtime and the `signoz-page-story` skill, proved by one page: Home | This PR |
|
||||
| **Phase 2: Chromatic** | determinism prerequisites, CI job, blocking review check, snapshot exclusions | Not started |
|
||||
| **Phase 3: Page fan-out** | more pages, gradually, one skill run each, the auth-less ones (login, signup, workspace-locked) included | Continuous |
|
||||
|
||||
No end date. Phase 3 runs for as long as there are pages worth covering, and a
|
||||
page is added when someone is working on it.
|
||||
|
||||
### Phase 2 detail
|
||||
|
||||
- **Coverage:** every story, light and dark. A story can opt out with
|
||||
`parameters.chromatic.disableSnapshot` when the cost is not worth it.
|
||||
- **Gate:** Chromatic's review check is required. It stays red until a frontend
|
||||
reviewer accepts the diffs, so an unreviewed visual change cannot merge.
|
||||
- **Prerequisites:** frozen dates in mock data (`appShellMocks` computes banner
|
||||
dates from `Date.now()` today), animations off, charts rendering the same
|
||||
picture from static data.
|
||||
- **Baseline:** main.
|
||||
|
||||
### Failure triage
|
||||
|
||||
A red story has two causes, and they are told apart before anything is edited:
|
||||
|
||||
1. **A real visual regression.** Fix the app.
|
||||
2. **The component changed and the mocks no longer match it.** Regenerate the
|
||||
mocks with the skill. This is expected, routine, and not a review event.
|
||||
|
||||
### Rollback
|
||||
|
||||
Phase 1 is additive: nothing in the app bundle depends on it, so removing it is
|
||||
deleting directories. The two app-side changes (`AppHarness` extraction, the
|
||||
`appContextMock` fixture) stand on their own and stay. Phase 2 rolls back by
|
||||
making the Chromatic check non-required.
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing strategy
|
||||
|
||||
Stories are not tests, and there are no assertions in them. What guards the
|
||||
stories themselves:
|
||||
|
||||
### Per-story gates (the skill's `verify.md`)
|
||||
|
||||
- Console silent: no `[storybook] no msw handler`, no 501, no msw unhandled
|
||||
request, no React warning.
|
||||
- No navigation overlay on mount.
|
||||
- Every control flipped once, with its effect seen on screen. Args are settable
|
||||
from the iframe URL, so the sweep runs headless.
|
||||
- Both themes render styled (`<body data-theme>` present).
|
||||
- `<body data-signoz-story-role>` and `data-signoz-context-role` agree, which is
|
||||
the tell that the page reads the `AppContext` the story filled.
|
||||
- `pnpm tsgo --noEmit`, `pnpm exec oxlint`, `pnpm exec oxfmt --check` clean.
|
||||
|
||||
### The layers around it
|
||||
|
||||
| Layer | What it owns |
|
||||
|-------|--------------|
|
||||
| Jest (786 files) | component and hook behaviour |
|
||||
| E2E (10 suites) | flows against a real backend, real contracts, migration correctness |
|
||||
| Page stories | what the page looks like, in every state |
|
||||
| Chromatic (Phase 2) | that it still looks like that |
|
||||
|
||||
A component migrated wrongly is E2E's to catch, not Chromatic's. A story that
|
||||
renders a broken component still renders, and the diff only shows what moved.
|
||||
|
||||
---
|
||||
|
||||
## 13. Observability
|
||||
|
||||
N/A. Nothing here runs in production. `api/common/logEvent` is replaced by a
|
||||
spy, and `preview-head.html` stubs `signozBootData` with posthog, sentry,
|
||||
appcues and pylon all off, so no third-party script loads inside the iframe.
|
||||
|
||||
Phase 2 adds Chromatic build history, which is where the regression record
|
||||
lives.
|
||||
|
||||
---
|
||||
|
||||
## 14. Design system check
|
||||
|
||||
- [x] Reuses existing components/patterns, no new UI pattern introduced
|
||||
|
||||
Two UI surfaces are added, both Storybook-only: `NavigationBlockedOverlay`
|
||||
(lists what the page tried to navigate to) and the story controls panel, which
|
||||
is Storybook's own. `applyThemeBodyClass` puts `<body>` in the state the app
|
||||
gets from `index.html` plus `AppLayout`, including `data-theme="default"`,
|
||||
without which every `@signozhq/design-tokens` semantic token resolves to nothing
|
||||
and the page renders unstyled.
|
||||
|
||||
---
|
||||
|
||||
## 15. Timeline & milestones
|
||||
|
||||
| Milestone | Status |
|
||||
|-----------|--------|
|
||||
| Phase 1: Home | This PR |
|
||||
| Phase 2: Chromatic + determinism | Not started, TODO |
|
||||
| Phase 3: page fan-out | Continuous, no end date |
|
||||
|
||||
Effort estimate: TODO.
|
||||
|
||||
---
|
||||
|
||||
## 16. What would let a regression through
|
||||
|
||||
Ranked by likelihood, since a visual test that passes wrongly is worse than one
|
||||
that fails.
|
||||
|
||||
| # | Hazard | Mitigation |
|
||||
|---|--------|------------|
|
||||
| 1 | **Mock payload no longer matches the API.** The page renders the mock's shape happily while production gets something else. | builders typed from `src/api/generated`; E2E owns the real contract |
|
||||
| 2 | **A state exists that no control reaches.** It is never snapshotted, so it never regresses visibly. | the skill's inventory is the checklist: a branch in it and not in the panel is a bug in the story |
|
||||
| 3 | **Non-deterministic pixels.** A diff every day teaches the team to click accept. | frozen dates, animations off, static chart data |
|
||||
| 4 | **A story stops covering the page** because a widget silently stopped rendering (permission, flag, preference). | control sweep: a control with no visible effect is a finding, not a shrug |
|
||||
| 5 | **Endpoint answered by a stale layer.** A page handler that does not match the call falls through to a shared handler, which answers something plausible. | endless spinner with Data on `loaded` is the symptom |
|
||||
| 6 | **nuqs and history diverge** on a page that writes params through both. | known gap, documented; a page on one mechanism is unaffected |
|
||||
| 7 | **Snapshot exclusions accumulate** until the gate covers little. | exclusions are a reviewed edit in the story file |
|
||||
@@ -7,6 +7,8 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
|
||||
"dev": "vite",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build -o storybook-static",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -158,6 +160,10 @@
|
||||
"@commitlint/config-conventional": "20.4.4",
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
@@ -203,6 +209,7 @@
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.35.0",
|
||||
"storybook": "10.5.9",
|
||||
"stylelint": "17.7.0",
|
||||
"svgo": "4.0.2",
|
||||
"ts-jest": "29.4.9",
|
||||
|
||||
1097
frontend/pnpm-lock.yaml
generated
1097
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
15
frontend/src/components/NotFound/NotFound.stories.mocks.tsx
generated
Normal file
15
frontend/src/components/NotFound/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' }),
|
||||
});
|
||||
44
frontend/src/components/NotFound/NotFound.stories.tsx
Normal file
44
frontend/src/components/NotFound/NotFound.stories.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import NotFound from './index';
|
||||
import { notFoundMocks } from './NotFound.stories.mocks';
|
||||
|
||||
type NotFoundArgs = PageStoryArgs<typeof notFoundMocks>;
|
||||
|
||||
/**
|
||||
* The catch-all route mounts it with no props, and its `defaultProps` is what
|
||||
* keeps the component itself from typing as one that takes the story's args.
|
||||
*/
|
||||
function CatchAllPage(): JSX.Element {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
const pageStory = storyMocks(notFoundMocks);
|
||||
|
||||
/**
|
||||
* The shell around a pathname no route matched: the side nav stays, the content
|
||||
* area carries the 404.
|
||||
*
|
||||
* Route: any unmatched path.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/System/Not Found',
|
||||
component: CatchAllPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<NotFoundArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<NotFoundArgs>;
|
||||
|
||||
/**
|
||||
* What the app shows for a pathname no route matched, inside the shell: the
|
||||
* side nav is still there, and the way back is the home button.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
15
frontend/src/harness/AntdThemeBridge.tsx
Normal file
15
frontend/src/harness/AntdThemeBridge.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { useThemeConfig } from 'hooks/useDarkMode';
|
||||
|
||||
/**
|
||||
* `useThemeConfig` has to run under `ThemeProvider`, so the antd
|
||||
* `ConfigProvider` lives in its own component the way `AppRoutes` does it.
|
||||
*/
|
||||
function AntdThemeBridge({ children }: { children: ReactNode }): JSX.Element {
|
||||
const themeConfig = useThemeConfig();
|
||||
|
||||
return <ConfigProvider theme={themeConfig}>{children}</ConfigProvider>;
|
||||
}
|
||||
|
||||
export default AntdThemeBridge;
|
||||
125
frontend/src/harness/AppHarness.tsx
Normal file
125
frontend/src/harness/AppHarness.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Store } from 'redux';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import { NotificationProvider } from 'hooks/useNotifications';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import {
|
||||
QueryBuilderContext,
|
||||
QueryBuilderProvider,
|
||||
} from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import AntdThemeBridge from './AntdThemeBridge';
|
||||
|
||||
/**
|
||||
* A layer the runner supplies. A render function rather than a component, so an
|
||||
* inline one does not change identity between renders and remount the tree.
|
||||
*/
|
||||
export type HarnessWrapper = (children: ReactNode) => ReactNode;
|
||||
|
||||
export interface AppHarnessProps {
|
||||
children: ReactNode;
|
||||
/** Stands in for `AppProvider`, whose fetches no harness can make. */
|
||||
appContext: IAppContext;
|
||||
store: Store;
|
||||
queryClient: QueryClient;
|
||||
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
|
||||
queryBuilder?: Partial<QueryBuilderContextType>;
|
||||
/**
|
||||
* The router the runner drives: jest a `MemoryRouter`, Storybook a `Router` on
|
||||
* the contained history. Everything above it in the tree is router-free, so
|
||||
* the choice stays here.
|
||||
*/
|
||||
router: HarnessWrapper;
|
||||
/** The nuqs adapter: the react one under jest, the testing one in Storybook. */
|
||||
searchParams: HarnessWrapper;
|
||||
/** Rendered beside the subject: Storybook's palette, overlay and probe. */
|
||||
overlays?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's provider tree, as `src/index.tsx` and `src/AppRoutes/index.tsx`
|
||||
* mount it, minus Sentry, posthog and `AppProvider`, with the pieces a test
|
||||
* runner has to choose left as props. Storybook's `StorybookProviders` mounts
|
||||
* it, so a story gets the tree production gets. jest's `test-utils` keeps its
|
||||
* own smaller tree: ~20 files in the suite mock provider modules down to a
|
||||
* single export, so the providers those modules also carry would come back
|
||||
* `undefined`.
|
||||
*/
|
||||
function AppHarness({
|
||||
children,
|
||||
appContext,
|
||||
store,
|
||||
queryClient,
|
||||
queryBuilder,
|
||||
router,
|
||||
searchParams,
|
||||
overlays,
|
||||
}: AppHarnessProps): JSX.Element {
|
||||
const subject = queryBuilder ? (
|
||||
<QueryBuilderContext.Provider value={queryBuilder as QueryBuilderContextType}>
|
||||
{children}
|
||||
</QueryBuilderContext.Provider>
|
||||
) : (
|
||||
<QueryBuilderProvider>{children}</QueryBuilderProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<HelmetProvider>
|
||||
{searchParams(
|
||||
<ThemeProvider>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
<AppContext.Provider value={appContext}>
|
||||
<AntdThemeBridge>
|
||||
{router(
|
||||
<TooltipProvider>
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
<ResourceProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<PreferenceContextProvider>
|
||||
{subject}
|
||||
{overlays}
|
||||
</PreferenceContextProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
</ResourceProvider>
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>
|
||||
</TooltipProvider>,
|
||||
)}
|
||||
</AntdThemeBridge>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</ThemeProvider>,
|
||||
)}
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
AppHarness.defaultProps = {
|
||||
queryBuilder: undefined,
|
||||
overlays: undefined,
|
||||
};
|
||||
|
||||
export default AppHarness;
|
||||
@@ -135,3 +135,5 @@ export const closeAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().closeModal();
|
||||
export const toggleAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().toggleModal();
|
||||
export const clearAllAuthZDevOverrides = (): void =>
|
||||
useAuthZDevStore.getState().clearAllOverrides();
|
||||
|
||||
131
frontend/src/modules/Servicemap/ServiceMap.stories.mocks.tsx
generated
Normal file
131
frontend/src/modules/Servicemap/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 'types/reducer/trace';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
attributeKeysFor,
|
||||
attributeKeysResponse,
|
||||
attributeValuesFor,
|
||||
attributeValuesResponse,
|
||||
dependencyGraphResponse,
|
||||
MAX_DEPENDENCIES,
|
||||
RESOURCE_FILTERS,
|
||||
type ResourceFilter,
|
||||
resourceFilterQueries,
|
||||
SERVICE_HEALTH,
|
||||
type ServiceHealth,
|
||||
} from './__story_mockdata__/serviceMap';
|
||||
|
||||
const GRAPH = 'Service map · graph';
|
||||
const FILTERS = 'Service map · filters';
|
||||
|
||||
interface DependencyGraphBody {
|
||||
tags?: Tags[];
|
||||
}
|
||||
|
||||
const serviceMapRoute = (filters: readonly ResourceFilter[]): string => {
|
||||
if (filters.length === 0) {
|
||||
return ROUTES.SERVICE_MAP;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
[QueryParams.resourceAttributes]: encode(
|
||||
JSON.stringify(resourceFilterQueries(filters)),
|
||||
),
|
||||
});
|
||||
|
||||
return `${ROUTES.SERVICE_MAP}?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const serviceMapMocks = defineStoryMocks({
|
||||
controls: {
|
||||
services: countControl('Dependencies', {
|
||||
group: GRAPH,
|
||||
description:
|
||||
'Call edges the endpoint answers with. Every one of them is a link and its two nodes; 0 is the "No Service Found" card.',
|
||||
value: MAX_DEPENDENCIES,
|
||||
max: MAX_DEPENDENCIES,
|
||||
}),
|
||||
health: choiceControl<ServiceHealth>('Service health', {
|
||||
group: GRAPH,
|
||||
description:
|
||||
'Error rate on the calls into a service, which is what turns its node red.',
|
||||
options: SERVICE_HEALTH,
|
||||
value: 'degraded',
|
||||
}),
|
||||
filters: multiChoiceControl<ResourceFilter>('Applied filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Resource attributes the page opens with, as the environment selector and a chip. The graph narrows to what they match.',
|
||||
options: RESOURCE_FILTERS,
|
||||
value: [],
|
||||
}),
|
||||
environments: countControl('Environments', {
|
||||
group: FILTERS,
|
||||
description: 'Values the environment selector offers.',
|
||||
value: 3,
|
||||
max: 5,
|
||||
}),
|
||||
resourceAttributes: toggleControl('Resource attributes ingested', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Off answers both autocomplete endpoints with nothing, which is what the filter reports as no resource attributes available.',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v1/dependency_graph',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as DependencyGraphBody;
|
||||
|
||||
return dependencyGraphResponse({
|
||||
count: values.services,
|
||||
health: values.health,
|
||||
tags: body.tags,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) =>
|
||||
attributeKeysResponse(
|
||||
values.resourceAttributes
|
||||
? attributeKeysFor(req.url.searchParams.get('searchText'))
|
||||
: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
response.json((req) =>
|
||||
attributeValuesResponse(
|
||||
values.resourceAttributes
|
||||
? attributeValuesFor(
|
||||
req.url.searchParams.get('attributeKey'),
|
||||
values.environments,
|
||||
)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: serviceMapRoute(values.filters) }),
|
||||
});
|
||||
73
frontend/src/modules/Servicemap/ServiceMap.stories.tsx
Normal file
73
frontend/src/modules/Servicemap/ServiceMap.stories.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { serviceMapMocks } from './ServiceMap.stories.mocks';
|
||||
|
||||
import ServiceMapContainer from './index';
|
||||
|
||||
type ServiceMapArgs = PageStoryArgs<typeof serviceMapMocks>;
|
||||
|
||||
const pageStory = storyMocks(serviceMapMocks);
|
||||
|
||||
/**
|
||||
* Service to service calls as a force graph over `/api/v1/dependency_graph`,
|
||||
* nodes sized by request rate and coloured by error rate, each opening its own
|
||||
* side panel.
|
||||
*
|
||||
* Route: `/service-map`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Services/Service Map',
|
||||
tags: ['beta', 'play'],
|
||||
component: ServiceMapContainer,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ServiceMapArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ServiceMapArgs>;
|
||||
|
||||
/** The keys are only fetched once the select opens, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The whole topology: one node per service, sized by how many calls it takes,
|
||||
* red where those calls are failing, and a link per dependency carrying the
|
||||
* latency and error rate its tooltip reports.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* The map narrowed to one environment and one cluster: the environment selector
|
||||
* carries the first, a chip carries the second, and the graph is what is left.
|
||||
*/
|
||||
export const Filtered: Story = {
|
||||
args: { filters: ['environment', 'cluster'] },
|
||||
};
|
||||
|
||||
/** A workspace with no dependencies recorded in the selected time range. */
|
||||
export const NoServices: Story = {
|
||||
args: { services: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* The attribute filter open: of everything the endpoint returns, the map only
|
||||
* offers the three keys it can send to `/dependency_graph`.
|
||||
*/
|
||||
export const FilterAttributes: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const filter = canvas.getByTestId('resource-attributes-filter');
|
||||
|
||||
// The select opens on a press inside it: a click on the wrapper the test id
|
||||
// sits on never reaches the handler that opens the list.
|
||||
await userEvent.click(within(filter).getByRole('combobox'));
|
||||
await canvas.findByText('k8s.cluster.name', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
352
frontend/src/modules/Servicemap/__story_mockdata__/serviceMap.ts
generated
Normal file
352
frontend/src/modules/Servicemap/__story_mockdata__/serviceMap.ts
generated
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { IResourceAttribute } from 'hooks/useResourceAttribute/types';
|
||||
import { getResourceDeploymentKeys } from 'hooks/useResourceAttribute/utils';
|
||||
import type { ServicesMapItem } from 'store/actions/serviceMap';
|
||||
import type {
|
||||
TagKeysPayloadProps,
|
||||
TagValuesPayloadProps,
|
||||
} from 'types/api/metrics/getResourceAttributes';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { Tags } from 'types/reducer/trace';
|
||||
|
||||
export const SERVICE_HEALTH = ['healthy', 'degraded', 'failing'] as const;
|
||||
|
||||
export type ServiceHealth = (typeof SERVICE_HEALTH)[number];
|
||||
|
||||
interface Dependency {
|
||||
parent: string;
|
||||
child: string;
|
||||
callCount: number;
|
||||
callRate: number;
|
||||
/** Nanoseconds: the link tooltip divides by 1e6 to show milliseconds. */
|
||||
p99: number;
|
||||
environment: string;
|
||||
cluster: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One call edge per entry, parents before children, so slicing the head of the
|
||||
* list keeps the graph connected instead of leaving orphaned nodes behind.
|
||||
*/
|
||||
const DEPENDENCIES: Dependency[] = [
|
||||
{
|
||||
parent: 'gateway',
|
||||
child: 'frontend',
|
||||
callCount: 41200,
|
||||
callRate: 68.4,
|
||||
p99: 184_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'auth',
|
||||
callCount: 12800,
|
||||
callRate: 21.3,
|
||||
p99: 46_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'catalogue',
|
||||
callCount: 18600,
|
||||
callRate: 31,
|
||||
p99: 92_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'cart',
|
||||
callCount: 9400,
|
||||
callRate: 15.6,
|
||||
p99: 58_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'cart',
|
||||
child: 'redis',
|
||||
callCount: 7300,
|
||||
callRate: 12.1,
|
||||
p99: 4_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'catalogue',
|
||||
child: 'mysql',
|
||||
callCount: 15200,
|
||||
callRate: 25.3,
|
||||
p99: 31_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'auth',
|
||||
child: 'mysql',
|
||||
callCount: 8100,
|
||||
callRate: 13.5,
|
||||
p99: 27_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'checkout',
|
||||
callCount: 6200,
|
||||
callRate: 10.3,
|
||||
p99: 210_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'checkout',
|
||||
child: 'payments',
|
||||
callCount: 5900,
|
||||
callRate: 9.8,
|
||||
p99: 340_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'checkout',
|
||||
child: 'shipping',
|
||||
callCount: 5400,
|
||||
callRate: 9,
|
||||
p99: 120_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'payments',
|
||||
child: 'stripe-proxy',
|
||||
callCount: 5100,
|
||||
callRate: 8.5,
|
||||
p99: 290_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'shipping',
|
||||
child: 'geo-service',
|
||||
callCount: 4700,
|
||||
callRate: 7.8,
|
||||
p99: 76_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-eu-west',
|
||||
},
|
||||
{
|
||||
parent: 'geo-service',
|
||||
child: 'redis',
|
||||
callCount: 4300,
|
||||
callRate: 7.1,
|
||||
p99: 3_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-eu-west',
|
||||
},
|
||||
{
|
||||
parent: 'catalogue',
|
||||
child: 'recommendations',
|
||||
callCount: 3800,
|
||||
callRate: 6.3,
|
||||
p99: 150_000_000,
|
||||
environment: 'staging',
|
||||
cluster: 'staging-eu',
|
||||
},
|
||||
{
|
||||
parent: 'recommendations',
|
||||
child: 'ml-inference',
|
||||
callCount: 3500,
|
||||
callRate: 5.8,
|
||||
p99: 480_000_000,
|
||||
environment: 'staging',
|
||||
cluster: 'staging-eu',
|
||||
},
|
||||
{
|
||||
parent: 'notifications',
|
||||
child: 'email-relay',
|
||||
callCount: 900,
|
||||
callRate: 1.5,
|
||||
p99: 65_000_000,
|
||||
environment: 'staging',
|
||||
cluster: 'staging-eu',
|
||||
},
|
||||
];
|
||||
|
||||
export const MAX_DEPENDENCIES = DEPENDENCIES.length;
|
||||
|
||||
const DEGRADED_SERVICES = ['payments', 'redis'];
|
||||
|
||||
const ERROR_RATES = [1.2, 3.4, 0.8, 6.1, 2.5];
|
||||
|
||||
const errorRateFor = (
|
||||
child: string,
|
||||
health: ServiceHealth,
|
||||
index: number,
|
||||
): number => {
|
||||
if (health === 'healthy') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (health === 'degraded' && !DEGRADED_SERVICES.includes(child)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ERROR_RATES[index % ERROR_RATES.length];
|
||||
};
|
||||
|
||||
const ATTRIBUTE_BY_TAG_KEY: Record<string, 'environment' | 'cluster'> = {
|
||||
'deployment.environment': 'environment',
|
||||
'k8s.cluster.name': 'cluster',
|
||||
};
|
||||
|
||||
/**
|
||||
* The page sends its resource-attribute chips as trace tags, so the response has
|
||||
* to narrow with them: a filter that changed nothing would look broken.
|
||||
*/
|
||||
const matchesTag = (dependency: Dependency, tag: Tags): boolean => {
|
||||
const attribute = ATTRIBUTE_BY_TAG_KEY[tag.Key];
|
||||
|
||||
if (!attribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const matched = tag.StringValues.includes(dependency[attribute]);
|
||||
|
||||
return tag.Operator === 'NotIn' ? !matched : matched;
|
||||
};
|
||||
|
||||
interface DependencyGraphOptions {
|
||||
count: number;
|
||||
health: ServiceHealth;
|
||||
tags?: Tags[];
|
||||
}
|
||||
|
||||
export const dependencyGraphResponse = ({
|
||||
count,
|
||||
health,
|
||||
tags = [],
|
||||
}: DependencyGraphOptions): ServicesMapItem[] =>
|
||||
DEPENDENCIES.slice(0, count)
|
||||
.filter((dependency) => tags.every((tag) => matchesTag(dependency, tag)))
|
||||
.map(({ parent, child, callCount, callRate, p99 }, index) => ({
|
||||
parent,
|
||||
child,
|
||||
callCount,
|
||||
callRate,
|
||||
p99,
|
||||
errorRate: errorRateFor(child, health, index),
|
||||
}));
|
||||
|
||||
const ENVIRONMENT_KEY = 'resource_deployment_environment';
|
||||
const CLUSTER_KEY = 'resource_k8s_cluster_name';
|
||||
const NAMESPACE_KEY = 'resource_k8s_cluster_namespace';
|
||||
|
||||
/**
|
||||
* `service.name` and `host.name` are not in the service-map whitelist, so they
|
||||
* are here to be dropped: the page filters the keys it offers down to the three
|
||||
* it can send to `/dependency_graph`.
|
||||
*/
|
||||
const ATTRIBUTE_KEYS = [
|
||||
ENVIRONMENT_KEY,
|
||||
CLUSTER_KEY,
|
||||
NAMESPACE_KEY,
|
||||
'resource_service_name',
|
||||
'resource_host_name',
|
||||
];
|
||||
|
||||
const ENVIRONMENTS = [
|
||||
'production',
|
||||
'staging',
|
||||
'development',
|
||||
'canary',
|
||||
'load-test',
|
||||
];
|
||||
|
||||
const CLUSTERS = ['prod-us-east', 'prod-eu-west', 'staging-eu'];
|
||||
|
||||
const NAMESPACES = ['default', 'checkout', 'ingest'];
|
||||
|
||||
/**
|
||||
* The environment selector asks the same endpoint as the attribute filter, and
|
||||
* the deployment key it matches on is the only thing telling the two apart.
|
||||
*/
|
||||
export const attributeKeysFor = (searchText: string | null): string[] =>
|
||||
searchText === getResourceDeploymentKeys()
|
||||
? [getResourceDeploymentKeys()]
|
||||
: ATTRIBUTE_KEYS;
|
||||
|
||||
export const attributeValuesFor = (
|
||||
attributeKey: string | null,
|
||||
environments: number,
|
||||
): string[] => {
|
||||
if (
|
||||
attributeKey === getResourceDeploymentKeys() ||
|
||||
attributeKey === ENVIRONMENT_KEY
|
||||
) {
|
||||
return ENVIRONMENTS.slice(0, environments);
|
||||
}
|
||||
|
||||
if (attributeKey === CLUSTER_KEY) {
|
||||
return CLUSTERS;
|
||||
}
|
||||
|
||||
return attributeKey === NAMESPACE_KEY ? NAMESPACES : [];
|
||||
};
|
||||
|
||||
export const attributeKeysResponse = (
|
||||
keys: readonly string[],
|
||||
): TagKeysPayloadProps & { status: string } => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: keys.map((key) => ({
|
||||
key,
|
||||
type: 'resource',
|
||||
dataType: DataTypes.String,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
export const attributeValuesResponse = (
|
||||
values: readonly string[],
|
||||
): TagValuesPayloadProps & { status: string } => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
boolAttributeValues: null,
|
||||
numberAttributeValues: null,
|
||||
stringAttributeValues: [...values],
|
||||
},
|
||||
});
|
||||
|
||||
export const RESOURCE_FILTERS = ['environment', 'cluster'] as const;
|
||||
|
||||
export type ResourceFilter = (typeof RESOURCE_FILTERS)[number];
|
||||
|
||||
/**
|
||||
* The environment query has to carry the deployment key the app derives, since
|
||||
* that is what routes it into the environment selector instead of a chip.
|
||||
*/
|
||||
const FILTER_QUERIES: Record<ResourceFilter, IResourceAttribute> = {
|
||||
environment: {
|
||||
id: 'storybook-environment',
|
||||
tagKey: getResourceDeploymentKeys(),
|
||||
operator: 'IN',
|
||||
tagValue: ['production'],
|
||||
},
|
||||
cluster: {
|
||||
id: 'storybook-cluster',
|
||||
tagKey: CLUSTER_KEY,
|
||||
operator: 'IN',
|
||||
tagValue: ['prod-us-east'],
|
||||
},
|
||||
};
|
||||
|
||||
export const resourceFilterQueries = (
|
||||
filters: readonly ResourceFilter[],
|
||||
): IResourceAttribute[] => filters.map((filter) => FILTER_QUERIES[filter]);
|
||||
47
frontend/src/modules/Usage/Usage.stories.mocks.tsx
generated
Normal file
47
frontend/src/modules/Usage/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 }),
|
||||
});
|
||||
38
frontend/src/modules/Usage/Usage.stories.tsx
Normal file
38
frontend/src/modules/Usage/Usage.stories.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import UsageExplorerContainer from './index';
|
||||
import { usageMocks } from './Usage.stories.mocks';
|
||||
|
||||
type UsageArgs = PageStoryArgs<typeof usageMocks>;
|
||||
|
||||
const pageStory = storyMocks(usageMocks);
|
||||
|
||||
/**
|
||||
* Spans ingested per service over a period, the usage view that predates Cost
|
||||
* Meter.
|
||||
*
|
||||
* Route: `/usage-explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Metering/Usage Explorer',
|
||||
component: UsageExplorerContainer,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<UsageArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<UsageArgs>;
|
||||
|
||||
/** Spans ingested over the window, and the total they add up to. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace that has not sent anything yet. */
|
||||
export const NoSpans: Story = {
|
||||
args: { spansPerBucket: 0 },
|
||||
};
|
||||
54
frontend/src/modules/Usage/__story_mockdata__/usage.ts
generated
Normal file
54
frontend/src/modules/Usage/__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)),
|
||||
}));
|
||||
};
|
||||
240
frontend/src/pages/AIAssistantPage/AIAssistantPage.stories.mocks.tsx
generated
Normal file
240
frontend/src/pages/AIAssistantPage/AIAssistantPage.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { globalConfigResponse } from '@/storybook/msw/__story_mockdata__/appShell';
|
||||
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
AGENT_STATES,
|
||||
type AgentState,
|
||||
answeredBlocks,
|
||||
chipsResponse,
|
||||
EXECUTION_ID,
|
||||
executionEvents,
|
||||
newConversation,
|
||||
NEW_THREAD_ID,
|
||||
openConversation,
|
||||
THREAD_ID,
|
||||
streamingState,
|
||||
THREAD_PARTS,
|
||||
type ThreadPart,
|
||||
threadDetailResponse,
|
||||
threadListResponse,
|
||||
} from './__story_mockdata__/aiAssistant';
|
||||
|
||||
const THREAD = 'AI assistant · thread';
|
||||
const AGENT = 'AI assistant · agent';
|
||||
const CONVERSATIONS = 'AI assistant · conversations';
|
||||
|
||||
/**
|
||||
* The assistant talks to its own backend, whose host comes from the global
|
||||
* config rather than being the SigNoz API. Pointing it at the same origin is
|
||||
* what puts its calls in front of the story's handlers.
|
||||
*/
|
||||
const AI_BACKEND_URL = 'http://localhost';
|
||||
|
||||
/** The analysis thread, without the turns that carry an interactive block. */
|
||||
const ANALYSIS: ThreadPart[] = [
|
||||
'prose',
|
||||
'table',
|
||||
'code',
|
||||
'activity',
|
||||
'actions',
|
||||
'voted',
|
||||
];
|
||||
|
||||
/** Suggestions the `@` picker offers under Dashboards. */
|
||||
const CONTEXT_DASHBOARDS = [
|
||||
'Checkout overview',
|
||||
'Payments upstream',
|
||||
'Ingestion health',
|
||||
];
|
||||
|
||||
/**
|
||||
* `useIsAIAssistantEnabled` pushes the assistant's host into the axios instance
|
||||
* during render, and pushes `null` for as long as the global config query is in
|
||||
* flight. Every call that leaves in that window goes out against an empty base
|
||||
* and lands on the page's own origin, so each endpoint answers on both paths
|
||||
* rather than the story showing the 404 the app puts there. Reported as an app
|
||||
* bug.
|
||||
*/
|
||||
const onBothBases = (
|
||||
method: 'get' | 'post' | 'patch',
|
||||
path: string,
|
||||
resolver: MockResolver,
|
||||
): RequestHandler[] => [
|
||||
rest[method](`${AI_BACKEND_URL}${AI_API_PATH}${path}`, resolver),
|
||||
rest[method](path, resolver),
|
||||
];
|
||||
|
||||
const ok: MockResolver = (_req, res, ctx) => res(ctx.status(200), ctx.json({}));
|
||||
|
||||
const startedExecution: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ executionId: EXECUTION_ID }));
|
||||
|
||||
export const aiAssistantMocks = defineStoryMocks({
|
||||
controls: {
|
||||
conversation: toggleControl('Open conversation', {
|
||||
group: THREAD,
|
||||
description:
|
||||
'Off is the empty thread a first visit lands on, with the suggested prompts instead of an exchange.',
|
||||
value: true,
|
||||
}),
|
||||
contents: multiChoiceControl<ThreadPart>('Thread contents', {
|
||||
group: THREAD,
|
||||
description:
|
||||
'What the open thread holds. Each interactive block arrives as its own turn, and `voted` is a rating already on the last answer.',
|
||||
options: THREAD_PARTS,
|
||||
value: ANALYSIS,
|
||||
}),
|
||||
answered: toggleControl('Interactive blocks answered', {
|
||||
group: THREAD,
|
||||
description:
|
||||
'The question, confirm and action blocks after the user has picked. The choice lives in the store, keyed by message, so it survives a remount.',
|
||||
value: false,
|
||||
}),
|
||||
agent: choiceControl<AgentState>('Agent', {
|
||||
group: AGENT,
|
||||
description:
|
||||
'What the agent is doing when the thread opens. Both waiting states block the composer until the user answers.',
|
||||
options: AGENT_STATES,
|
||||
value: 'idle',
|
||||
}),
|
||||
history: countControl('Past conversations', {
|
||||
group: CONVERSATIONS,
|
||||
description:
|
||||
'Threads the sidebar lists, the first being the open one, so an open conversation holds the count at one. Their ages spread across every date group.',
|
||||
value: 6,
|
||||
max: 12,
|
||||
}),
|
||||
archived: countControl('Archived conversations', {
|
||||
group: CONVERSATIONS,
|
||||
description: 'Threads under the archived group at the foot of the sidebar.',
|
||||
value: 2,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get('http://localhost/api/v1/global/config', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
...globalConfigResponse,
|
||||
data: {
|
||||
...globalConfigResponse.data,
|
||||
ai_assistant_url: AI_BACKEND_URL,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
...onBothBases(
|
||||
'get',
|
||||
'/threads',
|
||||
response.json((req) =>
|
||||
req.url.searchParams.get('archived') === 'true'
|
||||
? threadListResponse(values.archived, true)
|
||||
: // A thread the sidebar does not list is one the server does not
|
||||
// know, and the store drops those, so an open conversation is
|
||||
// always the first row.
|
||||
threadListResponse(
|
||||
values.conversation ? Math.max(1, values.history) : values.history,
|
||||
false,
|
||||
),
|
||||
),
|
||||
),
|
||||
...onBothBases(
|
||||
'get',
|
||||
'/threads/:threadId',
|
||||
response.json(() => threadDetailResponse(values.contents, values.agent)),
|
||||
),
|
||||
...onBothBases(
|
||||
'get',
|
||||
'/empty-state/chips',
|
||||
response.json(() => chipsResponse()),
|
||||
),
|
||||
|
||||
// Everything the user can set off from the page. They answer plainly rather
|
||||
// than through `response`, so a click still lands while the Data control
|
||||
// holds the page's own endpoints on loading or error.
|
||||
// The first send of a new conversation mints a thread, and the page puts the
|
||||
// id it gets back in the pathname: the overlay reports that as a navigation
|
||||
// the story cannot follow, with the answer still streaming underneath.
|
||||
...onBothBases('post', '/threads', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ threadId: THREAD_ID })),
|
||||
),
|
||||
...onBothBases('patch', '/threads/:threadId', ok),
|
||||
...onBothBases('post', '/threads/:threadId/messages', startedExecution),
|
||||
...onBothBases('post', '/messages/:messageId/regenerate', startedExecution),
|
||||
...onBothBases('post', '/messages/:messageId/feedback', ok),
|
||||
...onBothBases('post', '/approve', startedExecution),
|
||||
...onBothBases('post', '/clarify', startedExecution),
|
||||
...onBothBases('post', '/reject', ok),
|
||||
...onBothBases('post', '/cancel', ok),
|
||||
...onBothBases('post', '/undo', ok),
|
||||
...onBothBases('post', '/revert', ok),
|
||||
...onBothBases('post', '/restore', ok),
|
||||
...onBothBases('get', '/executions/:executionId/events', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'text/event-stream'),
|
||||
ctx.body(executionEvents()),
|
||||
),
|
||||
),
|
||||
|
||||
// The composer's `@` picker. Alert rules and services are answered by the
|
||||
// shared handlers already; the dashboard list is not.
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => dashboardsForUserResponse(CONTEXT_DASHBOARDS)),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
// The bare `/ai-assistant` always rewrites itself to the thread it opens,
|
||||
// so a story starts on the thread rather than on the redirect.
|
||||
route: ROUTES.AI_ASSISTANT.replace(
|
||||
':conversationId',
|
||||
values.conversation ? THREAD_ID : NEW_THREAD_ID,
|
||||
),
|
||||
}),
|
||||
effect: (values) => {
|
||||
// The layout fetches the thread list in its mount effect, before the global
|
||||
// config query it takes the assistant's host from has answered, so the
|
||||
// first call would go out against an empty base. Setting it here is what
|
||||
// the config response does, one render earlier.
|
||||
setAIBackendUrl(AI_BACKEND_URL);
|
||||
|
||||
// The store is a zustand singleton and persists the answered blocks and the
|
||||
// active thread, so a story's state is put there before the tree mounts
|
||||
// rather than inherited from whichever story ran last.
|
||||
const conversation = values.conversation
|
||||
? openConversation()
|
||||
: newConversation();
|
||||
|
||||
useAIAssistantStore.setState({
|
||||
conversations: { [conversation.id]: conversation },
|
||||
activeConversationId: conversation.id,
|
||||
isLoadingThread: false,
|
||||
isLoadingThreads: false,
|
||||
answeredBlocks: values.answered ? answeredBlocks() : {},
|
||||
// A stream is client state with no response behind it: the events the
|
||||
// reducer folds into it only exist while the SSE connection is open.
|
||||
streams:
|
||||
values.agent === 'streaming' ? { [conversation.id]: streamingState() } : {},
|
||||
});
|
||||
},
|
||||
});
|
||||
218
frontend/src/pages/AIAssistantPage/AIAssistantPage.stories.tsx
Normal file
218
frontend/src/pages/AIAssistantPage/AIAssistantPage.stories.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import AIAssistantPage from './AIAssistantPage';
|
||||
import { aiAssistantMocks } from './AIAssistantPage.stories.mocks';
|
||||
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
|
||||
|
||||
type AIAssistantArgs = PageStoryArgs<typeof aiAssistantMocks>;
|
||||
|
||||
const pageStory = storyMocks(aiAssistantMocks);
|
||||
|
||||
/**
|
||||
* Noz, the assistant: a thread of messages over the workspace's telemetry, tool
|
||||
* calls and the artefacts they produce rendered inline, and the thread list beside
|
||||
* it. The answer arrives as SSE, so it streams inside the story.
|
||||
*
|
||||
* Route: `/ai-assistant/:conversationId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Noz',
|
||||
tags: ['play'],
|
||||
component: AIAssistantPage,
|
||||
decorators: [withAppLayout],
|
||||
// The conversation id is in the pathname, so the page renders under its own
|
||||
// route rather than being mounted on its own.
|
||||
render: (): JSX.Element => (
|
||||
<Route
|
||||
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
|
||||
component={AIAssistantPage}
|
||||
/>
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AIAssistantArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AIAssistantArgs>;
|
||||
|
||||
/** The thread list resolves before the thread does, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Click something, and keep clicking until what it opens is on screen. The
|
||||
* message list remounts its items while it measures a freshly loaded thread, so
|
||||
* a single click can land on a row that is about to be replaced, taking the
|
||||
* state it just set with it.
|
||||
*/
|
||||
const clickUntil = async (
|
||||
find: () => Promise<HTMLElement>,
|
||||
opens: RegExp,
|
||||
): Promise<void> => {
|
||||
await waitFor(async () => {
|
||||
await userEvent.click(await find());
|
||||
await screen.findByText(opens, undefined, { timeout: 1_000 });
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
/** The blocks the agent renders as cards the user answers in place. */
|
||||
const INTERACTIVE: ThreadPart[] = [
|
||||
'question',
|
||||
'checkboxes',
|
||||
'confirm',
|
||||
'suggested-action',
|
||||
];
|
||||
|
||||
const QUESTIONS: ThreadPart[] = ['question', 'checkboxes'];
|
||||
|
||||
const COMMITMENTS: ThreadPart[] = ['confirm', 'suggested-action'];
|
||||
|
||||
/**
|
||||
* Two short exchanges and nothing else, so the state the story is about sits in
|
||||
* the first screen rather than under a scroll.
|
||||
*/
|
||||
const BRIEF: ThreadPart[] = [];
|
||||
|
||||
/** A thread mid-investigation, with the earlier ones beside it. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The first visit: the suggested prompts and nothing asked yet. */
|
||||
export const NewConversation: Story = {
|
||||
args: { conversation: false, history: 0, archived: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* The cards the agent puts in the thread when it needs the user to pick: one
|
||||
* answer, or several.
|
||||
*/
|
||||
export const QuestionBlocks: Story = {
|
||||
args: { contents: QUESTIONS },
|
||||
};
|
||||
|
||||
/**
|
||||
* The cards that ask the user to commit: a confirmation the agent acts on, and
|
||||
* a page action it will apply here.
|
||||
*/
|
||||
export const ActionBlocks: Story = {
|
||||
args: { contents: COMMITMENTS },
|
||||
};
|
||||
|
||||
/** All four cards once the user has answered them, which the store remembers. */
|
||||
export const AnsweredBlocks: Story = {
|
||||
args: { contents: INTERACTIVE, answered: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Mid-answer: a step already done, the text so far, and a step still running
|
||||
* with the elapsed clock on it. The composer waits its turn.
|
||||
*/
|
||||
export const Streaming: Story = {
|
||||
args: { agent: 'streaming', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** A change the agent will not make until the user reads the diff and approves. */
|
||||
export const AwaitingApproval: Story = {
|
||||
args: { agent: 'awaiting-approval', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** The agent asking for the details it needs, one field per detail. */
|
||||
export const AwaitingClarification: Story = {
|
||||
args: { agent: 'awaiting-clarification', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** Reopening the page on a thread that has not come back yet. */
|
||||
export const LoadingThread: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The steps behind an answer: what the agent thought, and each tool it called
|
||||
* with what went in and what came back.
|
||||
*/
|
||||
export const ActivityExpanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/worked through/i, undefined, untilLoaded),
|
||||
/compared checkout p99/i,
|
||||
);
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/compared checkout p99/i, undefined, untilLoaded),
|
||||
/^Output$/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The approval diff at full size, before against after. */
|
||||
export const ApprovalDiff: Story = {
|
||||
args: { agent: 'awaiting-approval', contents: BRIEF },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByLabelText(
|
||||
/expand diff/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
/approval diff/i,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The comment box a thumbs down opens, which a thumbs up does not. */
|
||||
export const NegativeFeedback: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
// Every assistant message carries the bar; only the last one shows it
|
||||
// without a hover.
|
||||
const bars = await within(canvasElement).findAllByLabelText(
|
||||
/bad response/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return bars[bars.length - 1];
|
||||
}, /what went wrong/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** What a conversation row offers: rename, a link to it, and archiving. */
|
||||
export const ConversationActions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /copy link/i);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's context picker: the dashboards, alerts and services a question
|
||||
* can be pinned to.
|
||||
*/
|
||||
export const AddContext: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /add context/i },
|
||||
untilLoaded,
|
||||
),
|
||||
/checkout overview/i,
|
||||
);
|
||||
},
|
||||
};
|
||||
731
frontend/src/pages/AIAssistantPage/__story_mockdata__/aiAssistant.ts
generated
Normal file
731
frontend/src/pages/AIAssistantPage/__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/AlertHistory.stories.mocks.tsx
generated
Normal file
158
frontend/src/pages/AlertList/AlertHistory/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 '../__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,53 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertHistoryMocks } from './AlertHistory.stories.mocks';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type AlertHistoryArgs = PageStoryArgs<typeof alertHistoryMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertHistoryMocks);
|
||||
|
||||
/**
|
||||
* One rule's firing history: the timeline of state changes, the overall status for
|
||||
* the period, and the series contributing most to it.
|
||||
*
|
||||
* Route: `/alerts/history?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/History',
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AlertHistoryArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AlertHistoryArgs>;
|
||||
|
||||
/**
|
||||
* How one rule behaved over the selected window: how often it fired, how long
|
||||
* it took to resolve, what contributed most, and every state change in order.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A rule that never fired in the window: both cards say so and the table is empty. */
|
||||
export const NeverTriggered: Story = {
|
||||
args: {
|
||||
triggers: 0,
|
||||
resolutionMinutes: 0,
|
||||
topContributors: 0,
|
||||
timelineEntries: 0,
|
||||
statusWindows: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** A rule firing constantly, where the timeline pages rather than fits. */
|
||||
export const Noisy: Story = {
|
||||
args: { triggers: 184, timelineEntries: 40, topContributors: 8 },
|
||||
};
|
||||
216
frontend/src/pages/AlertList/AlertHistory/__story_mockdata__/alertHistory.ts
generated
Normal file
216
frontend/src/pages/AlertList/AlertHistory/__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/AlertOverview.stories.mocks.tsx
generated
Normal file
148
frontend/src/pages/AlertList/AlertOverview/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 '../__story_mockdata__/alerts';
|
||||
import {
|
||||
alertFieldKeysResponse,
|
||||
alertFieldValuesResponse,
|
||||
alertMetricMetadataResponse,
|
||||
alertMetricsResponse,
|
||||
alertPreviewSeries,
|
||||
} from '../__story_mockdata__/alertQuery';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
const STORY_RELATIVE_TIME = '6h';
|
||||
|
||||
const RULE = 'Alert overview · rule';
|
||||
const PREVIEW = 'Alert overview · preview';
|
||||
|
||||
export const alertOverviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`v2` opens the stepper the new alert form uses; `classic` is the single-form page rules written before it still open in.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
ruleState: choiceControl<RuleStateChoice>('State', {
|
||||
group: RULE,
|
||||
description: 'The badge next to the rule name in the header.',
|
||||
options: RULE_STATE_CHOICES,
|
||||
value: 'firing',
|
||||
}),
|
||||
ruleSeverity: choiceControl<SeverityChoice>('Severity', {
|
||||
group: RULE,
|
||||
options: SEVERITY_CHOICES,
|
||||
value: 'critical',
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: RULE,
|
||||
description: 'What the thresholds can be routed to.',
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
previewSeries: countControl('Preview series', {
|
||||
group: PREVIEW,
|
||||
description:
|
||||
'Lines the chart above the condition draws. Zero is the preview with nothing to plot.',
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id',
|
||||
response.json((req) =>
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
alertFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts/overview?ruleId=${STORY_RULE_ID}&relativeTime=${STORY_RELATIVE_TIME}`,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertOverviewMocks } from './AlertOverview.stories.mocks';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type AlertOverviewArgs = PageStoryArgs<typeof alertOverviewMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertOverviewMocks);
|
||||
|
||||
/**
|
||||
* One rule read only: its condition, the series it evaluates against, its state
|
||||
* and the channels it notifies.
|
||||
*
|
||||
* Route: `/alerts/overview?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Overview',
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AlertOverviewArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AlertOverviewArgs>;
|
||||
|
||||
/**
|
||||
* One alert rule opened up: the query it watches, the condition it fires on and
|
||||
* where the notification goes.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A rule written before the current schema, which opens in the classic form. */
|
||||
export const ClassicSchema: Story = {
|
||||
args: { alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/** A rule someone turned off: the toggle in the header is what turns it back on. */
|
||||
export const Disabled: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
};
|
||||
|
||||
/** A rule with no matching series in the window, so the preview has nothing to draw. */
|
||||
export const NoPreviewData: Story = {
|
||||
args: { previewSeries: 0 },
|
||||
};
|
||||
|
||||
/** The rule id in the URL does not resolve, which is where the page gives up. */
|
||||
export const RuleNotFound: Story = {
|
||||
args: { dataState: 'error' },
|
||||
};
|
||||
57
frontend/src/pages/AlertList/AlertRules/AlertRules.stories.mocks.tsx
generated
Normal file
57
frontend/src/pages/AlertList/AlertRules/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 '../__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,83 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertRulesMocks } from './AlertRules.stories.mocks';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertRulesMocks);
|
||||
|
||||
/**
|
||||
* The rule list tab: every rule with its severity, state and channels. Creating
|
||||
* and editing follow the legacy editor role.
|
||||
*
|
||||
* Route: `/alerts?tab=AlertRules`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Rules',
|
||||
tags: ['role-gated', 'play'],
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AlertRulesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AlertRulesArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every alert rule the org has configured, with the state each one evaluated to
|
||||
* on its last run and the severity it fires at.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace with no rule yet, which is where the tab explains itself. */
|
||||
export const NoRules: Story = {
|
||||
args: { rules: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the row actions and the New Alert button are gone, so the tab is
|
||||
* read-only.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** The per-rule actions: enable or disable, edit, clone and delete. */
|
||||
export const RowActions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/clone/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The columns the table can show, including the audit ones it hides by default. */
|
||||
export const ColumnPicker: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-columns-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/toggle columns/i);
|
||||
},
|
||||
};
|
||||
32
frontend/src/pages/AlertList/Channels/Channels.stories.mocks.tsx
generated
Normal file
32
frontend/src/pages/AlertList/Channels/Channels.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import { CHANNEL_MAX, channelsResponse } from '../__story_mockdata__/alerts';
|
||||
import { AlertListTabs } from '../types';
|
||||
|
||||
const LIST = 'Channels · list';
|
||||
|
||||
export const channelsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
channels: countControl('Notification channels', {
|
||||
group: LIST,
|
||||
description: 'One per channel type, in the order the seeds declare them.',
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.CHANNELS}` }),
|
||||
});
|
||||
50
frontend/src/pages/AlertList/Channels/Channels.stories.tsx
Normal file
50
frontend/src/pages/AlertList/Channels/Channels.stories.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelsMocks } from './Channels.stories.mocks';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type ChannelsArgs = PageStoryArgs<typeof channelsMocks>;
|
||||
|
||||
const pageStory = storyMocks(channelsMocks);
|
||||
|
||||
/**
|
||||
* Notification channels tab: what a rule can notify, one row per channel.
|
||||
*
|
||||
* Route: `/alerts?tab=Channels`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Channels/List',
|
||||
tags: ['role-gated'],
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ChannelsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ChannelsArgs>;
|
||||
|
||||
/**
|
||||
* Where notifications go: every configured channel with the integration it
|
||||
* sends through.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace with nowhere to send an alert yet. */
|
||||
export const NoChannels: Story = {
|
||||
args: { channels: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the Action column and the New Alert Channel button are gone, and
|
||||
* the button explains who to ask.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
48
frontend/src/pages/AlertList/ChannelsEdit/ChannelsEdit.stories.mocks.tsx
generated
Normal file
48
frontend/src/pages/AlertList/ChannelsEdit/ChannelsEdit.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
CHANNEL_TYPES,
|
||||
channelResponse,
|
||||
type ChannelType,
|
||||
} from '../__story_mockdata__/alerts';
|
||||
|
||||
const STORY_CHANNEL_ID = '1';
|
||||
|
||||
const CHANNEL = 'Channel · integration';
|
||||
|
||||
export const channelsEditMocks = defineStoryMocks({
|
||||
controls: {
|
||||
channelType: choiceControl<ChannelType>('Channel type', {
|
||||
group: CHANNEL,
|
||||
description:
|
||||
'The integration the saved channel uses, which decides every field below the type picker.',
|
||||
options: CHANNEL_TYPES,
|
||||
value: 'slack',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels/:id',
|
||||
response.json((req) =>
|
||||
channelResponse(String(req.params.id), values.channelType),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/channels/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/testChannel', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelsEditMocks } from './ChannelsEdit.stories.mocks';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type ChannelsEditArgs = PageStoryArgs<typeof channelsEditMocks>;
|
||||
|
||||
const pageStory = storyMocks(channelsEditMocks);
|
||||
|
||||
/**
|
||||
* One channel's settings, with the fields its type asks for and the test call the
|
||||
* form makes before saving.
|
||||
*
|
||||
* Route: `/alerts/channels/edit/:id`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Channels/Edit',
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ChannelsEditArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ChannelsEditArgs>;
|
||||
|
||||
/**
|
||||
* A saved notification channel opened for editing: the name and the type are
|
||||
* fixed, and the integration's own settings are filled from what was stored.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* The PagerDuty channel, whose form carries the routing key and the extra
|
||||
* details sent with the incident.
|
||||
*/
|
||||
export const PagerDuty: Story = {
|
||||
args: { channelType: 'pagerduty' },
|
||||
};
|
||||
|
||||
/** The webhook channel, saved with basic auth on the outgoing request. */
|
||||
export const Webhook: Story = {
|
||||
args: { channelType: 'webhook' },
|
||||
};
|
||||
28
frontend/src/pages/AlertList/ChannelsNew/ChannelsNew.stories.mocks.tsx
generated
Normal file
28
frontend/src/pages/AlertList/ChannelsNew/ChannelsNew.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 { rest } from 'msw';
|
||||
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
/**
|
||||
* The form holds the channel type in component state and posts on save, so the
|
||||
* page has no response for a control to turn: the types are stories with a
|
||||
* `play` that picks one. The write endpoints are here so Save and Test answer
|
||||
* instead of falling through to the catch-all.
|
||||
*/
|
||||
export const channelsNewMocks = defineStoryMocks({
|
||||
controls: {},
|
||||
handlers: () => [
|
||||
rest.post('http://localhost/api/v1/channels', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/testChannel', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: '/alerts/channels/new' }),
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelsNewMocks } from './ChannelsNew.stories.mocks';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type ChannelsNewArgs = PageStoryArgs<typeof channelsNewMocks>;
|
||||
|
||||
const pageStory = storyMocks(channelsNewMocks);
|
||||
|
||||
/**
|
||||
* The new channel form: pick a type, fill its fields, test it, save.
|
||||
*
|
||||
* Route: `/alerts/channels/new`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Channels/New',
|
||||
tags: ['play'],
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ChannelsNewArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ChannelsNewArgs>;
|
||||
|
||||
/**
|
||||
* The type is an antd Select: clicking the element carrying the test id does
|
||||
* nothing, the combobox inside it is what opens the list.
|
||||
*/
|
||||
const selectChannelType = async (
|
||||
canvasElement: HTMLElement,
|
||||
label: RegExp,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const select = await canvas.findByTestId('channel-type-select');
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByTitle(label));
|
||||
};
|
||||
|
||||
/** A new notification channel, on the Slack form the page opens with. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The webhook form: the URL to post to and the auth to send with it. */
|
||||
export const Webhook: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Webhook$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The PagerDuty form: routing key, severity and the incident details. */
|
||||
export const PagerDuty: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Pagerduty$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The email form: the recipients and the HTML body the alert is sent as. */
|
||||
export const Email: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Email$/);
|
||||
},
|
||||
};
|
||||
80
frontend/src/pages/AlertList/PlannedDowntime/PlannedDowntime.stories.mocks.tsx
generated
Normal file
80
frontend/src/pages/AlertList/PlannedDowntime/PlannedDowntime.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
DOWNTIME_KINDS,
|
||||
DOWNTIME_MAX,
|
||||
downtimeSchedulesResponse,
|
||||
type DowntimeKind,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import { alertRulesResponse, RULE_MAX } from '../__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../types';
|
||||
|
||||
const LIST = 'Planned downtime · list';
|
||||
|
||||
export const plannedDowntimeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
schedules: countControl('Planned downtimes', {
|
||||
group: LIST,
|
||||
value: 4,
|
||||
max: DOWNTIME_MAX,
|
||||
}),
|
||||
downtimeKind: choiceControl<DowntimeKind>('Kind', {
|
||||
group: LIST,
|
||||
description:
|
||||
'A recurring downtime carries a repeat rule instead of an end time, which is what the Repeats row shows.',
|
||||
options: DOWNTIME_KINDS,
|
||||
value: 'mixed',
|
||||
}),
|
||||
silencedRules: countControl('Alert rules to silence', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The rules the form offers, and the names a downtime resolves its silenced ids to.',
|
||||
value: 8,
|
||||
max: RULE_MAX,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/downtime_schedules',
|
||||
response.json(() =>
|
||||
downtimeSchedulesResponse(values.schedules, values.downtimeKind),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/downtime_schedules', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/downtime_schedules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.delete(
|
||||
'http://localhost/api/v1/downtime_schedules/:id',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() =>
|
||||
alertRulesResponse(values.silencedRules, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { plannedDowntimeMocks } from './PlannedDowntime.stories.mocks';
|
||||
import { FIRST_DOWNTIME_NAME } from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type PlannedDowntimeArgs = PageStoryArgs<typeof plannedDowntimeMocks>;
|
||||
|
||||
const pageStory = storyMocks(plannedDowntimeMocks);
|
||||
|
||||
/**
|
||||
* Windows that silence rules on a schedule, one off or recurring, with the rules
|
||||
* each window covers.
|
||||
*
|
||||
* Route: `/alerts?tab=Configuration&subTab=PlannedDowntime`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Planned Downtime',
|
||||
tags: ['role-gated', 'play'],
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PlannedDowntimeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PlannedDowntimeArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The windows where alerting is held back: what is running now, what is
|
||||
* scheduled, and which rules each one silences.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace that has never scheduled a downtime. */
|
||||
export const NoDowntimes: Story = {
|
||||
args: { schedules: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the edit and delete actions on a row and the New downtime button
|
||||
* are gone.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** A downtime opened up: who scheduled it, the window, and what it silences. */
|
||||
export const Expanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByText(FIRST_DOWNTIME_NAME, undefined, untilLoaded),
|
||||
);
|
||||
await canvas.findByText(/alerts silenced/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The form a downtime is scheduled in: the window, the repeat and the rules. */
|
||||
export const NewDowntime: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/new downtime/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/new planned downtime/i);
|
||||
},
|
||||
};
|
||||
164
frontend/src/pages/AlertList/PlannedDowntime/__story_mockdata__/plannedDowntime.ts
generated
Normal file
164
frontend/src/pages/AlertList/PlannedDowntime/__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),
|
||||
),
|
||||
});
|
||||
69
frontend/src/pages/AlertList/RoutingPolicies/RoutingPolicies.stories.mocks.tsx
generated
Normal file
69
frontend/src/pages/AlertList/RoutingPolicies/RoutingPolicies.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
ROUTING_POLICY_MAX,
|
||||
routingPoliciesResponse,
|
||||
} from './__story_mockdata__/routingPolicies';
|
||||
|
||||
import {
|
||||
CHANNEL_MAX,
|
||||
channelNames,
|
||||
channelsResponse,
|
||||
} from '../__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../types';
|
||||
|
||||
const LIST = 'Routing policies · list';
|
||||
|
||||
export const routingPoliciesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
policies: countControl('Routing policies', {
|
||||
group: LIST,
|
||||
description: 'The table paginates at five, so the cap is past that.',
|
||||
value: 4,
|
||||
max: ROUTING_POLICY_MAX,
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The channels a policy can route to, and the ones its Channels row names.',
|
||||
value: 6,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/route_policies',
|
||||
response.json(() =>
|
||||
routingPoliciesResponse(values.policies, channelNames(values.channels)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.ROUTING_POLICIES}`,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { routingPoliciesMocks } from './RoutingPolicies.stories.mocks';
|
||||
import { FIRST_POLICY_NAME } from './__story_mockdata__/routingPolicies';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
type RoutingPoliciesArgs = PageStoryArgs<typeof routingPoliciesMocks>;
|
||||
|
||||
const pageStory = storyMocks(routingPoliciesMocks);
|
||||
|
||||
/**
|
||||
* Policies that route a firing alert to channels by expression, in the order they
|
||||
* are evaluated.
|
||||
*
|
||||
* Route: `/alerts?tab=Configuration&subTab=RoutingPolicies`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Routing Policies',
|
||||
tags: ['role-gated', 'play'],
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<RoutingPoliciesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<RoutingPoliciesArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The rules that decide which channel an alert reaches, matched on the labels
|
||||
* the alert carries.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace routing everything through the rule's own channels. */
|
||||
export const NoPolicies: Story = {
|
||||
args: { policies: 0 },
|
||||
};
|
||||
|
||||
/** A viewer: the row actions and the New routing policy button are gone. */
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** A policy opened up: the expression it matches on and where it sends. */
|
||||
export const Expanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByText(FIRST_POLICY_NAME, undefined, untilLoaded),
|
||||
);
|
||||
await canvas.findByText(/expression/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The form a policy is written in: the expression and the channels it routes to. */
|
||||
export const NewPolicy: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/new routing policy/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/create routing policy/i);
|
||||
},
|
||||
};
|
||||
96
frontend/src/pages/AlertList/RoutingPolicies/__story_mockdata__/routingPolicies.ts
generated
Normal file
96
frontend/src/pages/AlertList/RoutingPolicies/__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),
|
||||
),
|
||||
});
|
||||
56
frontend/src/pages/AlertList/TriggeredAlerts/TriggeredAlerts.stories.mocks.tsx
generated
Normal file
56
frontend/src/pages/AlertList/TriggeredAlerts/TriggeredAlerts.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
SEVERITY_CHOICES,
|
||||
TRIGGERED_ALERT_MAX,
|
||||
TRIGGERED_STATES,
|
||||
triggeredAlertsResponse,
|
||||
type SeverityChoice,
|
||||
type TriggeredState,
|
||||
} from '../__story_mockdata__/alerts';
|
||||
import { AlertListTabs } from '../types';
|
||||
|
||||
const LIST = 'Triggered alerts · list';
|
||||
|
||||
export const triggeredAlertsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alerts: countControl('Triggered alerts', {
|
||||
group: LIST,
|
||||
value: 9,
|
||||
max: TRIGGERED_ALERT_MAX,
|
||||
}),
|
||||
alertSeverity: choiceControl<SeverityChoice>('Severity', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The severity label every alert carries. `mixed` leaves each alert with its own, which is what the tag filter has something to narrow.',
|
||||
options: SEVERITY_CHOICES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
alertState: choiceControl<TriggeredState>('Alert state', {
|
||||
group: LIST,
|
||||
description: 'Suppressed alerts are the ones a silence is holding back.',
|
||||
options: TRIGGERED_STATES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/alerts',
|
||||
response.json(() =>
|
||||
triggeredAlertsResponse(values.alerts, {
|
||||
severity: values.alertSeverity,
|
||||
state: values.alertState,
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.TRIGGERED_ALERTS}` }),
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { triggeredAlertsMocks } from './TriggeredAlerts.stories.mocks';
|
||||
|
||||
import AlertList from '../index';
|
||||
import { AlertListTabs } from '../types';
|
||||
|
||||
type TriggeredAlertsArgs = PageStoryArgs<typeof triggeredAlertsMocks>;
|
||||
|
||||
const pageStory = storyMocks(triggeredAlertsMocks);
|
||||
|
||||
/**
|
||||
* Alerts firing now, grouped and filtered from the query string, with severity and
|
||||
* state per row.
|
||||
*
|
||||
* Route: `/alerts?tab=TriggeredAlerts`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Triggered',
|
||||
component: AlertList,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<TriggeredAlertsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<TriggeredAlertsArgs>;
|
||||
|
||||
const tab = `/alerts?tab=${AlertListTabs.TRIGGERED_ALERTS}`;
|
||||
|
||||
/**
|
||||
* The alerts firing right now, newest first, with how long each one has been
|
||||
* firing and the labels the rule attached to it.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Nothing firing, which is the state an on-call engineer wants to see. */
|
||||
export const NoAlerts: Story = {
|
||||
args: { alerts: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* The same alerts collapsed under the service they came from: one row per
|
||||
* group, each expanding to the alerts inside it.
|
||||
*/
|
||||
export const GroupedByService: Story = {
|
||||
parameters: {
|
||||
signoz: { route: `${tab}&groupBy=${JSON.stringify(['service'])}` },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag filter narrowing the list to the critical alerts, which is how the tab
|
||||
* is read during an incident.
|
||||
*/
|
||||
export const FilteredToCritical: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
route: `${tab}&alertFilters=${JSON.stringify(['severity:critical'])}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
225
frontend/src/pages/AlertList/__story_mockdata__/alertQuery.ts
generated
Normal file
225
frontend/src/pages/AlertList/__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: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
661
frontend/src/pages/AlertList/__story_mockdata__/alerts.ts
generated
Normal file
661
frontend/src/pages/AlertList/__story_mockdata__/alerts.ts
generated
Normal file
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
MetrictypesSpaceAggregationDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTimeAggregationDTO,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5ReduceToDTO,
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesCompareOperatorDTO,
|
||||
RuletypesMatchTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleTypeDTO,
|
||||
RuletypesThresholdBasicDTOKind,
|
||||
type AlertmanagertypesDeprecatedGettableAlertDTO,
|
||||
type GetAlerts200,
|
||||
type GetRuleByID200,
|
||||
type ListRules200,
|
||||
type RuletypesAlertCompositeQueryDTO,
|
||||
type RuletypesRuleConditionDTO,
|
||||
type RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import type { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
export const ALERT_SEVERITIES = [
|
||||
'critical',
|
||||
'error',
|
||||
'warning',
|
||||
'info',
|
||||
] as const;
|
||||
|
||||
export type AlertSeverity = (typeof ALERT_SEVERITIES)[number];
|
||||
|
||||
/** `mixed` spreads the seeds' own severities instead of forcing one. */
|
||||
export const SEVERITY_CHOICES = ['mixed', ...ALERT_SEVERITIES] as const;
|
||||
|
||||
export type SeverityChoice = (typeof SEVERITY_CHOICES)[number];
|
||||
|
||||
export const RULE_STATES = [
|
||||
'firing',
|
||||
'pending',
|
||||
'inactive',
|
||||
'disabled',
|
||||
'nodata',
|
||||
] as const;
|
||||
|
||||
export type RuleState = (typeof RULE_STATES)[number];
|
||||
|
||||
export const RULE_STATE_CHOICES = ['mixed', ...RULE_STATES] as const;
|
||||
|
||||
export type RuleStateChoice = (typeof RULE_STATE_CHOICES)[number];
|
||||
|
||||
export const ALERT_SCHEMAS = ['v2', 'classic'] as const;
|
||||
|
||||
export type AlertSchema = (typeof ALERT_SCHEMAS)[number];
|
||||
|
||||
export const CHANNEL_TYPES = [
|
||||
'slack',
|
||||
'webhook',
|
||||
'pagerduty',
|
||||
'opsgenie',
|
||||
'email',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
] as const;
|
||||
|
||||
export type ChannelType = (typeof CHANNEL_TYPES)[number];
|
||||
|
||||
/**
|
||||
* One query envelope is enough for the alert form to mount its query builder
|
||||
* over the rule, and it is the query the preview chart is drawn for.
|
||||
*/
|
||||
const compositeQuery = (seed: RuleSeed): RuletypesAlertCompositeQueryDTO => ({
|
||||
queryType: RuletypesQueryTypeDTO.builder,
|
||||
panelType: RuletypesPanelTypeDTO.graph,
|
||||
unit: seed.unit,
|
||||
queries: [
|
||||
{
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: MetricsSignal.metrics,
|
||||
disabled: false,
|
||||
aggregations: [
|
||||
{
|
||||
metricName: seed.metric,
|
||||
temporality: MetrictypesTemporalityDTO.unspecified,
|
||||
timeAggregation: MetrictypesTimeAggregationDTO.avg,
|
||||
spaceAggregation: MetrictypesSpaceAggregationDTO.avg,
|
||||
reduceTo: Querybuildertypesv5ReduceToDTO.last,
|
||||
},
|
||||
],
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
order: [],
|
||||
stepInterval: 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const condition = (seed: RuleSeed): RuletypesRuleConditionDTO => ({
|
||||
compositeQuery: compositeQuery(seed),
|
||||
op: RuletypesCompareOperatorDTO.above,
|
||||
matchType: RuletypesMatchTypeDTO.at_least_once,
|
||||
selectedQueryName: 'A',
|
||||
target: seed.target,
|
||||
targetUnit: seed.unit,
|
||||
alertOnAbsent: false,
|
||||
requireMinPoints: false,
|
||||
thresholds: {
|
||||
kind: RuletypesThresholdBasicDTOKind.basic,
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
matchType: RuletypesMatchTypeDTO.at_least_once,
|
||||
op: RuletypesCompareOperatorDTO.above,
|
||||
target: seed.target,
|
||||
targetUnit: seed.unit,
|
||||
channels: ['ops-slack'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
interface RuleSeed {
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
state: RuletypesAlertStateDTO;
|
||||
severity: AlertSeverity;
|
||||
labels: Record<string, string>;
|
||||
metric: string;
|
||||
unit: string;
|
||||
target: number;
|
||||
}
|
||||
|
||||
const RULE_SEEDS: RuleSeed[] = [
|
||||
{
|
||||
alert: 'Node CPU saturation',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
severity: 'critical',
|
||||
labels: { team: 'platform', env: 'prod' },
|
||||
metric: 'system_cpu_utilization',
|
||||
unit: 'percent',
|
||||
target: 85,
|
||||
},
|
||||
{
|
||||
alert: 'Checkout API latency above 2s',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
severity: 'critical',
|
||||
labels: { team: 'checkout', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 'ms',
|
||||
target: 2000,
|
||||
},
|
||||
{
|
||||
alert: 'Payment service error rate',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
severity: 'critical',
|
||||
labels: { team: 'payments', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 'percent',
|
||||
target: 5,
|
||||
},
|
||||
{
|
||||
alert: 'Kafka consumer lag',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
severity: 'error',
|
||||
labels: { team: 'platform', component: 'kafka' },
|
||||
metric: 'kafka_consumer_lag',
|
||||
unit: '',
|
||||
target: 10_000,
|
||||
},
|
||||
{
|
||||
alert: 'Postgres connections near limit',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'warning',
|
||||
labels: { team: 'platform', component: 'database' },
|
||||
metric: 'postgresql_backends',
|
||||
unit: '',
|
||||
target: 90,
|
||||
},
|
||||
{
|
||||
alert: 'Auth service 5xx spike',
|
||||
alertType: RuletypesAlertTypeDTO.LOGS_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'error',
|
||||
labels: { team: 'identity', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: '',
|
||||
target: 20,
|
||||
},
|
||||
{
|
||||
alert: 'Unhandled exceptions in web',
|
||||
alertType: RuletypesAlertTypeDTO.EXCEPTIONS_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
severity: 'error',
|
||||
labels: { team: 'web', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: '',
|
||||
target: 15,
|
||||
},
|
||||
{
|
||||
alert: 'Ingest pipeline dropped logs',
|
||||
alertType: RuletypesAlertTypeDTO.LOGS_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.nodata,
|
||||
severity: 'warning',
|
||||
labels: { team: 'platform', component: 'collector' },
|
||||
metric: 'system_memory_usage',
|
||||
unit: '',
|
||||
target: 1,
|
||||
},
|
||||
{
|
||||
alert: 'Nightly batch job overran',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.disabled,
|
||||
severity: 'info',
|
||||
labels: { team: 'data' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 's',
|
||||
target: 3600,
|
||||
},
|
||||
{
|
||||
alert: 'Search p99 above budget',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'info',
|
||||
labels: { team: 'search', env: 'staging' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 'ms',
|
||||
target: 800,
|
||||
},
|
||||
{
|
||||
alert: 'Cache hit ratio dropped',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'info',
|
||||
labels: { team: 'platform', component: 'redis' },
|
||||
metric: 'system_memory_usage',
|
||||
unit: 'percent',
|
||||
target: 70,
|
||||
},
|
||||
{
|
||||
alert: 'Disk usage on ingesters',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
severity: 'critical',
|
||||
labels: { team: 'platform', env: 'prod' },
|
||||
metric: 'system_memory_usage',
|
||||
unit: 'percent',
|
||||
target: 92,
|
||||
},
|
||||
];
|
||||
|
||||
export const RULE_MAX = RULE_SEEDS.length;
|
||||
|
||||
/** The rule `rule-1` resolves to, which is the one the detail stories open. */
|
||||
export const FIRST_RULE_NAME = RULE_SEEDS[0].alert;
|
||||
|
||||
const seedAt = (index: number): RuleSeed =>
|
||||
RULE_SEEDS[index % RULE_SEEDS.length];
|
||||
|
||||
const ruleName = (index: number): string => {
|
||||
const seed = seedAt(index);
|
||||
const round = Math.floor(index / RULE_SEEDS.length);
|
||||
|
||||
return round === 0 ? seed.alert : `${seed.alert} (${round + 1})`;
|
||||
};
|
||||
|
||||
export interface RuleShape {
|
||||
severity: SeverityChoice;
|
||||
state: RuleStateChoice;
|
||||
schema?: AlertSchema;
|
||||
}
|
||||
|
||||
const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
|
||||
const seed = seedAt(index);
|
||||
const severity = shape.severity === 'mixed' ? seed.severity : shape.severity;
|
||||
const state =
|
||||
shape.state === 'mixed'
|
||||
? seed.state
|
||||
: (shape.state as RuletypesAlertStateDTO);
|
||||
|
||||
return {
|
||||
id: `rule-${index + 1}`,
|
||||
alert: ruleName(index),
|
||||
alertType: seed.alertType,
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
state,
|
||||
disabled: state === RuletypesAlertStateDTO.disabled,
|
||||
condition: condition(seed),
|
||||
labels: { severity, ...seed.labels },
|
||||
annotations: {
|
||||
summary: `${seed.alert} crossed its threshold of ${seed.target}`,
|
||||
description:
|
||||
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
|
||||
},
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m0s',
|
||||
createdAt: ago((index + 3) * DAY),
|
||||
updatedAt: ago((index + 1) * HOUR),
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedBy: 'grace@signoz.io',
|
||||
schemaVersion:
|
||||
shape.schema === 'classic' ? undefined : NEW_ALERT_SCHEMA_VERSION,
|
||||
version: 'v5',
|
||||
source: 'http://localhost/alerts',
|
||||
preferredChannels: ['ops-slack'],
|
||||
notificationSettings: {
|
||||
groupBy: ['alertname'],
|
||||
usePolicy: false,
|
||||
renotify: { enabled: false, interval: '30m0s' },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const alertRulesResponse = (
|
||||
count: number,
|
||||
shape: RuleShape,
|
||||
): ListRules200 => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) =>
|
||||
buildRule(index, shape),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* The detail endpoint answers for whatever id the URL carries, so a story keeps
|
||||
* rendering after a row click lands on a rule the list never returned.
|
||||
*/
|
||||
export const alertRuleByIdResponse = (
|
||||
ruleId: string,
|
||||
shape: RuleShape,
|
||||
): GetRuleByID200 => {
|
||||
const index = Math.max(Number.parseInt(ruleId.replace(/\D/g, ''), 10) - 1, 0);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: { ...buildRule(Number.isNaN(index) ? 0 : index, shape), id: ruleId },
|
||||
};
|
||||
};
|
||||
|
||||
interface TriggeredSeed {
|
||||
alertname: string;
|
||||
severity: AlertSeverity;
|
||||
labels: Record<string, string>;
|
||||
summary: string;
|
||||
firingForMinutes: number;
|
||||
}
|
||||
|
||||
const TRIGGERED_SEEDS: TriggeredSeed[] = [
|
||||
{
|
||||
alertname: 'Checkout API latency above 2s',
|
||||
severity: 'critical',
|
||||
labels: { service: 'checkout', env: 'prod', team: 'checkout' },
|
||||
summary: 'p99 latency is 3.4s against a 2s budget',
|
||||
firingForMinutes: 14,
|
||||
},
|
||||
{
|
||||
alertname: 'Payment service error rate',
|
||||
severity: 'critical',
|
||||
labels: { service: 'payments', env: 'prod', team: 'payments' },
|
||||
summary: '7.2% of payment spans failed in the last 5 minutes',
|
||||
firingForMinutes: 42,
|
||||
},
|
||||
{
|
||||
alertname: 'Node CPU saturation',
|
||||
severity: 'warning',
|
||||
labels: { service: 'kubelet', env: 'prod', team: 'platform' },
|
||||
summary: 'CPU utilisation held above 85% on 3 nodes',
|
||||
firingForMinutes: 128,
|
||||
},
|
||||
{
|
||||
alertname: 'Kafka consumer lag',
|
||||
severity: 'error',
|
||||
labels: { service: 'events-consumer', env: 'prod', team: 'platform' },
|
||||
summary: 'Lag is 24k messages and climbing',
|
||||
firingForMinutes: 300,
|
||||
},
|
||||
{
|
||||
alertname: 'Unhandled exceptions in web',
|
||||
severity: 'error',
|
||||
labels: { service: 'web', env: 'prod', team: 'web' },
|
||||
summary: '31 unhandled exceptions in the last 10 minutes',
|
||||
firingForMinutes: 8,
|
||||
},
|
||||
{
|
||||
alertname: 'Auth service 5xx spike',
|
||||
severity: 'error',
|
||||
labels: { service: 'auth', env: 'prod', team: 'identity' },
|
||||
summary: '5xx rate is 22 requests per second',
|
||||
firingForMinutes: 55,
|
||||
},
|
||||
{
|
||||
alertname: 'Search p99 above budget',
|
||||
severity: 'info',
|
||||
labels: { service: 'search', env: 'staging', team: 'search' },
|
||||
summary: 'p99 is 940ms against an 800ms budget',
|
||||
firingForMinutes: 1_450,
|
||||
},
|
||||
{
|
||||
alertname: 'Cache hit ratio dropped',
|
||||
severity: 'info',
|
||||
labels: { service: 'redis', env: 'prod', team: 'platform' },
|
||||
summary: 'Hit ratio fell to 61%',
|
||||
firingForMinutes: 620,
|
||||
},
|
||||
{
|
||||
alertname: 'Disk usage on ingesters',
|
||||
severity: 'critical',
|
||||
labels: { service: 'ingester', env: 'prod', team: 'platform' },
|
||||
summary: 'Two ingesters are above 92% disk',
|
||||
firingForMinutes: 3,
|
||||
},
|
||||
{
|
||||
alertname: 'Postgres connections near limit',
|
||||
severity: 'warning',
|
||||
labels: { service: 'postgres', env: 'prod', team: 'platform' },
|
||||
summary: '91% of the connection pool is in use',
|
||||
firingForMinutes: 240,
|
||||
},
|
||||
{
|
||||
alertname: 'Ingest pipeline dropped logs',
|
||||
severity: 'warning',
|
||||
labels: { service: 'otel-collector', env: 'prod', team: 'platform' },
|
||||
summary: 'The collector dropped 4.1k log records',
|
||||
firingForMinutes: 76,
|
||||
},
|
||||
{
|
||||
alertname: 'Nightly batch job overran',
|
||||
severity: 'info',
|
||||
labels: { service: 'batch-runner', env: 'prod', team: 'data' },
|
||||
summary: 'The nightly job ran 41 minutes past its window',
|
||||
firingForMinutes: 900,
|
||||
},
|
||||
];
|
||||
|
||||
export const TRIGGERED_ALERT_MAX = TRIGGERED_SEEDS.length;
|
||||
|
||||
/** A resolved alert is one alertmanager still lists with an `endsAt` in the past. */
|
||||
export const TRIGGERED_STATES = ['mixed', 'active', 'suppressed'] as const;
|
||||
|
||||
export type TriggeredState = (typeof TRIGGERED_STATES)[number];
|
||||
|
||||
export interface TriggeredShape {
|
||||
severity: SeverityChoice;
|
||||
state: TriggeredState;
|
||||
}
|
||||
|
||||
const buildTriggeredAlert = (
|
||||
index: number,
|
||||
shape: TriggeredShape,
|
||||
): AlertmanagertypesDeprecatedGettableAlertDTO => {
|
||||
const seed = TRIGGERED_SEEDS[index % TRIGGERED_SEEDS.length];
|
||||
const severity = shape.severity === 'mixed' ? seed.severity : shape.severity;
|
||||
const mixedState = index % 4 === 3 ? 'suppressed' : 'active';
|
||||
const state = shape.state === 'mixed' ? mixedState : shape.state;
|
||||
const ruleId = `rule-${(index % RULE_MAX) + 1}`;
|
||||
|
||||
return {
|
||||
fingerprint: `fingerprint-${index + 1}`,
|
||||
startsAt: ago(seed.firingForMinutes * MINUTE),
|
||||
endsAt: new Date(Date.now() + HOUR).toISOString(),
|
||||
generatorURL: `http://localhost/alerts/overview?ruleId=${ruleId}`,
|
||||
labels: {
|
||||
alertname: seed.alertname,
|
||||
severity,
|
||||
ruleId,
|
||||
...seed.labels,
|
||||
},
|
||||
annotations: {
|
||||
summary: seed.summary,
|
||||
description: `${seed.alertname} has been firing for ${seed.firingForMinutes} minutes`,
|
||||
},
|
||||
status: {
|
||||
state,
|
||||
silencedBy: state === 'suppressed' ? ['silence-1'] : [],
|
||||
inhibitedBy: [],
|
||||
},
|
||||
receivers: ['ops-slack'],
|
||||
};
|
||||
};
|
||||
|
||||
export const triggeredAlertsResponse = (
|
||||
count: number,
|
||||
shape: TriggeredShape,
|
||||
): GetAlerts200 => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) =>
|
||||
buildTriggeredAlert(index, shape),
|
||||
),
|
||||
});
|
||||
|
||||
interface ChannelSeed {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
/** The alertmanager receiver the channel serialises into its `data` field. */
|
||||
receiver: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CHANNEL_SEEDS: ChannelSeed[] = [
|
||||
{
|
||||
name: 'ops-slack',
|
||||
type: 'slack',
|
||||
receiver: {
|
||||
slack_configs: [
|
||||
{
|
||||
api_url: 'https://hooks.slack.com/services/T000/B000/story-token',
|
||||
channel: '#ops-alerts',
|
||||
send_resolved: true,
|
||||
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
|
||||
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'oncall-pagerduty',
|
||||
type: 'pagerduty',
|
||||
receiver: {
|
||||
pagerduty_configs: [
|
||||
{
|
||||
routing_key: 'story-routing-key',
|
||||
send_resolved: true,
|
||||
client: 'SigNoz',
|
||||
description: '{{ .CommonLabels.alertname }}',
|
||||
severity: 'critical',
|
||||
details: { firing: '{{ .Alerts.Firing | len }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'platform-webhook',
|
||||
type: 'webhook',
|
||||
receiver: {
|
||||
webhook_configs: [
|
||||
{
|
||||
url: 'https://hooks.example.com/signoz',
|
||||
send_resolved: true,
|
||||
http_config: {
|
||||
basic_auth: { username: 'signoz', password: 'story-password' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sre-email',
|
||||
type: 'email',
|
||||
receiver: {
|
||||
email_configs: [
|
||||
{
|
||||
to: 'sre@signoz.io',
|
||||
send_resolved: true,
|
||||
html: '<p>{{ .CommonLabels.alertname }}</p>',
|
||||
headers: { Subject: '[SigNoz] {{ .CommonLabels.alertname }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'incident-opsgenie',
|
||||
type: 'opsgenie',
|
||||
receiver: {
|
||||
opsgenie_configs: [
|
||||
{
|
||||
api_key: 'story-api-key',
|
||||
send_resolved: true,
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
description: '{{ .CommonLabels.alertname }} is firing',
|
||||
priority: 'P2',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'eng-msteams',
|
||||
type: 'msteams',
|
||||
receiver: {
|
||||
msteamsv2_configs: [
|
||||
{
|
||||
webhook_url: 'https://signoz.webhook.office.com/story',
|
||||
send_resolved: true,
|
||||
title: '{{ .CommonLabels.alertname }}',
|
||||
text: '{{ .CommonAnnotations.summary }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'support-googlechat',
|
||||
type: 'googlechat',
|
||||
receiver: {
|
||||
googlechat_configs: [
|
||||
{
|
||||
webhook_url: 'https://chat.googleapis.com/v1/spaces/story',
|
||||
send_resolved: true,
|
||||
title: '{{ .CommonLabels.alertname }}',
|
||||
description: '{{ .CommonAnnotations.summary }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const CHANNEL_MAX = CHANNEL_SEEDS.length;
|
||||
|
||||
const buildChannel = (index: number): Channels => {
|
||||
const seed = CHANNEL_SEEDS[index % CHANNEL_SEEDS.length];
|
||||
|
||||
return {
|
||||
id: String(index + 1),
|
||||
name: seed.name,
|
||||
type: seed.type,
|
||||
created_at: ago((index + 10) * DAY),
|
||||
updated_at: ago((index + 1) * DAY),
|
||||
data: JSON.stringify({ name: seed.name, ...seed.receiver }),
|
||||
};
|
||||
};
|
||||
|
||||
export const channelsResponse = (
|
||||
count: number,
|
||||
): { status: string; data: Channels[] } => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) => buildChannel(index)),
|
||||
});
|
||||
|
||||
/** Channel names the alert form and the routing policies pick from. */
|
||||
export const channelNames = (count: number): string[] =>
|
||||
channelsResponse(count).data.map((channel) => channel.name);
|
||||
|
||||
export const channelResponse = (
|
||||
id: string,
|
||||
type: ChannelType,
|
||||
): { status: string; data: Channels } => {
|
||||
const index = CHANNEL_SEEDS.findIndex((seed) => seed.type === type);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: { ...buildChannel(Math.max(index, 0)), id },
|
||||
};
|
||||
};
|
||||
97
frontend/src/pages/AllErrors/AllErrors.stories.mocks.tsx
generated
Normal file
97
frontend/src/pages/AllErrors/AllErrors.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { countControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
EXCEPTION_CATALOGUE_SIZE,
|
||||
EXCEPTION_QUICK_FILTER_CAP,
|
||||
exceptionAttributeKeysResponse,
|
||||
exceptionAttributeValuesResponse,
|
||||
exceptionQuickFiltersResponse,
|
||||
exceptionRows,
|
||||
exceptionTotal,
|
||||
type ListErrorsBody,
|
||||
} from './__story_mockdata__/exceptions';
|
||||
|
||||
const LIST = 'Exceptions · list';
|
||||
const FILTERS = 'Exceptions · filters';
|
||||
|
||||
export const exceptionsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
exceptions: countControl('Exception groups', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Groups the endpoint holds. The table asks for one page at a time and pages against `/countErrors`, so a count past ten paginates.',
|
||||
value: EXCEPTION_CATALOGUE_SIZE,
|
||||
max: EXCEPTION_CATALOGUE_SIZE,
|
||||
}),
|
||||
quickFilters: countControl('Quick filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Filters the org has configured for exceptions. At 0 the panel has nothing to render, which is what a workspace that never customised them shows.',
|
||||
value: 6,
|
||||
max: EXCEPTION_QUICK_FILTER_CAP,
|
||||
}),
|
||||
filterPanel: toggleControl('Quick filters panel', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Whether the panel starts expanded. The page keeps this in local storage, so it survives the collapse arrow being clicked.',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v1/listErrors',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as ListErrorsBody;
|
||||
|
||||
return exceptionRows(values.exceptions, body);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/countErrors',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as ListErrorsBody;
|
||||
|
||||
return exceptionTotal(values.exceptions, body);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/orgs/me/filters/:signal',
|
||||
response.json(() => exceptionQuickFiltersResponse(values.quickFilters)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) =>
|
||||
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
response.json((req) =>
|
||||
exceptionAttributeValuesResponse(
|
||||
req.url.searchParams.get('attributeKey'),
|
||||
req.url.searchParams.get('searchText'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
effect: (values) => {
|
||||
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
|
||||
// The quick-filter settings announcement is a first-run popover that covers
|
||||
// the toolbar until it is closed, and closing it is what the app persists.
|
||||
set(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, 'false');
|
||||
},
|
||||
});
|
||||
71
frontend/src/pages/AllErrors/AllErrors.stories.tsx
Normal file
71
frontend/src/pages/AllErrors/AllErrors.stories.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { exceptionsMocks } from './AllErrors.stories.mocks';
|
||||
import AllErrors from './index';
|
||||
|
||||
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
|
||||
|
||||
const pageStory = storyMocks(exceptionsMocks, { route: ROUTES.ALL_ERROR });
|
||||
|
||||
/**
|
||||
* Exception groups over the period, with the quick filters and the filter panel
|
||||
* the explorers share.
|
||||
*
|
||||
* Route: `/exceptions`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Exceptions/List',
|
||||
tags: ['play'],
|
||||
component: AllErrors,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AllErrorsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AllErrorsArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every exception group in the window: the org's quick filters down the left, the
|
||||
* resource filter and the time range above, and the table sorted by application
|
||||
* with each type linking to its detail page.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A workspace with nothing thrown in the window and no quick filters configured,
|
||||
* so the table and the filter panel both show their empty states.
|
||||
*/
|
||||
export const NoExceptions: Story = {
|
||||
args: { exceptions: 0, quickFilters: 0 },
|
||||
};
|
||||
|
||||
/** The table mid-query, with the cancel action the toolbar offers while it runs. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* What cancelling a running query leaves behind: the table is dropped for a
|
||||
* placeholder until Run Query is pressed again.
|
||||
*/
|
||||
export const QueryCancelled: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(/cancel/i, undefined, untilLoaded),
|
||||
);
|
||||
|
||||
await screen.findByText(/query cancelled/i, undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
298
frontend/src/pages/AllErrors/__story_mockdata__/exceptions.ts
generated
Normal file
298
frontend/src/pages/AllErrors/__story_mockdata__/exceptions.ts
generated
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
type BaseAutocompleteData,
|
||||
DataTypes,
|
||||
type IQueryAutocompleteResponse,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { Exception, Order, OrderBy } from 'types/api/errors/getAll';
|
||||
import type { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
import type { Filter } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
export interface ExceptionShape {
|
||||
exceptionType: string;
|
||||
exceptionMessage: string;
|
||||
exceptionCount: number;
|
||||
serviceName: string;
|
||||
groupID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered by count, the way the backend answers an unsorted request, so a slice
|
||||
* keeps a spread of services, languages and counts.
|
||||
*/
|
||||
export const EXCEPTION_CATALOGUE: ExceptionShape[] = [
|
||||
{
|
||||
exceptionType: '*errors.errorString',
|
||||
exceptionMessage: 'redis timeout',
|
||||
exceptionCount: 2510,
|
||||
serviceName: 'redis-manual',
|
||||
groupID: '511b9c91a92b9c5166ecb77235f5743b',
|
||||
},
|
||||
{
|
||||
exceptionType: 'ConnectionError',
|
||||
exceptionMessage:
|
||||
"HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)",
|
||||
exceptionCount: 1834,
|
||||
serviceName: 'checkout',
|
||||
groupID: '6a1f0c2d8e4b7a935c10d4f6b8e2a771',
|
||||
},
|
||||
{
|
||||
exceptionType: 'java.net.SocketTimeoutException',
|
||||
exceptionMessage: 'Read timed out',
|
||||
exceptionCount: 1290,
|
||||
serviceName: 'payment-java',
|
||||
groupID: 'c93d2f81a0b64e7f95d31c8e7a4b0d26',
|
||||
},
|
||||
{
|
||||
exceptionType: 'TypeError',
|
||||
exceptionMessage: "Cannot read properties of undefined (reading 'id')",
|
||||
exceptionCount: 964,
|
||||
serviceName: 'frontend',
|
||||
groupID: '1d7e4b93c05f8a26e91b4d70c3f85a12',
|
||||
},
|
||||
{
|
||||
exceptionType: 'psycopg2.OperationalError',
|
||||
exceptionMessage: 'could not connect to server: Connection refused',
|
||||
exceptionCount: 742,
|
||||
serviceName: 'orders',
|
||||
groupID: 'ab3c5d7e9f10234567890bcdef123456',
|
||||
},
|
||||
{
|
||||
exceptionType: 'KeyError',
|
||||
exceptionMessage: "'customer_id'",
|
||||
exceptionCount: 611,
|
||||
serviceName: 'cart',
|
||||
groupID: '77e0a1b2c3d4e5f60718293a4b5c6d7e',
|
||||
},
|
||||
{
|
||||
exceptionType: '*net.OpError',
|
||||
exceptionMessage: 'dial tcp 10.0.4.11:9092: connect: connection refused',
|
||||
exceptionCount: 508,
|
||||
serviceName: 'kafka-producer',
|
||||
groupID: '2f4a6c8e0b1d3f5709a2b4c6d8e0f135',
|
||||
},
|
||||
{
|
||||
exceptionType: 'ValidationError',
|
||||
exceptionMessage:
|
||||
'1 validation error for Order\nquantity: value is not a valid integer',
|
||||
exceptionCount: 402,
|
||||
serviceName: 'orders',
|
||||
groupID: '9b8a7c6d5e4f30211f2e3d4c5b6a7988',
|
||||
},
|
||||
{
|
||||
exceptionType: 'java.lang.NullPointerException',
|
||||
exceptionMessage: 'Cannot invoke "String.length()" because "sku" is null',
|
||||
exceptionCount: 355,
|
||||
serviceName: 'inventory-java',
|
||||
groupID: '3c1e5a79b2d4f68008a1c3e5b7d9f012',
|
||||
},
|
||||
{
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'Event loop is closed',
|
||||
exceptionCount: 287,
|
||||
serviceName: 'notifications',
|
||||
groupID: 'e5d4c3b2a1908f7e6d5c4b3a29180706',
|
||||
},
|
||||
{
|
||||
exceptionType: 'sqlalchemy.exc.IntegrityError',
|
||||
exceptionMessage:
|
||||
'duplicate key value violates unique constraint "orders_pkey"',
|
||||
exceptionCount: 213,
|
||||
serviceName: 'orders',
|
||||
groupID: '0a1b2c3d4e5f60718293a4b5c6d7e8f9',
|
||||
},
|
||||
{
|
||||
exceptionType: 'AxiosError',
|
||||
exceptionMessage: 'Request failed with status code 503',
|
||||
exceptionCount: 168,
|
||||
serviceName: 'frontend',
|
||||
groupID: '4d6f8a0c2e4b6d8f0a1c3e5b7d9f1113',
|
||||
},
|
||||
{
|
||||
exceptionType: '*fmt.wrapError',
|
||||
exceptionMessage: 'publish message: context deadline exceeded',
|
||||
exceptionCount: 96,
|
||||
serviceName: 'kafka-producer',
|
||||
groupID: 'bb0a99887766554433221100ffeeddcc',
|
||||
},
|
||||
{
|
||||
exceptionType: 'RedisTimeoutError',
|
||||
exceptionMessage: 'Command timed out after 1000ms',
|
||||
exceptionCount: 41,
|
||||
serviceName: 'session-store',
|
||||
groupID: '8e7d6c5b4a39281706f5e4d3c2b1a099',
|
||||
},
|
||||
];
|
||||
|
||||
export const EXCEPTION_CATALOGUE_SIZE = EXCEPTION_CATALOGUE.length;
|
||||
|
||||
export const EXCEPTION_SERVICE_NAMES = Array.from(
|
||||
new Set(EXCEPTION_CATALOGUE.map(({ serviceName }) => serviceName)),
|
||||
);
|
||||
|
||||
export const EXCEPTION_TYPES = EXCEPTION_CATALOGUE.map(
|
||||
({ exceptionType }) => exceptionType,
|
||||
);
|
||||
|
||||
/** The nanoseconds every row carries, so `firstSeen` stays the table's row key. */
|
||||
const SUBSECOND_NANOS = '797616374';
|
||||
|
||||
/**
|
||||
* `lastSeen` and `firstSeen` come back as RFC 3339 with nanoseconds, which is
|
||||
* what `getNanoSeconds` parses to build the link to the detail page.
|
||||
*/
|
||||
const seenAt = (atMs: number): string =>
|
||||
`${new Date(atMs).toISOString().slice(0, 19)}.${SUBSECOND_NANOS}Z`;
|
||||
|
||||
export interface ListErrorsBody {
|
||||
start: string;
|
||||
end: string;
|
||||
order?: Order;
|
||||
orderParam?: OrderBy;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
exceptionType?: string;
|
||||
serviceName?: string;
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
|
||||
const contains = (value: string, search: string | undefined): boolean =>
|
||||
!search || value.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
const compare = (left: Exception, right: Exception, by: OrderBy): number => {
|
||||
if (by === 'exceptionCount') {
|
||||
return left.exceptionCount - right.exceptionCount;
|
||||
}
|
||||
|
||||
if (by === 'lastSeen' || by === 'firstSeen') {
|
||||
return Date.parse(left[by]) - Date.parse(right[by]);
|
||||
}
|
||||
|
||||
return left[by].localeCompare(right[by]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The exception groups the endpoint holds for one request. The timestamps land
|
||||
* inside the window the time picker asked for, and the sorting and the column
|
||||
* searches are applied here: the table writes both into the query string and
|
||||
* sends them along, rather than sorting or filtering what it already has.
|
||||
*/
|
||||
const selectExceptions = (count: number, body: ListErrorsBody): Exception[] => {
|
||||
const endMs = Number(body.end) / 1e6;
|
||||
|
||||
const rows: Exception[] = EXCEPTION_CATALOGUE.slice(0, count).map(
|
||||
(exception, index) => ({
|
||||
...exception,
|
||||
lastSeen: seenAt(endMs - index * MINUTE_MS),
|
||||
firstSeen: seenAt(endMs - (index + 1) * 30 * MINUTE_MS),
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = rows.filter(
|
||||
(row) =>
|
||||
contains(row.exceptionType, body.exceptionType) &&
|
||||
contains(row.serviceName, body.serviceName),
|
||||
);
|
||||
|
||||
const orderParam = body.orderParam ?? 'serviceName';
|
||||
const direction = body.order === 'descending' ? -1 : 1;
|
||||
|
||||
return filtered.sort(
|
||||
(left, right) => direction * compare(left, right, orderParam),
|
||||
);
|
||||
};
|
||||
|
||||
export const exceptionRows = (
|
||||
count: number,
|
||||
body: ListErrorsBody,
|
||||
): Exception[] => {
|
||||
const offset = body.offset ?? 0;
|
||||
const limit = body.limit ?? 10;
|
||||
|
||||
return selectExceptions(count, body).slice(offset, offset + limit);
|
||||
};
|
||||
|
||||
/** `/countErrors` answers the bare total the table pages against. */
|
||||
export const exceptionTotal = (count: number, body: ListErrorsBody): number =>
|
||||
selectExceptions(count, body).length;
|
||||
|
||||
const QUICK_FILTERS: Filter[] = [
|
||||
{ key: 'service.name', dataType: 'string', type: 'resource' },
|
||||
{ key: 'exceptionType', dataType: 'string', type: 'tag' },
|
||||
{ key: 'deployment.environment', dataType: 'string', type: 'resource' },
|
||||
{ key: 'telemetry.sdk.language', dataType: 'string', type: 'resource' },
|
||||
{ key: 'host.name', dataType: 'string', type: 'resource' },
|
||||
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
|
||||
{ key: 'os.type', dataType: 'string', type: 'resource' },
|
||||
{ key: 'hasError', dataType: 'bool', type: 'tag' },
|
||||
];
|
||||
|
||||
export const EXCEPTION_QUICK_FILTER_CAP = QUICK_FILTERS.length;
|
||||
|
||||
export const exceptionQuickFiltersResponse = (
|
||||
count: number,
|
||||
): { status: string; data: { filters: Filter[]; signal: string } } => ({
|
||||
status: 'success',
|
||||
data: { filters: QUICK_FILTERS.slice(0, count), signal: 'exceptions' },
|
||||
});
|
||||
|
||||
const ATTRIBUTE_VALUES: Record<string, string[]> = {
|
||||
'service.name': EXCEPTION_SERVICE_NAMES,
|
||||
exceptionType: EXCEPTION_TYPES,
|
||||
'deployment.environment': ['production', 'staging', 'canary'],
|
||||
'telemetry.sdk.language': ['go', 'python', 'java', 'nodejs'],
|
||||
'host.name': ['ip-10-0-4-11', 'ip-10-0-4-12', 'ip-10-0-5-31'],
|
||||
'k8s.namespace.name': ['default', 'otel-demo', 'payments'],
|
||||
'os.type': ['linux', 'darwin'],
|
||||
};
|
||||
|
||||
export const exceptionAttributeValuesResponse = (
|
||||
attributeKey: string | null,
|
||||
searchText: string | null,
|
||||
): { status: string; data: IAttributeValuesResponse } => {
|
||||
const search = (searchText ?? '').toLowerCase();
|
||||
const values = (ATTRIBUTE_VALUES[attributeKey ?? ''] ?? []).filter((value) =>
|
||||
value.toLowerCase().includes(search),
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
stringAttributeValues: values,
|
||||
numberAttributeValues: null,
|
||||
boolAttributeValues: attributeKey === 'hasError' ? ['true', 'false'] : null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ATTRIBUTE_KEYS: BaseAutocompleteData[] = [
|
||||
...QUICK_FILTERS.map(({ key, dataType, type }) => ({
|
||||
key,
|
||||
dataType: dataType === 'bool' ? DataTypes.bool : DataTypes.String,
|
||||
type,
|
||||
})),
|
||||
{ key: 'service.namespace', dataType: DataTypes.String, type: 'resource' },
|
||||
{ key: 'k8s.pod.name', dataType: DataTypes.String, type: 'resource' },
|
||||
{ key: 'k8s.cluster.name', dataType: DataTypes.String, type: 'resource' },
|
||||
{ key: 'cloud.region', dataType: DataTypes.String, type: 'resource' },
|
||||
];
|
||||
|
||||
export const exceptionAttributeKeysResponse = (
|
||||
searchText: string | null,
|
||||
): { status: string; data: IQueryAutocompleteResponse } => {
|
||||
const search = (searchText ?? '').toLowerCase();
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: ATTRIBUTE_KEYS.filter(({ key }) =>
|
||||
key.toLowerCase().includes(search),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
342
frontend/src/pages/ApiMonitoring/ApiMonitoringPage.stories.mocks.tsx
generated
Normal file
342
frontend/src/pages/ApiMonitoring/ApiMonitoringPage.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { DEFAULT_PARAMS } from 'container/ApiMonitoring/queryParams';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { rest } from 'msw';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { Props as ListOverviewRequest } from 'types/api/thirdPartyApis/listOverview';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
allEndpointsResponse,
|
||||
DEPENDENT_SERVICE_MAX,
|
||||
dependentServicesResponse,
|
||||
DOMAIN_MAX,
|
||||
domainListResponse,
|
||||
domainMetricsResponse,
|
||||
type DrawerDomain,
|
||||
DRAWER_DOMAINS,
|
||||
drawerDomainName,
|
||||
endpointDropdownResponse,
|
||||
ENDPOINT_MAX,
|
||||
endpointMetricsResponse,
|
||||
endpointUrl,
|
||||
groupByAttributeKeys,
|
||||
overTimeChartResponse,
|
||||
STATUS_CODE_MAX,
|
||||
statusCodeChartResponse,
|
||||
statusCodeTableResponse,
|
||||
TOP_ERROR_MAX,
|
||||
topErrorsResponse,
|
||||
} from './__story_mockdata__/apiMonitoring';
|
||||
|
||||
const DOMAINS = 'External APIs · domains';
|
||||
const DRAWER = 'External APIs · drawer';
|
||||
|
||||
const DRAWER_STATES = [
|
||||
'closed',
|
||||
'all-endpoints',
|
||||
'endpoint-stats',
|
||||
'top-errors',
|
||||
] as const;
|
||||
|
||||
type DrawerState = (typeof DRAWER_STATES)[number];
|
||||
|
||||
const VIEW_OF: Record<Exclude<DrawerState, 'closed'>, VIEWS> = {
|
||||
'all-endpoints': VIEWS.ALL_ENDPOINTS,
|
||||
'endpoint-stats': VIEWS.ENDPOINT_STATS,
|
||||
'top-errors': VIEWS.TOP_ERRORS,
|
||||
};
|
||||
|
||||
const RELATIVE_TIME: Time = '30m';
|
||||
|
||||
const THIRTY_MINUTES_IN_MS = 30 * 60 * 1000;
|
||||
|
||||
const NANOSECONDS_IN_MS = 1_000_000;
|
||||
|
||||
/**
|
||||
* `globalTime` derives its window from `window.location.pathname`, which in a
|
||||
* story is the preview's rather than the page's, so without a seeded range the
|
||||
* time picker and the queries would disagree about the window.
|
||||
*/
|
||||
const timeRange = (): Partial<AppState> => {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
globalTime: {
|
||||
minTime: (now - THIRTY_MINUTES_IN_MS) * NANOSECONDS_IN_MS,
|
||||
maxTime: now * NANOSECONDS_IN_MS,
|
||||
loading: false,
|
||||
selectedTime: RELATIVE_TIME,
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const serviceFilterItems = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'storybook-service-filter',
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource' },
|
||||
op: '=',
|
||||
value: 'checkout',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface RouteValues {
|
||||
drawer: DrawerState;
|
||||
drawerDomain: DrawerDomain;
|
||||
domains: number;
|
||||
serviceFilter: boolean;
|
||||
}
|
||||
|
||||
const apiMonitoringRoute = ({
|
||||
drawer,
|
||||
drawerDomain,
|
||||
domains,
|
||||
serviceFilter,
|
||||
}: RouteValues): string => {
|
||||
const domainName = drawerDomainName(drawerDomain, domains);
|
||||
|
||||
if (drawer === 'closed' || !domainName) {
|
||||
return ROUTES.API_MONITORING;
|
||||
}
|
||||
|
||||
const params = {
|
||||
...DEFAULT_PARAMS,
|
||||
selectedDomain: domainName,
|
||||
selectedView: VIEW_OF[drawer],
|
||||
selectedEndPointName:
|
||||
drawer === 'endpoint-stats' ? endpointUrl(domainName, 0) : '',
|
||||
...(serviceFilter ? { endPointDetailsLocalFilters: serviceFilterItems } : {}),
|
||||
};
|
||||
|
||||
return `${ROUTES.API_MONITORING}?apiMonitoringParams=${encodeURIComponent(
|
||||
JSON.stringify(params),
|
||||
)}`;
|
||||
};
|
||||
|
||||
/** The parts of a query spec the handler tells the page's requests apart by. */
|
||||
interface RequestSpec {
|
||||
name?: string;
|
||||
aggregations?: Array<{ expression?: string }>;
|
||||
groupBy?: Array<{ name: string }>;
|
||||
filter?: { expression?: string };
|
||||
}
|
||||
|
||||
interface QueryShape {
|
||||
names: string[];
|
||||
expressions: string[];
|
||||
groupBy: string[];
|
||||
filters: string[];
|
||||
/** The endpoint the request pinned, when one is selected. */
|
||||
endPointName?: string;
|
||||
}
|
||||
|
||||
const shapeOf = (body: QueryRangeRequestV5): QueryShape => {
|
||||
const specs = (body.compositeQuery?.queries ?? []).map(
|
||||
({ spec }) => spec as RequestSpec,
|
||||
);
|
||||
const filters = specs.map((spec) => spec.filter?.expression ?? '');
|
||||
|
||||
return {
|
||||
names: specs
|
||||
.map((spec) => spec.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
expressions: specs.flatMap((spec) =>
|
||||
(spec.aggregations ?? []).map((aggregation) => aggregation.expression ?? ''),
|
||||
),
|
||||
// Every query in the request repeats the same group-by, so the columns the
|
||||
// response answers with are the distinct ones.
|
||||
groupBy: [
|
||||
...new Set(
|
||||
specs.flatMap((spec) => (spec.groupBy ?? []).map(({ name }) => name)),
|
||||
),
|
||||
],
|
||||
filters,
|
||||
endPointName: filters
|
||||
.map((expression) => /http_url\s*=\s*'([^']+)'/.exec(expression)?.[1])
|
||||
.find(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
export const apiMonitoringMocks = defineStoryMocks({
|
||||
controls: {
|
||||
domains: countControl('Domains', {
|
||||
group: DOMAINS,
|
||||
description:
|
||||
'External hosts the workspace called in the window. At 0 the page shows what to instrument instead of the table.',
|
||||
value: DOMAIN_MAX,
|
||||
max: DOMAIN_MAX,
|
||||
}),
|
||||
drawer: choiceControl<DrawerState>('Domain drawer', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'The drawer a domain row opens, and which of its three views is showing.',
|
||||
options: DRAWER_STATES,
|
||||
value: 'closed',
|
||||
}),
|
||||
drawerDomain: choiceControl<DrawerDomain>('Drawer domain', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Which row the drawer opens: a healthy host, one failing most calls, or a bare address on a non-standard port.',
|
||||
options: DRAWER_DOMAINS,
|
||||
value: 'healthy',
|
||||
}),
|
||||
endpoints: countControl('Endpoints', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Endpoints the domain has. Fills the Endpoint Overview table and the endpoint picker.',
|
||||
value: ENDPOINT_MAX,
|
||||
max: ENDPOINT_MAX,
|
||||
}),
|
||||
statusCodes: countControl('Status codes', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Distinct response codes the endpoint answered with, in the table and in the call response chart.',
|
||||
value: STATUS_CODE_MAX,
|
||||
max: STATUS_CODE_MAX,
|
||||
}),
|
||||
dependentServices: countControl('Dependent services', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Services calling the endpoint. Past five the list collapses behind Show more.',
|
||||
value: DEPENDENT_SERVICE_MAX,
|
||||
max: DEPENDENT_SERVICE_MAX,
|
||||
}),
|
||||
topErrors: countControl('Top errors', {
|
||||
group: DRAWER,
|
||||
description: 'Rows the Top 10 Errors table has for the domain.',
|
||||
value: TOP_ERROR_MAX,
|
||||
max: TOP_ERROR_MAX,
|
||||
}),
|
||||
serviceFilter: toggleControl('Service filter', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Puts a service.name filter on the endpoint stats view, which drops the Dependent Services block.',
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v1/third-party-apis/overview/list',
|
||||
response.json(async (req) => {
|
||||
const { show_ip: showIp } = (await req.json()) as ListOverviewRequest;
|
||||
|
||||
return domainListResponse(values.domains, showIp, Date.now());
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/orgs/me/filters/:signal',
|
||||
response.json((req) => ({
|
||||
status: 'success',
|
||||
data: { filters: [], signal: req.params.signal },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: groupByAttributeKeys(
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
},
|
||||
})),
|
||||
),
|
||||
|
||||
// Every widget in the drawer asks the same endpoint, so what a request is
|
||||
// for is only in its shape: the panel type, what it groups by, and which
|
||||
// aggregations it names.
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const shape = shapeOf(body);
|
||||
const domainName =
|
||||
drawerDomainName(values.drawerDomain, values.domains) ?? '';
|
||||
const window = { start: body.start, end: body.end };
|
||||
|
||||
if (body.requestType === 'time_series') {
|
||||
if (shape.groupBy.includes('response_status_code')) {
|
||||
return statusCodeChartResponse(
|
||||
domainName,
|
||||
values.statusCodes,
|
||||
window,
|
||||
shape.expressions.includes('count()') ? 'calls' : 'latency',
|
||||
);
|
||||
}
|
||||
|
||||
return overTimeChartResponse(
|
||||
domainName,
|
||||
window,
|
||||
shape.expressions.includes('rate()') ? 'rate' : 'latency',
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('status_message')) {
|
||||
return topErrorsResponse(
|
||||
domainName,
|
||||
values.topErrors,
|
||||
shape.filters.some((expression) =>
|
||||
expression.includes('status_message EXISTS'),
|
||||
),
|
||||
shape.endPointName,
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('response_status_code')) {
|
||||
return statusCodeTableResponse(domainName, values.statusCodes);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('http_url')) {
|
||||
if (shape.names.length === 1) {
|
||||
return endpointDropdownResponse(domainName, values.endpoints);
|
||||
}
|
||||
|
||||
return allEndpointsResponse(
|
||||
domainName,
|
||||
values.endpoints,
|
||||
shape.groupBy,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('service.name')) {
|
||||
return dependentServicesResponse(domainName, values.dependentServices);
|
||||
}
|
||||
|
||||
if (shape.expressions.includes('rate()')) {
|
||||
return endpointMetricsResponse(
|
||||
domainName,
|
||||
shape.endPointName ?? endpointUrl(domainName, 0),
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
return domainMetricsResponse(domainName, Date.now());
|
||||
}),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
route: apiMonitoringRoute(values),
|
||||
reduxState: timeRange(),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { apiMonitoringMocks } from './ApiMonitoringPage.stories.mocks';
|
||||
import ApiMonitoringPage from './ApiMonitoringPage';
|
||||
|
||||
type ApiMonitoringArgs = PageStoryArgs<typeof apiMonitoringMocks>;
|
||||
|
||||
const pageStory = storyMocks(apiMonitoringMocks);
|
||||
|
||||
/**
|
||||
* Third party domains instrumented services call, their endpoints, status codes
|
||||
* and the services depending on them. The domain drawer is part of the route, so
|
||||
* it is a control rather than a play.
|
||||
*
|
||||
* Route: `/api-monitoring/explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/External APIs',
|
||||
component: ApiMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ApiMonitoringArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ApiMonitoringArgs>;
|
||||
|
||||
/**
|
||||
* Every external host the workspace called in the window, with the endpoints it
|
||||
* uses, how often, how slow and how much of it failed. Clicking a row opens the
|
||||
* domain drawer.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* The domain drawer on All Endpoints: the host's own rate, latency and error
|
||||
* share above a table of every endpoint under it, groupable by any span
|
||||
* attribute.
|
||||
*/
|
||||
export const DomainEndpoints: Story = {
|
||||
args: { drawer: 'all-endpoints' },
|
||||
};
|
||||
|
||||
/**
|
||||
* One endpoint's stats: the services calling it, the codes it answered with as
|
||||
* a chart and a table, and its rate and latency over the window.
|
||||
*/
|
||||
export const EndpointStats: Story = {
|
||||
args: { drawer: 'endpoint-stats' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The ten errors the domain returned most, by endpoint, status code and the
|
||||
* message that came back. A row opens the traces behind it.
|
||||
*/
|
||||
export const TopErrors: Story = {
|
||||
args: { drawer: 'top-errors' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A domain answering almost every call with an error, which is what the drawer
|
||||
* looks like when the host is the problem.
|
||||
*/
|
||||
export const FailingDomain: Story = {
|
||||
args: { drawer: 'endpoint-stats', drawerDomain: 'failing' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Nothing instrumented yet: no client spans carrying a URL, so the page explains
|
||||
* what to send instead of listing hosts.
|
||||
*/
|
||||
export const NoExternalCalls: Story = {
|
||||
args: { domains: 0 },
|
||||
};
|
||||
|
||||
/** The domain list mid-query, with the cancel action the toolbar offers. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
556
frontend/src/pages/ApiMonitoring/__story_mockdata__/apiMonitoring.ts
generated
Normal file
556
frontend/src/pages/ApiMonitoring/__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/CreateAlert.stories.mocks.tsx
generated
Normal file
157
frontend/src/pages/CreateAlert/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/__story_mockdata__/alertQuery';
|
||||
import {
|
||||
channelsResponse,
|
||||
CHANNEL_MAX,
|
||||
} from '../AlertList/__story_mockdata__/alerts';
|
||||
|
||||
/**
|
||||
* Which alert the page is building. The page reads this off the URL, so the
|
||||
* control is a route rather than a response: with no type at all it stays on
|
||||
* the picker, anomaly detection routes to the classic form, and everything else
|
||||
* opens the current one.
|
||||
*/
|
||||
const ALERT_MODES = [
|
||||
'select-type',
|
||||
'metrics',
|
||||
'logs',
|
||||
'traces',
|
||||
'exceptions',
|
||||
'anomaly',
|
||||
'classic-form',
|
||||
] as const;
|
||||
|
||||
type AlertMode = (typeof ALERT_MODES)[number];
|
||||
|
||||
const ALERT_TYPE_BY_MODE: Partial<Record<AlertMode, AlertTypes>> = {
|
||||
metrics: AlertTypes.METRICS_BASED_ALERT,
|
||||
logs: AlertTypes.LOGS_BASED_ALERT,
|
||||
traces: AlertTypes.TRACES_BASED_ALERT,
|
||||
exceptions: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
anomaly: AlertTypes.METRICS_BASED_ALERT,
|
||||
'classic-form': AlertTypes.METRICS_BASED_ALERT,
|
||||
};
|
||||
|
||||
const routeFor = (mode: AlertMode): string => {
|
||||
const alertType = ALERT_TYPE_BY_MODE[mode];
|
||||
|
||||
if (!alertType) {
|
||||
return ROUTES.ALERTS_NEW;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
[QueryParams.alertType]: alertType,
|
||||
[QueryParams.ruleType]:
|
||||
mode === 'anomaly'
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.relativeTime]: '6h',
|
||||
});
|
||||
|
||||
if (mode === 'classic-form') {
|
||||
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
}
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const FORM = 'Create alert · form';
|
||||
|
||||
export const createAlertMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertMode: choiceControl<AlertMode>('Alert being created', {
|
||||
group: FORM,
|
||||
options: ALERT_MODES,
|
||||
value: 'metrics',
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: FORM,
|
||||
description: 'What a threshold can be routed to.',
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
previewSeries: countControl('Preview series', {
|
||||
group: FORM,
|
||||
description:
|
||||
'Lines the chart above the condition draws once the query has something to run. A new metric alert has no metric picked yet, so it draws nothing until one is.',
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
alertFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: routeFor(values.alertMode) }),
|
||||
});
|
||||
61
frontend/src/pages/CreateAlert/CreateAlert.stories.tsx
Normal file
61
frontend/src/pages/CreateAlert/CreateAlert.stories.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { createAlertMocks } from './CreateAlert.stories.mocks';
|
||||
|
||||
import CreateAlertPage from './index';
|
||||
|
||||
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
|
||||
|
||||
const pageStory = storyMocks(createAlertMocks);
|
||||
|
||||
/**
|
||||
* The new rule builder: the query, the condition, the evaluation preview against
|
||||
* `query_range`, and the channels to notify. The mode control picks the alert
|
||||
* type.
|
||||
*
|
||||
* Route: `/alerts/new`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Create',
|
||||
component: CreateAlertPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<CreateAlertArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<CreateAlertArgs>;
|
||||
|
||||
/**
|
||||
* A new metric alert being written: the query it watches, the threshold it
|
||||
* fires on, and where the notification goes.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Where a new alert starts: the signal the rule is going to watch. */
|
||||
export const SelectAlertType: Story = {
|
||||
args: { alertMode: 'select-type' },
|
||||
};
|
||||
|
||||
/** A log-based alert, whose query section searches logs rather than metrics. */
|
||||
export const LogsAlert: Story = {
|
||||
args: { alertMode: 'logs' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Anomaly detection, which is still written in the classic form: the seasonality
|
||||
* and the deviation take the place of a fixed threshold.
|
||||
*/
|
||||
export const AnomalyAlert: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
};
|
||||
|
||||
/** The classic form, which `showClassicCreateAlertsPage` opts back into. */
|
||||
export const ClassicForm: Story = {
|
||||
args: { alertMode: 'classic-form' },
|
||||
};
|
||||
261
frontend/src/pages/DashboardPageV2/DashboardPageV2.stories.mocks.tsx
generated
Normal file
261
frontend/src/pages/DashboardPageV2/DashboardPageV2.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { GetPublicDashboard200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
countControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
import {
|
||||
currentDashboardDocument,
|
||||
patchDashboardDocument,
|
||||
PANEL_IDS,
|
||||
seedDashboardDocument,
|
||||
STORY_DASHBOARD_ID,
|
||||
VARIABLE_KINDS,
|
||||
type DashboardArgs,
|
||||
type VariableKind,
|
||||
} from './__story_mockdata__/dashboard';
|
||||
import {
|
||||
emptyPanelResponse,
|
||||
NAMESPACE_VALUES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from './__story_mockdata__/panelData';
|
||||
|
||||
const LAYOUT = 'Dashboard · layout';
|
||||
const DATA = 'Dashboard · panels';
|
||||
const SHARING = 'Dashboard · sharing';
|
||||
|
||||
export const dashboardRoute = (): string =>
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: STORY_DASHBOARD_ID });
|
||||
|
||||
const NOT_FOUND = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'not_found',
|
||||
message: `dashboard with id ${STORY_DASHBOARD_ID} not found`,
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
|
||||
const ok = { status: 'success', data: null };
|
||||
|
||||
const publicMeta = (): GetPublicDashboard200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: '30m',
|
||||
publicPath: `/public/dashboard/${STORY_DASHBOARD_ID}`,
|
||||
},
|
||||
});
|
||||
|
||||
const NOT_PUBLIC = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'public_dashboard_not_found',
|
||||
message: `dashboard with id ${STORY_DASHBOARD_ID} isn't public`,
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
|
||||
interface PanelQuerySpec {
|
||||
signal?: string;
|
||||
aggregations?: { metricName?: string }[];
|
||||
groupBy?: { name?: string }[];
|
||||
}
|
||||
|
||||
const readPanelQuery = (
|
||||
body: QueryRangeRequestV5,
|
||||
): { metricName?: string; groupBy?: string } => {
|
||||
const spec = body.compositeQuery?.queries?.[0]?.spec as
|
||||
| PanelQuerySpec
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
metricName: spec?.aggregations?.[0]?.metricName,
|
||||
groupBy: spec?.groupBy?.[0]?.name,
|
||||
};
|
||||
};
|
||||
|
||||
export const dashboardMocks = defineStoryMocks({
|
||||
controls: {
|
||||
panels: countControl('Panels', {
|
||||
group: LAYOUT,
|
||||
description:
|
||||
'Panels the dashboard holds, taken in layout order. Zero is the blank dashboard a fresh one starts as.',
|
||||
value: PANEL_IDS.length,
|
||||
max: PANEL_IDS.length,
|
||||
}),
|
||||
sectioned: toggleControl('Sections', {
|
||||
group: LAYOUT,
|
||||
description:
|
||||
'Titled, collapsible, reorderable sections. Off is the single untitled grid a dashboard without sections renders.',
|
||||
value: true,
|
||||
}),
|
||||
locked: toggleControl('Locked', {
|
||||
group: LAYOUT,
|
||||
description:
|
||||
'A locked dashboard is read-only: the lock indicator shows and the edit affordances go.',
|
||||
value: false,
|
||||
}),
|
||||
variables: multiChoiceControl<VariableKind>('Variables', {
|
||||
group: LAYOUT,
|
||||
description:
|
||||
'The variable bar above the panels, one control per kind: a custom list, a query-backed list, a dynamic attribute and a free-text value.',
|
||||
options: VARIABLE_KINDS,
|
||||
value: [...VARIABLE_KINDS],
|
||||
}),
|
||||
variableValues: countControl('Variable options', {
|
||||
group: DATA,
|
||||
description: 'Values the query-backed `service` variable resolves to.',
|
||||
value: 4,
|
||||
max: 12,
|
||||
}),
|
||||
noData: toggleControl('Panels return nothing', {
|
||||
group: DATA,
|
||||
description:
|
||||
'Every panel query answers with an empty result, which is the no-data state each renderer draws on its own.',
|
||||
value: false,
|
||||
}),
|
||||
notFound: toggleControl('Dashboard not found', {
|
||||
group: LAYOUT,
|
||||
description:
|
||||
'Answers the dashboard document with a 404, which is the page-level failure the shell renders around.',
|
||||
value: false,
|
||||
}),
|
||||
published: toggleControl('Published publicly', {
|
||||
group: SHARING,
|
||||
description:
|
||||
'Whether this dashboard has a public link, which is what the header globe reports. Turning it off is the 404 the endpoint answers for a dashboard nobody published.',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const document: DashboardArgs = {
|
||||
panels: values.panels,
|
||||
sectioned: values.sectioned,
|
||||
variables: values.variables,
|
||||
locked: values.locked,
|
||||
};
|
||||
|
||||
return [
|
||||
// The document is what the page renders from, so it answers on its own
|
||||
// rather than through the Data control: the panels are what that holds in
|
||||
// the loading and failed states, with the dashboard already laid out.
|
||||
rest.get('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
|
||||
values.notFound
|
||||
? res(ctx.status(404), ctx.json(NOT_FOUND))
|
||||
: res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
|
||||
),
|
||||
|
||||
// Every spec edit travels as a JSON Patch, and its response is what
|
||||
// replaces the cache, so the ops are applied to the story's document
|
||||
// rather than answered away.
|
||||
rest.patch(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
async (req, res, ctx) => {
|
||||
const ops = (await req.json()) as Parameters<
|
||||
typeof patchDashboardDocument
|
||||
>[1];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(patchDashboardDocument(document, ops)),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/dashboards/:id/clone', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
|
||||
rest.delete(
|
||||
'http://localhost/api/v2/dashboards/:id/lock',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
if (values.noData) {
|
||||
return emptyPanelResponse();
|
||||
}
|
||||
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
|
||||
return panelResponse({
|
||||
requestType: body.requestType,
|
||||
window: { start: body.start, end: body.end },
|
||||
...readPanelQuery(body),
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/variables/query',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(values.variableValues) },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
|
||||
),
|
||||
|
||||
// The header reads the public link on every load, so it answers even while
|
||||
// the panels are held in the loading or failed state.
|
||||
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
|
||||
values.published
|
||||
? res(ctx.status(200), ctx.json(publicMeta()))
|
||||
: res(ctx.status(404), ctx.json(NOT_PUBLIC)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/dashboards/:id/public',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(publicMeta())),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(publicMeta())),
|
||||
),
|
||||
|
||||
rest.delete(
|
||||
'http://localhost/api/v1/dashboards/:id/public',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(ok)),
|
||||
),
|
||||
];
|
||||
},
|
||||
config: () => ({ route: dashboardRoute() }),
|
||||
effect: (values) => {
|
||||
seedDashboardDocument({
|
||||
panels: values.panels,
|
||||
sectioned: values.sectioned,
|
||||
variables: values.variables,
|
||||
locked: values.locked,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from './DashboardPageV2.stories.mocks';
|
||||
|
||||
import DashboardPageV2 from './DashboardPageV2';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks);
|
||||
|
||||
/**
|
||||
* One dashboard: its variables, its sections and every panel querying
|
||||
* `query_range`, plus the lock, clone and publish actions in the header.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail',
|
||||
tags: ['role-gated'],
|
||||
component: DashboardPageV2,
|
||||
decorators: [withAppLayout],
|
||||
// The page reads the dashboard id out of the pathname, so it renders under
|
||||
// its own route rather than being mounted on its own.
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPageV2} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/**
|
||||
* A service dashboard with data: the variable bar over two titled sections, and
|
||||
* the panel kinds all drawn from the same query endpoint: time series, single
|
||||
* numbers, a table, a bar chart, a pie and a log list.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* The blank dashboard a freshly created one starts as, with the two steps that
|
||||
* populate it. A titled section renders its own add-panel state instead, so this
|
||||
* needs both no panels and no sections.
|
||||
*/
|
||||
export const Empty: Story = {
|
||||
args: { panels: 0, sectioned: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* A locked dashboard: the lock indicator sits over the grid and the edit
|
||||
* affordances are gone even for an admin.
|
||||
*/
|
||||
export const Locked: Story = {
|
||||
args: { locked: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the panels and the variable bar work, but nothing that would change
|
||||
* the dashboard is offered.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Every query ran and matched nothing, which each panel kind draws as its own
|
||||
* no-data state.
|
||||
*/
|
||||
export const NoData: Story = {
|
||||
args: { noData: true },
|
||||
};
|
||||
|
||||
/** The panels mid-fetch, with the header, variable bar and grid already laid out. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/** A dashboard id nobody has, which is what a deleted or mistyped link opens on. */
|
||||
export const NotFound: Story = {
|
||||
args: { notFound: true },
|
||||
};
|
||||
205
frontend/src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.stories.mocks.tsx
generated
Normal file
205
frontend/src/pages/DashboardPageV2/PanelEditorPage/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 '../__story_mockdata__/dashboard';
|
||||
import {
|
||||
emptyPanelResponse,
|
||||
NAMESPACE_VALUES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from '../__story_mockdata__/panelData';
|
||||
import {
|
||||
EDITOR_FIELD_KEYS,
|
||||
EDITOR_FIELD_VALUES,
|
||||
EDITOR_METRICS,
|
||||
NEW_PANEL_KINDS,
|
||||
newPanelKindOf,
|
||||
type NewPanelKind,
|
||||
} from './__story_mockdata__/panelEditor';
|
||||
|
||||
const PANEL = 'Panel editor · panel';
|
||||
const DATA = 'Panel editor · data';
|
||||
|
||||
const PANEL_OPTIONS = [...PANEL_IDS, NEW_PANEL_ID] as const;
|
||||
|
||||
type PanelOption = (typeof PANEL_OPTIONS)[number];
|
||||
|
||||
const editorRoute = (panel: PanelOption, kind: NewPanelKind): string => {
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId: STORY_DASHBOARD_ID,
|
||||
panelId: panel,
|
||||
});
|
||||
|
||||
return panel === NEW_PANEL_ID
|
||||
? `${path}${newPanelSearch(newPanelKindOf(kind))}`
|
||||
: path;
|
||||
};
|
||||
|
||||
export const panelEditorMocks = defineStoryMocks({
|
||||
controls: {
|
||||
panel: choiceControl<PanelOption>('Panel', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'The panel the editor opens on. `new` is the create route, which seeds an unsaved panel of the kind below instead of loading one.',
|
||||
options: PANEL_OPTIONS,
|
||||
value: 'request-rate',
|
||||
}),
|
||||
newPanelKind: choiceControl<NewPanelKind>('New panel kind', {
|
||||
group: PANEL,
|
||||
description: 'Which kind the create route seeds. Ignored on a saved panel.',
|
||||
options: NEW_PANEL_KINDS,
|
||||
value: 'time-series',
|
||||
}),
|
||||
locked: toggleControl('Dashboard locked', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'A locked dashboard is read-only, so the editor loads but Save is refused with the reason.',
|
||||
value: false,
|
||||
}),
|
||||
noData: toggleControl('Preview returns nothing', {
|
||||
group: DATA,
|
||||
description: 'The preview query answers with an empty result.',
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const document: DashboardArgs = {
|
||||
panels: PANEL_IDS.length,
|
||||
sectioned: true,
|
||||
variables: VARIABLE_KINDS,
|
||||
locked: values.locked,
|
||||
};
|
||||
|
||||
return [
|
||||
// The document the editor resolves its panel from, so it answers on its own
|
||||
// rather than through the Data control.
|
||||
rest.get('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
|
||||
),
|
||||
|
||||
// Saving the panel is a JSON Patch whose response replaces the cache, so
|
||||
// the ops are applied to the story's document and the edit stays.
|
||||
rest.patch(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
async (req, res, ctx) => {
|
||||
const ops = (await req.json()) as Parameters<
|
||||
typeof patchDashboardDocument
|
||||
>[1];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(patchDashboardDocument(document, ops)),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
if (values.noData) {
|
||||
return emptyPanelResponse();
|
||||
}
|
||||
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const spec = body.compositeQuery?.queries?.[0]?.spec as
|
||||
| {
|
||||
aggregations?: { metricName?: string }[];
|
||||
groupBy?: { name?: string }[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return panelResponse({
|
||||
requestType: body.requestType,
|
||||
window: { start: body.start, end: body.end },
|
||||
metricName: spec?.aggregations?.[0]?.metricName,
|
||||
groupBy: spec?.groupBy?.[0]?.name,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/variables/query',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(4) },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
listMetricsResponse(
|
||||
EDITOR_METRICS,
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
metricMetadataResponse(
|
||||
EDITOR_METRICS,
|
||||
req.url.searchParams.get('metricName') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(EDITOR_FIELD_KEYS)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
fieldValuesResponse(
|
||||
EDITOR_FIELD_VALUES[req.url.searchParams.get('name') ?? ''] ??
|
||||
NAMESPACE_VALUES,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
config: (values) => ({
|
||||
route: editorRoute(values.panel, values.newPanelKind),
|
||||
}),
|
||||
effect: (values) => {
|
||||
seedDashboardDocument({
|
||||
panels: PANEL_IDS.length,
|
||||
sectioned: true,
|
||||
variables: VARIABLE_KINDS,
|
||||
locked: values.locked,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { panelEditorMocks } from './PanelEditorPage.stories.mocks';
|
||||
|
||||
import PanelEditorPage from './PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks);
|
||||
|
||||
/**
|
||||
* The panel editor: the query builder on one side, the panel it renders on the
|
||||
* other, for a panel that exists or a new one of the chosen kind.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor',
|
||||
component: PanelEditorPage,
|
||||
decorators: [withAppLayout],
|
||||
// The dashboard and panel ids come out of the pathname, so the editor renders
|
||||
// under its own route rather than being mounted on its own.
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/**
|
||||
* Editing a saved time series panel: the live preview over the query builder on
|
||||
* the left, the panel's formatting, legend, axes and thresholds on the right.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The create route, seeding an unsaved panel of the chosen kind. */
|
||||
export const NewPanel: Story = {
|
||||
args: { panel: 'new' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
};
|
||||
|
||||
/** A table panel, with its column units and thresholds. */
|
||||
export const TablePanel: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
};
|
||||
|
||||
/** A locked dashboard: the editor still opens, but it cannot save. */
|
||||
export const ReadOnly: Story = {
|
||||
args: { locked: true },
|
||||
};
|
||||
98
frontend/src/pages/DashboardPageV2/PanelEditorPage/__story_mockdata__/panelEditor.ts
generated
Normal file
98
frontend/src/pages/DashboardPageV2/PanelEditorPage/__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,
|
||||
),
|
||||
];
|
||||
453
frontend/src/pages/DashboardPageV2/__story_mockdata__/dashboard.ts
generated
Normal file
453
frontend/src/pages/DashboardPageV2/__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/DashboardPageV2/__story_mockdata__/panelData.ts
generated
Normal file
175
frontend/src/pages/DashboardPageV2/__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;
|
||||
163
frontend/src/pages/DashboardWidget/DashboardWidget.stories.mocks.tsx
generated
Normal file
163
frontend/src/pages/DashboardWidget/DashboardWidget.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { choiceControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
listMetricsResponse,
|
||||
metricMetadataResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/metrics';
|
||||
|
||||
import {
|
||||
emptyPanelResponse,
|
||||
panelResponse,
|
||||
} from '../DashboardPageV2/__story_mockdata__/panelData';
|
||||
import {
|
||||
EDITOR_FIELD_KEYS,
|
||||
EDITOR_FIELD_VALUES,
|
||||
EDITOR_METRICS,
|
||||
} from '../DashboardPageV2/PanelEditorPage/__story_mockdata__/panelEditor';
|
||||
import {
|
||||
dashboardV1Response,
|
||||
exportedQuery,
|
||||
NEW_WIDGET_ID,
|
||||
SAVED_WIDGET_ID,
|
||||
STORY_DASHBOARD_ID,
|
||||
WIDGET_PANEL_TYPES,
|
||||
type WidgetPanelType,
|
||||
} from './__story_mockdata__/dashboardWidget';
|
||||
|
||||
const WIDGET = 'Widget editor · widget';
|
||||
const DATA = 'Widget editor · data';
|
||||
|
||||
const WIDGET_SOURCES = ['export', 'saved'] as const;
|
||||
|
||||
type WidgetSource = (typeof WIDGET_SOURCES)[number];
|
||||
|
||||
/**
|
||||
* The route the explorers' "Add to dashboard" builds: the `new` path segment is
|
||||
* the editor, and the widget id, panel type and exported query all ride the
|
||||
* query string.
|
||||
*/
|
||||
const widgetRoute = (
|
||||
source: WidgetSource,
|
||||
panelType: WidgetPanelType,
|
||||
): string => {
|
||||
const path = generatePath(ROUTES.DASHBOARD_WIDGET, {
|
||||
dashboardId: STORY_DASHBOARD_ID,
|
||||
widgetId: 'new',
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
[QueryParams.graphType]: panelType,
|
||||
[QueryParams.widgetId]: source === 'export' ? NEW_WIDGET_ID : SAVED_WIDGET_ID,
|
||||
});
|
||||
|
||||
if (source === 'export') {
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(exportedQuery(panelType))),
|
||||
);
|
||||
}
|
||||
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const dashboardWidgetMocks = defineStoryMocks({
|
||||
controls: {
|
||||
source: choiceControl<WidgetSource>('Widget', {
|
||||
group: WIDGET,
|
||||
description:
|
||||
'`export` is the explorer handing a query over to a brand-new widget; `saved` opens the editor on a widget the dashboard already holds.',
|
||||
options: WIDGET_SOURCES,
|
||||
value: 'export',
|
||||
}),
|
||||
panelType: choiceControl<WidgetPanelType>('Panel type', {
|
||||
group: WIDGET,
|
||||
description: 'The panel type the route opens on.',
|
||||
options: WIDGET_PANEL_TYPES,
|
||||
value: 'graph',
|
||||
}),
|
||||
noData: toggleControl('Preview returns nothing', {
|
||||
group: DATA,
|
||||
description: 'The preview query answers with an empty result.',
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
// The editor cannot render without the dashboard it saves into, so it
|
||||
// answers on its own rather than through the Data control.
|
||||
rest.get('http://localhost/api/v1/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(dashboardV1Response(values.panelType))),
|
||||
),
|
||||
|
||||
// Saving the widget writes the whole v1 dashboard back; the editor navigates
|
||||
// away on success, which the story reports as a blocked navigation.
|
||||
rest.put('http://localhost/api/v1/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(dashboardV1Response(values.panelType))),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
if (values.noData) {
|
||||
return emptyPanelResponse();
|
||||
}
|
||||
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
|
||||
return panelResponse({
|
||||
requestType: body.requestType,
|
||||
window: { start: body.start, end: body.end },
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
listMetricsResponse(
|
||||
EDITOR_METRICS,
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
metricMetadataResponse(
|
||||
EDITOR_METRICS,
|
||||
req.url.searchParams.get('metricName') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(EDITOR_FIELD_KEYS)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
fieldValuesResponse(
|
||||
EDITOR_FIELD_VALUES[req.url.searchParams.get('name') ?? ''] ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: widgetRoute(values.source, values.panelType) }),
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardWidgetMocks } from './DashboardWidget.stories.mocks';
|
||||
|
||||
import DashboardWidget from './index';
|
||||
|
||||
type DashboardWidgetArgs = PageStoryArgs<typeof dashboardWidgetMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardWidgetMocks);
|
||||
|
||||
/**
|
||||
* The widget editor a v1 dashboard opens: one query per panel type against
|
||||
* `query_range`.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/:widgetId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Widget Editor',
|
||||
component: DashboardWidget,
|
||||
decorators: [withAppLayout],
|
||||
// The dashboard id comes out of the pathname and the widget out of the query
|
||||
// string, so the editor renders under its own route.
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_WIDGET} component={DashboardWidget} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardWidgetArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardWidgetArgs>;
|
||||
|
||||
/**
|
||||
* Where an explorer's "Add to dashboard" lands: the query it exported already in
|
||||
* the builder, the preview above it, and the panel's options on the right,
|
||||
* waiting to be saved into the dashboard.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Editing a widget the dashboard already holds, opened on its saved query. */
|
||||
export const SavedWidget: Story = {
|
||||
args: { source: 'saved' },
|
||||
};
|
||||
|
||||
/** The same export as a table, which swaps the preview and the options pane. */
|
||||
export const TableWidget: Story = {
|
||||
args: { panelType: 'table' },
|
||||
};
|
||||
117
frontend/src/pages/DashboardWidget/__story_mockdata__/dashboardWidget.ts
generated
Normal file
117
frontend/src/pages/DashboardWidget/__story_mockdata__/dashboardWidget.ts
generated
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
ReduceOperators,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
export const STORY_DASHBOARD_ID = 'storybook-dashboard-1';
|
||||
|
||||
/** The id the explorer export puts in `widgetId`; nothing in the dashboard has it. */
|
||||
export const NEW_WIDGET_ID = 'storybook-widget-new';
|
||||
|
||||
export const SAVED_WIDGET_ID = 'storybook-widget-saved';
|
||||
|
||||
/**
|
||||
* Panel types the editor's own type switcher offers, as the `graphType` param
|
||||
* spells them. Written as the enum's string values so a control option stays a
|
||||
* plain string while still coming from the enum.
|
||||
*/
|
||||
export type WidgetPanelType = `${
|
||||
| PANEL_TYPES.TIME_SERIES
|
||||
| PANEL_TYPES.VALUE
|
||||
| PANEL_TYPES.TABLE
|
||||
| PANEL_TYPES.LIST
|
||||
| PANEL_TYPES.BAR
|
||||
| PANEL_TYPES.PIE
|
||||
| PANEL_TYPES.HISTOGRAM}`;
|
||||
|
||||
export const WIDGET_PANEL_TYPES: readonly WidgetPanelType[] = [
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.VALUE,
|
||||
PANEL_TYPES.TABLE,
|
||||
PANEL_TYPES.LIST,
|
||||
PANEL_TYPES.BAR,
|
||||
PANEL_TYPES.PIE,
|
||||
PANEL_TYPES.HISTOGRAM,
|
||||
];
|
||||
|
||||
const EXPORTED_AGGREGATION: MetricAggregation = {
|
||||
metricName: 'signoz_calls_total',
|
||||
temporality: '',
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
};
|
||||
|
||||
/**
|
||||
* The exported query the explorer hands over in `compositeQuery`, built from the
|
||||
* app's own seed so a change to the query-builder shape reaches the story.
|
||||
*/
|
||||
export const exportedQuery = (panelType: WidgetPanelType): Query => {
|
||||
const seed = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
return {
|
||||
...seed,
|
||||
builder: {
|
||||
...seed.builder,
|
||||
queryData: seed.builder.queryData.map(
|
||||
(data): IBuilderQuery => ({
|
||||
...data,
|
||||
aggregations: [EXPORTED_AGGREGATION],
|
||||
legend: panelType === PANEL_TYPES.LIST ? '' : 'requests',
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const savedWidget = (panelType: WidgetPanelType): Widgets => ({
|
||||
id: SAVED_WIDGET_ID,
|
||||
panelTypes: panelType as PANEL_TYPES,
|
||||
title: 'Request rate by service',
|
||||
description: 'Calls per second, grouped by service.',
|
||||
opacity: '1',
|
||||
nullZeroValues: 'zero',
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: null,
|
||||
selectedTracesFields: null,
|
||||
query: exportedQuery(panelType),
|
||||
});
|
||||
|
||||
export const dashboardV1Response = (
|
||||
panelType: WidgetPanelType,
|
||||
): { status: string; data: Dashboard } => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: STORY_DASHBOARD_ID,
|
||||
createdAt: '2026-05-04T09:12:00Z',
|
||||
updatedAt: '2026-08-21T16:40:00Z',
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedBy: 'ada@signoz.io',
|
||||
locked: false,
|
||||
data: {
|
||||
title: 'Checkout service overview',
|
||||
description: 'Traffic, errors and latency for the checkout path.',
|
||||
tags: ['env:prod'],
|
||||
variables: {},
|
||||
widgets: [savedWidget(panelType)],
|
||||
layout: [{ i: SAVED_WIDGET_ID, x: 0, y: 0, w: 6, h: 6 }],
|
||||
panelMap: {},
|
||||
version: 'v4',
|
||||
},
|
||||
},
|
||||
});
|
||||
303
frontend/src/pages/DashboardsListPageV2/DashboardsListPageV2.stories.mocks.tsx
generated
Normal file
303
frontend/src/pages/DashboardsListPageV2/DashboardsListPageV2.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import { dashboardResponse } from '../DashboardPageV2/__story_mockdata__/dashboard';
|
||||
import {
|
||||
dashboardIdAt,
|
||||
dashboardViewsResponse,
|
||||
dashboardsListResponse,
|
||||
orgUsersResponse,
|
||||
recentDashboardIds,
|
||||
ROW_MARKERS,
|
||||
savedView,
|
||||
seedPinnedDashboards,
|
||||
setDashboardPinned,
|
||||
STORY_USER_EMAIL,
|
||||
type RowMarker,
|
||||
} from './__story_mockdata__/dashboardsList';
|
||||
import { useDashboardViewsStore } from './store/useDashboardViewsStore';
|
||||
import {
|
||||
type DashboardDynamicColumns,
|
||||
useDashboardsListVisibleColumnsStore,
|
||||
} from './store/useVisibleColumnsStore';
|
||||
import { BuiltinViewId } from './types';
|
||||
import { builtinViewQuery } from './utils/views';
|
||||
|
||||
const LIST = 'Dashboards · list';
|
||||
const VIEWS = 'Dashboards · views';
|
||||
|
||||
const VIEWS_OPTIONS = [
|
||||
BuiltinViewId.All,
|
||||
BuiltinViewId.Mine,
|
||||
BuiltinViewId.Pinned,
|
||||
BuiltinViewId.Recent,
|
||||
BuiltinViewId.Locked,
|
||||
'saved',
|
||||
] as const;
|
||||
|
||||
type ViewOption = (typeof VIEWS_OPTIONS)[number];
|
||||
|
||||
const DETAIL_COLUMNS = ['updatedAt', 'updatedBy'] as const;
|
||||
|
||||
type DetailColumn = (typeof DETAIL_COLUMNS)[number];
|
||||
|
||||
const RECENT_COUNT = 4;
|
||||
|
||||
const PINNED_COUNT = 3;
|
||||
|
||||
/** A dashboard document, for the writes that echo the touched dashboard back. */
|
||||
const writtenDashboard = (): GetDashboardV2200 =>
|
||||
dashboardResponse({
|
||||
panels: 0,
|
||||
sectioned: false,
|
||||
variables: [],
|
||||
locked: false,
|
||||
});
|
||||
|
||||
const ok = { status: 'success', data: null };
|
||||
|
||||
/**
|
||||
* `formatQueryErrorMessage` strips the `invalid filter query:` prefix and turns
|
||||
* the backticks into quotes, so the message carries both to show it doing it.
|
||||
*/
|
||||
const INVALID_QUERY_MESSAGE =
|
||||
'invalid filter query: unexpected token `enviroment` at position 0, expected one of `name`, `description`, `created_by`, `created_at`, `updated_at`, `locked`';
|
||||
|
||||
/**
|
||||
* The rail applies a view by writing both `view` and `query`, so a story that
|
||||
* opens on one has to seed both or the header shows unsaved changes on mount.
|
||||
*/
|
||||
const listRoute = (view: ViewOption): string => {
|
||||
const { id, query } =
|
||||
view === 'saved'
|
||||
? savedView(0)
|
||||
: { id: view, query: builtinViewQuery(view, STORY_USER_EMAIL) ?? '' };
|
||||
|
||||
const params = new URLSearchParams({ view: id });
|
||||
|
||||
if (query) {
|
||||
params.set('query', query);
|
||||
}
|
||||
|
||||
return `${ROUTES.ALL_DASHBOARD}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const visibleColumns = (
|
||||
columns: readonly DetailColumn[],
|
||||
): DashboardDynamicColumns => ({
|
||||
createdAt: true,
|
||||
createdBy: true,
|
||||
updatedAt: columns.includes('updatedAt'),
|
||||
updatedBy: columns.includes('updatedBy'),
|
||||
});
|
||||
|
||||
export const dashboardsListMocks = defineStoryMocks({
|
||||
controls: {
|
||||
dashboards: countControl('Dashboards', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Dashboards the org has. The list pages at 20, so a higher count adds a pager.',
|
||||
value: 24,
|
||||
max: 45,
|
||||
}),
|
||||
markers: multiChoiceControl<RowMarker>('Row markers', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Pinned rows float to the top, locked rows carry the padlock, and a legacy row opens the "not available in the new experience" dialog instead of the dashboard.',
|
||||
options: ROW_MARKERS,
|
||||
value: [...ROW_MARKERS],
|
||||
}),
|
||||
columns: multiChoiceControl<DetailColumn>('Detail columns', {
|
||||
group: LIST,
|
||||
description: 'The optional fields on each 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,90 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, screen, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardsListMocks } from './DashboardsListPageV2.stories.mocks';
|
||||
import { BuiltinViewId } from './types';
|
||||
|
||||
import DashboardsListPageV2 from './DashboardsListPageV2';
|
||||
|
||||
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardsListMocks);
|
||||
|
||||
/**
|
||||
* Every dashboard in the workspace, with pins, the saved views over the list, and
|
||||
* the create, clone and lock actions. Creating follows the legacy editor role.
|
||||
*
|
||||
* Route: `/dashboard`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/List',
|
||||
tags: ['role-gated', 'play'],
|
||||
component: DashboardsListPageV2,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardsListArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardsListArgs>;
|
||||
|
||||
/**
|
||||
* All dashboards: the views rail on the left, the query box and the Created-by
|
||||
* and Updated dropdowns above the rows, pinned dashboards first, and a pager
|
||||
* because the org has more than one page of them.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* An org-shared saved view applied on load, so the rail entry is selected and
|
||||
* its query is in the box.
|
||||
*/
|
||||
export const SavedView: Story = {
|
||||
args: { view: 'saved' },
|
||||
};
|
||||
|
||||
/** What a new workspace shows: the create-your-first-dashboard call to action. */
|
||||
export const EmptyWorkspace: Story = {
|
||||
args: { dashboards: 0, savedViews: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the rows and the rail are still browsable, but everything that
|
||||
* writes (New dashboard, saving a view, the row's edit actions) is gone.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** The rows the user pinned, which the page filters out of the fetched page. */
|
||||
export const Pinned: Story = {
|
||||
args: { view: BuiltinViewId.Pinned },
|
||||
};
|
||||
|
||||
/**
|
||||
* The query the backend refused: the parse error it returned replaces the
|
||||
* generic failure copy, and there is nothing to retry.
|
||||
*/
|
||||
export const InvalidQuery: Story = {
|
||||
args: { invalidQuery: true },
|
||||
};
|
||||
|
||||
/** The New dashboard dialog, on its blank-dashboard tab. */
|
||||
export const NewDashboard: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
).toHaveTextContent('New dashboard');
|
||||
},
|
||||
};
|
||||
338
frontend/src/pages/DashboardsListPageV2/__story_mockdata__/dashboardsList.ts
generated
Normal file
338
frontend/src/pages/DashboardsListPageV2/__story_mockdata__/dashboardsList.ts
generated
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
DashboardtypesSourceDTO,
|
||||
type DashboardtypesListedDashboardForUserV2DTO,
|
||||
type ListDashboardViews200,
|
||||
type ListDashboardsForUserV2200,
|
||||
type ListUsers200,
|
||||
type TagtypesGettableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
/**
|
||||
* "My dashboards" matches on the signed-in address, so the rows have to be
|
||||
* created by the same user the providers mount.
|
||||
*/
|
||||
export const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user
|
||||
.email;
|
||||
|
||||
const TEAMMATE_EMAILS = [
|
||||
'ada@signoz.io',
|
||||
'grace@signoz.io',
|
||||
'linus@signoz.io',
|
||||
] as const;
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
const ago = (hours: number): string =>
|
||||
new Date(Date.now() - hours * HOUR).toISOString();
|
||||
|
||||
const tag = (key: string, value: string): TagtypesGettableTagDTO => ({
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
interface DashboardSeed {
|
||||
name: string;
|
||||
icon: string;
|
||||
tags: TagtypesGettableTagDTO[];
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
const SEEDS: DashboardSeed[] = [
|
||||
{
|
||||
name: 'Kubernetes cluster health',
|
||||
icon: 'circus-tent',
|
||||
tags: [tag('env', 'prod'), tag('team', 'platform')],
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
name: 'API latency and errors',
|
||||
icon: 'siren',
|
||||
tags: [tag('env', 'prod'), tag('team', 'api')],
|
||||
},
|
||||
{
|
||||
name: 'Checkout funnel',
|
||||
icon: 'bagel',
|
||||
tags: [tag('team', 'growth')],
|
||||
},
|
||||
{
|
||||
name: 'Postgres connections',
|
||||
icon: 'cheese',
|
||||
tags: [tag('env', 'prod'), tag('component', 'database')],
|
||||
},
|
||||
{
|
||||
name: 'Kafka consumer lag',
|
||||
icon: 'drum',
|
||||
tags: [tag('team', 'platform'), tag('component', 'kafka')],
|
||||
},
|
||||
{
|
||||
name: 'Nginx ingress overview',
|
||||
icon: 'crane',
|
||||
tags: [tag('env', 'staging')],
|
||||
},
|
||||
{
|
||||
name: 'Billing jobs',
|
||||
icon: 'dartboard',
|
||||
tags: [tag('team', 'billing')],
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
name: 'Frontend web vitals',
|
||||
icon: 'basketball',
|
||||
tags: [tag('team', 'frontend')],
|
||||
},
|
||||
{
|
||||
name: 'Redis cache hit ratio',
|
||||
icon: 'cookie',
|
||||
tags: [tag('component', 'redis')],
|
||||
},
|
||||
{
|
||||
name: 'Collector pipeline throughput',
|
||||
icon: 'motorcycle',
|
||||
tags: [tag('env', 'prod'), tag('component', 'otel')],
|
||||
},
|
||||
{
|
||||
name: 'On-call triage board',
|
||||
icon: 'police-car',
|
||||
tags: [tag('team', 'sre')],
|
||||
},
|
||||
{
|
||||
name: 'Cost per service',
|
||||
icon: 'orange',
|
||||
tags: [tag('team', 'finops')],
|
||||
},
|
||||
];
|
||||
|
||||
/** Row markers a story can put on the list, each landing on a slice of the rows. */
|
||||
export const ROW_MARKERS = ['pinned', 'locked', 'legacy'] as const;
|
||||
|
||||
export type RowMarker = (typeof ROW_MARKERS)[number];
|
||||
|
||||
export const dashboardIdAt = (index: number): string =>
|
||||
`storybook-dashboard-${index + 1}`;
|
||||
|
||||
/**
|
||||
* Pins are per-user state the endpoint owns, and the page writes them: keeping
|
||||
* them here is what lets the pin button stick instead of being answered away by
|
||||
* the next list fetch.
|
||||
*/
|
||||
const pinned = new Set<string>();
|
||||
|
||||
export const seedPinnedDashboards = (ids: readonly string[]): void => {
|
||||
pinned.clear();
|
||||
ids.forEach((id) => pinned.add(id));
|
||||
};
|
||||
|
||||
export const setDashboardPinned = (id: string, isPinned: boolean): void => {
|
||||
if (isPinned) {
|
||||
pinned.add(id);
|
||||
} else {
|
||||
pinned.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
interface ListArgs {
|
||||
count: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
markers: readonly RowMarker[];
|
||||
query: string;
|
||||
}
|
||||
|
||||
const seedAt = (index: number): DashboardSeed => SEEDS[index % SEEDS.length];
|
||||
|
||||
const nameAt = (index: number): string => {
|
||||
const seed = seedAt(index);
|
||||
const round = Math.floor(index / SEEDS.length);
|
||||
|
||||
return round === 0 ? seed.name : `${seed.name} (${round + 1})`;
|
||||
};
|
||||
|
||||
const dashboardAt = (
|
||||
index: number,
|
||||
markers: readonly RowMarker[],
|
||||
): DashboardtypesListedDashboardForUserV2DTO => {
|
||||
const seed = seedAt(index);
|
||||
const name = nameAt(index);
|
||||
const mine = index % 3 === 0;
|
||||
|
||||
return {
|
||||
id: dashboardIdAt(index),
|
||||
orgId: 'storybook-org',
|
||||
name,
|
||||
spec: { display: { name } },
|
||||
image: `/assets/Icons/${seed.icon}`,
|
||||
schemaVersion: 'v2',
|
||||
source: DashboardtypesSourceDTO.user,
|
||||
pinned: pinned.has(dashboardIdAt(index)),
|
||||
locked: markers.includes('locked') && !!seed.locked,
|
||||
legacy: markers.includes('legacy') && index % 7 === 4,
|
||||
tags: seed.tags,
|
||||
createdBy: mine
|
||||
? STORY_USER_EMAIL
|
||||
: TEAMMATE_EMAILS[index % TEAMMATE_EMAILS.length],
|
||||
updatedBy: TEAMMATE_EMAILS[(index + 1) % TEAMMATE_EMAILS.length],
|
||||
createdAt: ago(24 * (index + 3)),
|
||||
updatedAt: ago(index * 5 + 1),
|
||||
};
|
||||
};
|
||||
|
||||
/** `key OP value`, the only term shape the mock evaluates. */
|
||||
const TERM = /(\w+)\s*(=|!=|CONTAINS|IN)\s*(\[[^\]]*\]|'[^']*'|true|false)/gi;
|
||||
|
||||
const quoted = (raw: string): string[] =>
|
||||
Array.from(raw.matchAll(/'([^']*)'/g), (match) => match[1]);
|
||||
|
||||
const matchesTerm = (
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
key: string,
|
||||
operator: string,
|
||||
value: string,
|
||||
): boolean => {
|
||||
const values = quoted(value);
|
||||
const [first = ''] = values;
|
||||
|
||||
switch (key.toLowerCase()) {
|
||||
case 'locked':
|
||||
return dashboard.locked === (value.toLowerCase() === 'true');
|
||||
case 'created_by':
|
||||
return values.includes(dashboard.createdBy ?? '');
|
||||
case 'updated_by':
|
||||
return values.includes(dashboard.updatedBy ?? '');
|
||||
case 'name':
|
||||
return operator.toUpperCase() === 'CONTAINS'
|
||||
? dashboard.name.toLowerCase().includes(first.toLowerCase())
|
||||
: dashboard.name === first;
|
||||
case 'created_at':
|
||||
case 'updated_at':
|
||||
case 'description':
|
||||
return true;
|
||||
default:
|
||||
return dashboard.tags.some((t) => t.key === key && values.includes(t.value));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The AND-joined subset of the list DSL the built-in views, the saved views and
|
||||
* the Created-by dropdown emit. A term the mock cannot read is treated as
|
||||
* matching, so an unsupported query answers with the unfiltered page rather than
|
||||
* an empty one.
|
||||
*/
|
||||
const matchesQuery = (
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
query: string,
|
||||
): boolean =>
|
||||
Array.from(query.matchAll(TERM)).every(([, key, operator, value]) =>
|
||||
matchesTerm(dashboard, key, operator, value),
|
||||
);
|
||||
|
||||
export const dashboardsListResponse = ({
|
||||
count,
|
||||
offset,
|
||||
limit,
|
||||
markers,
|
||||
query,
|
||||
}: ListArgs): ListDashboardsForUserV2200 => {
|
||||
const all = Array.from({ length: count }, (_, index) =>
|
||||
dashboardAt(index, markers),
|
||||
);
|
||||
const matched = query ? all.filter((d) => matchesQuery(d, query)) : all;
|
||||
|
||||
// Pins float to the top of the requested ordering, server-side.
|
||||
matched.sort((a, b) => Number(b.pinned) - Number(a.pinned));
|
||||
|
||||
const tags = matched.flatMap((dashboard) => dashboard.tags);
|
||||
const uniqueTags = Array.from(
|
||||
new Map(tags.map((t) => [`${t.key}:${t.value}`, t])).values(),
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
total: matched.length,
|
||||
reservedKeywords: [
|
||||
'name',
|
||||
'description',
|
||||
'created_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'locked',
|
||||
],
|
||||
tags: uniqueTags,
|
||||
dashboards: matched.slice(offset, offset + limit),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const SAVED_VIEW_SEEDS = [
|
||||
{ name: 'Production dashboards', query: "env = 'prod'" },
|
||||
{ name: 'Platform team', query: "team = 'platform'" },
|
||||
{ name: 'Locked dashboards', query: 'locked = true' },
|
||||
{ name: 'Database dashboards', query: "component = 'database'" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A saved view as the rail addresses it. Selecting one applies its query, so a
|
||||
* story that opens on a saved view has to seed the route with both.
|
||||
*/
|
||||
export const savedView = (index: number): { id: string; query: string } => ({
|
||||
id: `storybook-view-${index + 1}`,
|
||||
query: SAVED_VIEW_SEEDS[index % SAVED_VIEW_SEEDS.length].query,
|
||||
});
|
||||
|
||||
export const dashboardViewsResponse = (
|
||||
count: number,
|
||||
): ListDashboardViews200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
views: Array.from({ length: count }, (_, index) => {
|
||||
const seed = SAVED_VIEW_SEEDS[index % SAVED_VIEW_SEEDS.length];
|
||||
|
||||
return {
|
||||
id: savedView(index).id,
|
||||
orgId: 'storybook-org',
|
||||
name: index < SAVED_VIEW_SEEDS.length ? seed.name : `${seed.name} ${index}`,
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: seed.query,
|
||||
sort: DashboardtypesListSortDTO.updated_at,
|
||||
order: DashboardtypesListOrderDTO.desc,
|
||||
},
|
||||
createdAt: ago(24 * (index + 1)),
|
||||
updatedAt: ago(index + 1),
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
/** The org's users, which is where the Created-by dropdown gets its options. */
|
||||
export const orgUsersResponse = (): ListUsers200 => ({
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
id: 'storybook-user-me',
|
||||
email: STORY_USER_EMAIL,
|
||||
displayName: 'John Doe',
|
||||
},
|
||||
...TEAMMATE_EMAILS.map((email, index) => ({
|
||||
id: `storybook-user-${index + 1}`,
|
||||
email,
|
||||
displayName: email.split('@')[0].replace(/^./, (c) => c.toUpperCase()),
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
/** Ids the Recently-viewed rail entry reads out of local state. */
|
||||
export const recentDashboardIds = (count: number): string[] =>
|
||||
Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `storybook-dashboard-${index + 4}`,
|
||||
);
|
||||
122
frontend/src/pages/EditRules/EditRules.stories.mocks.tsx
generated
Normal file
122
frontend/src/pages/EditRules/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/__story_mockdata__/alertQuery';
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
channelsResponse,
|
||||
CHANNEL_MAX,
|
||||
type AlertSchema,
|
||||
} from '../AlertList/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
|
||||
const RULE = 'Edit rule · rule';
|
||||
|
||||
export const editRulesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`classic` is the single-form page this route was built for. `v2` throws: the new form reads `CreateAlertProvider`, which only `pages/AlertDetails` mounts, so this route crashes on any rule saved on the current schema.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'classic',
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: RULE,
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
previewSeries: countControl('Preview series', {
|
||||
group: RULE,
|
||||
description: 'Lines the chart above the condition draws.',
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id',
|
||||
response.json((req) =>
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: 'critical',
|
||||
state: 'firing',
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
alertFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `${ROUTES.EDIT_ALERTS}?ruleId=${STORY_RULE_ID}&relativeTime=6h`,
|
||||
}),
|
||||
});
|
||||
44
frontend/src/pages/EditRules/EditRules.stories.tsx
Normal file
44
frontend/src/pages/EditRules/EditRules.stories.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { editRulesMocks } from './EditRules.stories.mocks';
|
||||
|
||||
import EditRules from './index';
|
||||
|
||||
type EditRulesArgs = PageStoryArgs<typeof editRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(editRulesMocks);
|
||||
|
||||
/**
|
||||
* An existing rule in the builder that created it, loaded from
|
||||
* `/api/v2/rules/:id`.
|
||||
*
|
||||
* Route: `/alerts/edit?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Edit',
|
||||
component: EditRules,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<EditRulesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<EditRulesArgs>;
|
||||
|
||||
/**
|
||||
* The alert form on its own route, without the alert-details tabs around it.
|
||||
* Metrics Explorer and the assistant link here when they send someone to a rule.
|
||||
* The rule is on the classic schema, which is the only one this route renders:
|
||||
* see the Alert schema control for what a current-schema rule does here.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The rule id in the URL does not resolve, so the page offers the way back. */
|
||||
export const RuleNotFound: Story = {
|
||||
args: { dataState: 'error' },
|
||||
};
|
||||
17
frontend/src/pages/ErrorBoundaryFallback/ErrorBoundaryFallback.stories.mocks.tsx
generated
Normal file
17
frontend/src/pages/ErrorBoundaryFallback/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,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import ErrorBoundaryFallback from './ErrorBoundaryFallback';
|
||||
import { errorBoundaryFallbackMocks } from './ErrorBoundaryFallback.stories.mocks';
|
||||
|
||||
type ErrorBoundaryFallbackArgs = PageStoryArgs<
|
||||
typeof errorBoundaryFallbackMocks
|
||||
>;
|
||||
|
||||
const pageStory = storyMocks(errorBoundaryFallbackMocks);
|
||||
|
||||
/**
|
||||
* What a render error leaves on screen: the error boundary's own page, with
|
||||
* nothing fetching behind it.
|
||||
*
|
||||
* Route: `/something-went-wrong`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/System/Error Fallback',
|
||||
component: ErrorBoundaryFallback,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ErrorBoundaryFallbackArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ErrorBoundaryFallbackArgs>;
|
||||
|
||||
/** What a page that threw is replaced with, anywhere in the app. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The self-hosted spelling, where support is the community rather than chat. */
|
||||
export const SelfHosted: Story = {
|
||||
args: { license: 'enterprise' },
|
||||
};
|
||||
100
frontend/src/pages/ErrorDetails/ErrorDetails.stories.mocks.tsx
generated
Normal file
100
frontend/src/pages/ErrorDetails/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)}`,
|
||||
}),
|
||||
});
|
||||
58
frontend/src/pages/ErrorDetails/ErrorDetails.stories.tsx
Normal file
58
frontend/src/pages/ErrorDetails/ErrorDetails.stories.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { errorDetailsMocks } from './ErrorDetails.stories.mocks';
|
||||
import ErrorDetails from './index';
|
||||
|
||||
type ErrorDetailsArgs = PageStoryArgs<typeof errorDetailsMocks>;
|
||||
|
||||
const pageStory = storyMocks(errorDetailsMocks);
|
||||
|
||||
/**
|
||||
* One exception: its stack trace rendered for the language it came from, the span
|
||||
* it belongs to, and the occurrence before and after it.
|
||||
*
|
||||
* Route: `/error-detail?...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Exceptions/Detail',
|
||||
component: ErrorDetails,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ErrorDetailsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ErrorDetailsArgs>;
|
||||
|
||||
/**
|
||||
* One exception event as the list opens it: the type and message it was grouped
|
||||
* under, when it was seen, its stack trace, and the span and trace it came from.
|
||||
* Older and Newer walk the group's other events in place.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A link to an event the backend no longer has, which is what a bookmarked
|
||||
* exception past its retention window opens.
|
||||
*/
|
||||
export const EventMissing: Story = {
|
||||
args: { found: false },
|
||||
};
|
||||
|
||||
/** The page mid-fetch, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The page without a timestamp to look the event up by, which is the link the
|
||||
* page refuses and sends back to the exceptions list.
|
||||
*/
|
||||
export const IncompleteLink: Story = {
|
||||
args: { params: 'no-timestamp' },
|
||||
};
|
||||
232
frontend/src/pages/ErrorDetails/__story_mockdata__/errorDetails.ts
generated
Normal file
232
frontend/src/pages/ErrorDetails/__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/__story_mockdata__/exceptions';
|
||||
import type { ExceptionShape } from 'pages/AllErrors/__story_mockdata__/exceptions';
|
||||
import type { PayloadProps as ErrorEvent } from 'types/api/errors/getByErrorTypeAndService';
|
||||
import type { PayloadProps as NextPrevIds } from 'types/api/errors/getNextPrevId';
|
||||
|
||||
export const EXCEPTION_LANGUAGES = ['go', 'python', 'java'] as const;
|
||||
|
||||
export type ExceptionLanguage = (typeof EXCEPTION_LANGUAGES)[number];
|
||||
|
||||
export const NEIGHBOUR_STATES = [
|
||||
'surrounded',
|
||||
'oldest',
|
||||
'newest',
|
||||
'only-event',
|
||||
] as const;
|
||||
|
||||
export type NeighbourState = (typeof NEIGHBOUR_STATES)[number];
|
||||
|
||||
/** The three parameter sets the page distinguishes, in the order it reads them. */
|
||||
export const DETAIL_PARAMS = ['group', 'event', 'no-timestamp'] as const;
|
||||
|
||||
export type DetailParams = (typeof DETAIL_PARAMS)[number];
|
||||
|
||||
/**
|
||||
* The three events the group holds, oldest first. Both lookups key on the
|
||||
* timestamp, so walking with Older and Newer, which is the page rewriting the
|
||||
* `timestamp` param, is what moves between them.
|
||||
*/
|
||||
const WALK = ['older', 'event', 'newer'] as const;
|
||||
|
||||
type Step = (typeof WALK)[number];
|
||||
|
||||
/** RFC 3339 as the payload carries it, and the nanoseconds the URL carries. */
|
||||
const STEP_TIMESTAMPS: Record<Step, { at: string; nanos: string }> = {
|
||||
older: { at: '2025-04-14T18:11:04.113904221Z', nanos: '1744654264113904221' },
|
||||
event: { at: '2025-04-14T18:27:57.797616374Z', nanos: '1744655277797616374' },
|
||||
newer: { at: '2025-04-14T18:44:12.550118903Z', nanos: '1744656252550118903' },
|
||||
};
|
||||
|
||||
const stepAt = (nanos: string | null): Step =>
|
||||
WALK.find((step) => STEP_TIMESTAMPS[step].nanos === nanos) ?? 'event';
|
||||
|
||||
const eventIdAt = (step: Step, errorId: string): string =>
|
||||
step === 'event' ? errorId : `${step}-${errorId}`;
|
||||
|
||||
const STACK_TRACES: Record<ExceptionLanguage, string> = {
|
||||
go: `*errors.errorString: redis timeout
|
||||
goroutine 51 [running]:
|
||||
github.com/signoz/sample/pkg/cache.(*Client).Get(0xc000112a80, {0x1096f40, 0xc0001a2000}, {0xf8a3c1, 0x11})
|
||||
\t/app/pkg/cache/client.go:118 +0x2c5
|
||||
github.com/signoz/sample/pkg/orders.(*Service).Lookup(0xc0000b8060, {0x1096f40, 0xc0001a2000}, 0x1f4)
|
||||
\t/app/pkg/orders/service.go:64 +0x11f
|
||||
github.com/signoz/sample/internal/http.(*Handler).ServeHTTP(0xc0000ba018, {0x1094e80, 0xc00019c0e0}, 0xc0001c4000)
|
||||
\t/app/internal/http/handler.go:41 +0x1a8
|
||||
net/http.serverHandler.ServeHTTP({0xc00018e000}, {0x1094e80, 0xc00019c0e0}, 0xc0001c4000)
|
||||
\t/usr/local/go/src/net/http/server.go:2938 +0x8e`,
|
||||
python: `Traceback (most recent call last):
|
||||
File "/usr/local/lib/python3.11/site-packages/urllib3/connectionpool.py", line 715, in urlopen
|
||||
httplib_response = self._make_request(
|
||||
File "/usr/local/lib/python3.11/site-packages/urllib3/connectionpool.py", line 467, in _make_request
|
||||
raise ReadTimeoutError(self, url, "Read timed out")
|
||||
urllib3.exceptions.ReadTimeoutError: HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/app/checkout/views.py", line 82, in place_order
|
||||
response = session.post(f"{PAYMENTS_URL}/charge", json=payload, timeout=2)
|
||||
File "/usr/local/lib/python3.11/site-packages/requests/sessions.py", line 637, in post
|
||||
return self.request("POST", url, data=data, json=json, **kwargs)
|
||||
requests.exceptions.ConnectionError: HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)`,
|
||||
java: `java.net.SocketTimeoutException: Read timed out
|
||||
\tat java.base/java.net.SocketInputStream.socketRead0(Native Method)
|
||||
\tat java.base/java.net.SocketInputStream.read(SocketInputStream.java:168)
|
||||
\tat okhttp3.internal.http1.Http1ExchangeCodec.readResponseHeaders(Http1ExchangeCodec.kt:180)
|
||||
\tat okhttp3.internal.connection.Exchange.readResponseHeaders(Exchange.kt:106)
|
||||
\tat io.signoz.sample.payment.GatewayClient.authorize(GatewayClient.java:74)
|
||||
\tat io.signoz.sample.payment.PaymentService.charge(PaymentService.java:112)
|
||||
\tat io.signoz.sample.payment.PaymentController.pay(PaymentController.java:48)
|
||||
\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)`,
|
||||
};
|
||||
|
||||
interface EventShape {
|
||||
group: ExceptionShape;
|
||||
errorId: string;
|
||||
spanID: string;
|
||||
traceID: string;
|
||||
}
|
||||
|
||||
const groupOf = (exceptionType: string): ExceptionShape => {
|
||||
const found = EXCEPTION_CATALOGUE.find(
|
||||
(exception) => exception.exceptionType === exceptionType,
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new Error(`no exception group for ${exceptionType} in the catalogue`);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const EVENTS: Record<ExceptionLanguage, EventShape> = {
|
||||
go: {
|
||||
group: groupOf('*errors.errorString'),
|
||||
errorId: '7c1ba0d4e6f84a1f9d2b3c4e5f607182',
|
||||
spanID: '3a5c7e9b1d0f2468',
|
||||
traceID: 'd41f8c2a6b3e5079a1c4e6b8d0f2a468',
|
||||
},
|
||||
python: {
|
||||
group: groupOf('ConnectionError'),
|
||||
errorId: 'b2d4f60819a3c5e7092b4d6f8a0c1e35',
|
||||
spanID: '9f1d3b5a7c9e0246',
|
||||
traceID: '5e7a9c1b3d5f7911a3c5e7b9d1f30517',
|
||||
},
|
||||
java: {
|
||||
group: groupOf('java.net.SocketTimeoutException'),
|
||||
errorId: 'e6c8a0f2d4b60819273a5c7e9b1d3f50',
|
||||
spanID: '2c4e6a8b0d2f4618',
|
||||
traceID: 'a0c2e4b6d8f01325476a8c0e2b4d6f81',
|
||||
},
|
||||
};
|
||||
|
||||
/** The parameters the list links with, and the ones Older and Newer add to them. */
|
||||
export const errorDetailsSearch = (
|
||||
language: ExceptionLanguage,
|
||||
params: DetailParams,
|
||||
): string => {
|
||||
const { group, errorId } = EVENTS[language];
|
||||
const search = new URLSearchParams({ groupId: group.groupID });
|
||||
|
||||
if (params === 'no-timestamp') {
|
||||
return search.toString();
|
||||
}
|
||||
|
||||
search.set('timestamp', STEP_TIMESTAMPS.event.nanos);
|
||||
|
||||
if (params === 'event') {
|
||||
search.set('errorId', errorId);
|
||||
}
|
||||
|
||||
return search.toString();
|
||||
};
|
||||
|
||||
export interface EventLookup {
|
||||
timestamp: string | null;
|
||||
errorId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/errorFromGroupID` and `/errorFromErrorID` both answer with the event itself,
|
||||
* unwrapped: the api module hands the whole body through as the payload.
|
||||
*/
|
||||
export const errorEvent = (
|
||||
language: ExceptionLanguage,
|
||||
lookup: EventLookup,
|
||||
): ErrorEvent => {
|
||||
const event = EVENTS[language];
|
||||
const step = stepAt(lookup.timestamp);
|
||||
|
||||
return {
|
||||
errorId: lookup.errorId || eventIdAt(step, event.errorId),
|
||||
exceptionType: event.group.exceptionType,
|
||||
exceptionMessage: event.group.exceptionMessage,
|
||||
exceptionStacktrace: STACK_TRACES[language],
|
||||
exceptionEscaped: 'false',
|
||||
timestamp: STEP_TIMESTAMPS[step].at,
|
||||
spanID: event.spanID,
|
||||
traceID: event.traceID,
|
||||
serviceName: event.group.serviceName,
|
||||
groupID: event.group.groupID,
|
||||
};
|
||||
};
|
||||
|
||||
const neighbourSteps = (
|
||||
step: Step,
|
||||
state: NeighbourState,
|
||||
): { older: Step | null; newer: Step | null } => {
|
||||
if (step === 'older') {
|
||||
return { older: null, newer: 'event' };
|
||||
}
|
||||
|
||||
if (step === 'newer') {
|
||||
return { older: 'event', newer: null };
|
||||
}
|
||||
|
||||
return {
|
||||
older: state === 'surrounded' || state === 'newest' ? 'older' : null,
|
||||
newer: state === 'surrounded' || state === 'oldest' ? 'newer' : null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* An empty id is how the backend says there is nothing further in that
|
||||
* direction, and it is what the Older and Newer buttons read to disable
|
||||
* themselves. Which end of the walk the page is on comes from the timestamp it
|
||||
* asks with, so a step in either direction lands somewhere it can step back
|
||||
* from.
|
||||
*/
|
||||
export const errorNeighbours = (
|
||||
language: ExceptionLanguage,
|
||||
state: NeighbourState,
|
||||
timestamp: string | null,
|
||||
): NextPrevIds => {
|
||||
const event = EVENTS[language];
|
||||
const { older, newer } = neighbourSteps(stepAt(timestamp), state);
|
||||
|
||||
return {
|
||||
groupID: event.group.groupID,
|
||||
prevErrorID: older ? eventIdAt(older, event.errorId) : '',
|
||||
prevTimestamp: older ? STEP_TIMESTAMPS[older].at : '',
|
||||
nextErrorID: newer ? eventIdAt(newer, event.errorId) : '',
|
||||
nextTimestamp: newer ? STEP_TIMESTAMPS[newer].at : '',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What the backend answers when no event matches the group and timestamp: a 404
|
||||
* carrying its own envelope, which `ErrorResponseHandler` reduces to the
|
||||
* `errorType` the page prints. A `null` body instead of this reaches the page as
|
||||
* a TypeError, so the `idPayload === null` branch it guards for is unreachable.
|
||||
*/
|
||||
export const ERROR_EVENT_NOT_FOUND = {
|
||||
status: 'error',
|
||||
errorType: 'not_found',
|
||||
error: 'Error/Exception not found',
|
||||
data: null,
|
||||
};
|
||||
67
frontend/src/pages/ForgotPassword/ForgotPassword.stories.mocks.tsx
generated
Normal file
67
frontend/src/pages/ForgotPassword/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 },
|
||||
}),
|
||||
});
|
||||
66
frontend/src/pages/ForgotPassword/ForgotPassword.stories.tsx
Normal file
66
frontend/src/pages/ForgotPassword/ForgotPassword.stories.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import ForgotPassword from './index';
|
||||
import { forgotPasswordMocks } from './ForgotPassword.stories.mocks';
|
||||
|
||||
type ForgotPasswordArgs = PageStoryArgs<typeof forgotPasswordMocks>;
|
||||
|
||||
const pageStory = storyMocks(forgotPasswordMocks);
|
||||
|
||||
/**
|
||||
* The reset request form, before any session exists.
|
||||
*
|
||||
* Route: `/forgot-password`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Auth/Forgot Password',
|
||||
tags: ['play'],
|
||||
component: ForgotPassword,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ForgotPasswordArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ForgotPasswordArgs>;
|
||||
|
||||
const submit = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole('button', {
|
||||
name: /send reset link/i,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** The email the login form carried over, ready to have a reset link sent to it. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** An email in three workspaces: the reset is scoped to one, so it has to be picked. */
|
||||
export const MultipleOrganizations: Story = {
|
||||
args: { orgs: 3 },
|
||||
};
|
||||
|
||||
/** The link sent: what the page shows instead of the form. */
|
||||
export const LinkSent: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await submit(canvasElement);
|
||||
await within(canvasElement).findByRole('heading', {
|
||||
name: /check your email/i,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Asked once too often, which the backend rate-limits. */
|
||||
export const RateLimited: Story = {
|
||||
args: { request: 'rejected' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await submit(canvasElement);
|
||||
await within(canvasElement).findByText(/rate_limited/i);
|
||||
},
|
||||
};
|
||||
33
frontend/src/pages/ForgotPassword/__story_mockdata__/forgotPassword.ts
generated
Normal file
33
frontend/src/pages/ForgotPassword/__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),
|
||||
});
|
||||
200
frontend/src/pages/HomePage/HomePage.stories.mocks.tsx
generated
Normal file
200
frontend/src/pages/HomePage/HomePage.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
countControl,
|
||||
choiceControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
buildAlertRules,
|
||||
buildServices,
|
||||
homeFeatureFlags,
|
||||
homeUserPreferences,
|
||||
HOME_CHECKLIST_STEPS,
|
||||
isSavedViewSignal,
|
||||
metricsOnboardingResponse,
|
||||
queryRangeV5ScalarResponse,
|
||||
recentDashboardsResponse,
|
||||
SAVED_VIEW_SIGNALS,
|
||||
type SavedViewSignal,
|
||||
savedViewsResponse,
|
||||
SERVICES_SOURCES,
|
||||
type ServicesSource,
|
||||
spanMetricsResponse,
|
||||
topLevelOperationsResponse,
|
||||
} from './__story_mockdata__/home';
|
||||
|
||||
const SIGNALS = 'Home · signals';
|
||||
const ONBOARDING = 'Home · onboarding';
|
||||
const LISTS = 'Home · lists';
|
||||
|
||||
const INGESTED_COUNT = 4213;
|
||||
|
||||
/** Home caps every list at five rows, so the control has to go past that. */
|
||||
const LIST_MAX = 8;
|
||||
|
||||
const CHECKLIST_VISIBILITY = ['visible', 'dismissed'] as const;
|
||||
|
||||
type ChecklistVisibility = (typeof CHECKLIST_VISIBILITY)[number];
|
||||
|
||||
interface QueryRangeV5Body {
|
||||
compositeQuery?: { queries?: { spec?: { signal?: string } }[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Home detects logs and traces with one `query_range` call each, told apart by
|
||||
* the signal on the query spec.
|
||||
*/
|
||||
const signalOf = (body: QueryRangeV5Body): string | undefined =>
|
||||
body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
export const homeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
logsIngestion: toggleControl('Logs ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
tracesIngestion: toggleControl('Traces ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
metricsIngestion: toggleControl('Metrics ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
|
||||
group: ONBOARDING,
|
||||
description:
|
||||
'Dismissing it moves the checklist behind the header button, as "I\'ll do this later" does.',
|
||||
options: CHECKLIST_VISIBILITY,
|
||||
value: 'visible',
|
||||
}),
|
||||
skippedSteps: multiChoiceControl('Skipped steps', {
|
||||
group: ONBOARDING,
|
||||
description: 'Steps the user chose to skip. Completion follows the data.',
|
||||
options: HOME_CHECKLIST_STEPS,
|
||||
value: [],
|
||||
}),
|
||||
alertRules: countControl('Alert rules', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
dashboards: countControl('Recent dashboards', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViews: countControl('Saved views per signal', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViewSignals: multiChoiceControl<SavedViewSignal>('Signals with views', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'Explorer tabs that have views; the rest fall back to their empty state.',
|
||||
options: SAVED_VIEW_SIGNALS,
|
||||
value: SAVED_VIEW_SIGNALS,
|
||||
}),
|
||||
services: countControl('Services', {
|
||||
group: LISTS,
|
||||
value: 6,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
servicesSource: choiceControl<ServicesSource>('Services source', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'`span-metrics` turns on the feature flag that swaps the services card for the span-metrics one.',
|
||||
options: SERVICES_SOURCES,
|
||||
value: 'traces',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get('http://localhost/api/v2/metrics/onboarding', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(metricsOnboardingResponse(values.metricsIngestion)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
|
||||
const signal = signalOf((await req.json()) as QueryRangeV5Body);
|
||||
|
||||
const isActive =
|
||||
signal === 'traces' ? values.tracesIngestion : values.logsIngestion;
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(queryRangeV5ScalarResponse(isActive ? INGESTED_COUNT : 0)),
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: homeUserPreferences({
|
||||
checklistDismissed: values.welcomeChecklist === 'dismissed',
|
||||
skippedSteps: values.skippedSteps,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => recentDashboardsResponse(values.dashboards)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) => {
|
||||
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
|
||||
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
|
||||
|
||||
return savedViewsResponse(
|
||||
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
|
||||
signal,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildServices(values.services),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/service/top_level_operations',
|
||||
response.json(() => topLevelOperationsResponse(values.services)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v4/query_range',
|
||||
response.json(() => spanMetricsResponse()),
|
||||
),
|
||||
],
|
||||
config: ({ servicesSource }) => ({
|
||||
appContext: { featureFlags: homeFeatureFlags(servicesSource) },
|
||||
}),
|
||||
});
|
||||
66
frontend/src/pages/HomePage/HomePage.stories.tsx
Normal file
66
frontend/src/pages/HomePage/HomePage.stories.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import { homeMocks } from './HomePage.stories.mocks';
|
||||
|
||||
import HomePage from './HomePage';
|
||||
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const pageStory = storyMocks(homeMocks, { route: ROUTES.HOME });
|
||||
|
||||
/**
|
||||
* The workspace landing page: ingestion state per signal, the welcome checklist
|
||||
* while a signal is missing, then alert rules, dashboards, saved views and the
|
||||
* services table.
|
||||
*
|
||||
* Route: `/home`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
tags: ['role-gated'],
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<HomeArgs>;
|
||||
|
||||
/**
|
||||
* Every widget carrying data: all three signals ingesting, alert rules across
|
||||
* severities, recent dashboards, saved views on each explorer tab and a
|
||||
* services table with failing services.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Fresh workspace: nothing ingested yet, so the welcome checklist takes over. */
|
||||
export const NoIngestion: Story = {
|
||||
args: {
|
||||
logsIngestion: false,
|
||||
tracesIngestion: false,
|
||||
metricsIngestion: false,
|
||||
alertRules: 0,
|
||||
dashboards: 0,
|
||||
savedViews: 0,
|
||||
services: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Telemetry reads only: no permission to manage anything, so the create actions
|
||||
* and the legacy editor role are both gone.
|
||||
*/
|
||||
export const ViewerAccess: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** Widgets stuck in their loading state, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
316
frontend/src/pages/HomePage/__story_mockdata__/home.ts
generated
Normal file
316
frontend/src/pages/HomePage/__story_mockdata__/home.ts
generated
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
|
||||
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import type { ServicesList } from 'types/api/metrics/getService';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
import { baseUserPreferences } from '@/storybook/msw/__story_mockdata__/appShell';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
export { queryRangeV5ScalarResponse };
|
||||
|
||||
/** Shapes follow the fields the components read, not the full generated DTOs. */
|
||||
|
||||
export const metricsOnboardingResponse = (
|
||||
hasMetrics: boolean,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: { hasMetrics },
|
||||
});
|
||||
|
||||
export const HOME_CHECKLIST_STEPS = [
|
||||
'SEND_LOGS',
|
||||
'SEND_TRACES',
|
||||
'SEND_METRICS',
|
||||
'SETUP_ALERTS',
|
||||
'SETUP_SAVED_VIEWS',
|
||||
'SETUP_DASHBOARDS',
|
||||
] as const;
|
||||
|
||||
export type HomeChecklistStep = (typeof HOME_CHECKLIST_STEPS)[number];
|
||||
|
||||
const skippedPreference = (name: string): UserPreference => ({
|
||||
name,
|
||||
description: 'Welcome checklist step skipped',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The welcome checklist reads its dismissed and skipped flags off the user
|
||||
* preferences list, one entry per step.
|
||||
*/
|
||||
export const homeUserPreferences = ({
|
||||
checklistDismissed,
|
||||
skippedSteps,
|
||||
}: {
|
||||
checklistDismissed: boolean;
|
||||
skippedSteps: readonly HomeChecklistStep[];
|
||||
}): UserPreference[] => [
|
||||
...baseUserPreferences,
|
||||
...(checklistDismissed
|
||||
? [skippedPreference(ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER)]
|
||||
: []),
|
||||
...skippedSteps.map((step) =>
|
||||
skippedPreference(checkListStepToPreferenceKeyMap[step]),
|
||||
),
|
||||
];
|
||||
|
||||
const DASHBOARDS = [
|
||||
{
|
||||
name: 'Kubernetes cluster health',
|
||||
tags: [{ key: 'team', value: 'platform' }, { key: 'k8s' }],
|
||||
},
|
||||
{ name: 'API latency overview', tags: [{ key: 'sre' }] },
|
||||
{
|
||||
name: 'Checkout funnel',
|
||||
tags: [{ key: 'team', value: 'payments' }, { key: 'business' }],
|
||||
},
|
||||
{ name: 'Postgres slow queries', tags: [{ key: 'database' }] },
|
||||
{
|
||||
name: 'Kafka consumer lag',
|
||||
tags: [{ key: 'team', value: 'data' }, { key: 'streaming' }],
|
||||
},
|
||||
{ name: 'Ingress error budget', tags: [{ key: 'sre' }, { key: 'slo' }] },
|
||||
{ name: 'Cost per service', tags: [{ key: 'finops' }] },
|
||||
{
|
||||
name: 'Redis cache hit rate',
|
||||
tags: [{ key: 'database' }, { key: 'cache' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const recentDashboardsResponse = (
|
||||
count: number,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
dashboards: DASHBOARDS.slice(0, count).map((dashboard, index) => ({
|
||||
id: `storybook-dashboard-${index + 1}`,
|
||||
name: dashboard.name,
|
||||
spec: { display: { name: dashboard.name } },
|
||||
tags: dashboard.tags,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const ALERT_NAMES = [
|
||||
'Checkout p99 above 2s',
|
||||
'Payment failure rate',
|
||||
'Log volume spike',
|
||||
'Kafka consumer lag',
|
||||
'Pod restart storm',
|
||||
'Disk usage above 85%',
|
||||
'Frontend error rate',
|
||||
'Postgres connections saturated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cycles the jest fixtures so the list keeps their severity and firing spread.
|
||||
* `updatedAt` descends because that is the order the page sorts on.
|
||||
*/
|
||||
export const buildAlertRules = (count: number): RuletypesRuleDTO[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
...alertRulesFixture[index % alertRulesFixture.length],
|
||||
id: `storybook-rule-${index + 1}`,
|
||||
alert: ALERT_NAMES[index % ALERT_NAMES.length],
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 20 - index, 9)).toISOString(),
|
||||
}));
|
||||
|
||||
export const SAVED_VIEW_SIGNALS = ['logs', 'traces', 'metrics'] as const;
|
||||
|
||||
export type SavedViewSignal = (typeof SAVED_VIEW_SIGNALS)[number];
|
||||
|
||||
const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
|
||||
logs: [
|
||||
'Checkout errors',
|
||||
'Auth service warnings',
|
||||
'Slow SQL statements',
|
||||
'Payment webhooks',
|
||||
'Rate limited requests',
|
||||
'Cron job failures',
|
||||
],
|
||||
traces: [
|
||||
'Slowest checkout spans',
|
||||
'Failed payment traces',
|
||||
'Cart to order funnel',
|
||||
'External API calls',
|
||||
'Cold start requests',
|
||||
'Retried gRPC calls',
|
||||
],
|
||||
metrics: [
|
||||
'Pod memory by namespace',
|
||||
'Queue depth by topic',
|
||||
'HTTP throughput',
|
||||
'Container CPU throttling',
|
||||
'JVM heap usage',
|
||||
'Cache hit ratio',
|
||||
],
|
||||
};
|
||||
|
||||
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
|
||||
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
|
||||
|
||||
export const savedViewsResponse = (
|
||||
count: number,
|
||||
sourcePage: SavedViewSignal,
|
||||
): Record<string, unknown> => {
|
||||
const names = VIEW_NAMES[sourcePage];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
|
||||
...explorerView.data[0],
|
||||
id: `storybook-${sourcePage}-view-${index + 1}`,
|
||||
name: names[index],
|
||||
sourcePage,
|
||||
tags: [sourcePage],
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/** Ordered by p99 with errors at both ends, so any slice keeps the spread. */
|
||||
const SERVICES: ServicesList[] = [
|
||||
{
|
||||
serviceName: 'payments',
|
||||
p99: 2_940_100_000,
|
||||
avgDuration: 921_440_000,
|
||||
numCalls: 46_080,
|
||||
callRate: 25.6,
|
||||
numErrors: 5990,
|
||||
errorRate: 13,
|
||||
},
|
||||
{
|
||||
serviceName: 'checkout',
|
||||
p99: 1_248_900_000,
|
||||
avgDuration: 486_310_000,
|
||||
numCalls: 92_160,
|
||||
callRate: 51.2,
|
||||
numErrors: 4608,
|
||||
errorRate: 5,
|
||||
},
|
||||
{
|
||||
serviceName: 'frontend',
|
||||
p99: 812_450_000,
|
||||
avgDuration: 274_120_000,
|
||||
numCalls: 184_320,
|
||||
callRate: 102.4,
|
||||
numErrors: 1843,
|
||||
errorRate: 1,
|
||||
},
|
||||
{
|
||||
serviceName: 'shipping',
|
||||
p99: 486_200_000,
|
||||
avgDuration: 192_800_000,
|
||||
numCalls: 23_040,
|
||||
callRate: 12.8,
|
||||
numErrors: 691,
|
||||
errorRate: 3,
|
||||
},
|
||||
{
|
||||
serviceName: 'cart',
|
||||
p99: 214_800_000,
|
||||
avgDuration: 88_640_000,
|
||||
numCalls: 138_240,
|
||||
callRate: 76.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
{
|
||||
serviceName: 'catalogue',
|
||||
p99: 96_300_000,
|
||||
avgDuration: 41_220_000,
|
||||
numCalls: 276_480,
|
||||
callRate: 153.6,
|
||||
numErrors: 276,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'recommendations',
|
||||
p99: 64_100_000,
|
||||
avgDuration: 28_400_000,
|
||||
numCalls: 61_440,
|
||||
callRate: 34.1,
|
||||
numErrors: 61,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'notifications',
|
||||
p99: 38_700_000,
|
||||
avgDuration: 15_900_000,
|
||||
numCalls: 12_288,
|
||||
callRate: 6.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const buildServices = (count: number): ServicesList[] =>
|
||||
SERVICES.slice(0, count);
|
||||
|
||||
export const SERVICES_SOURCES = ['traces', 'span-metrics'] as const;
|
||||
|
||||
export type ServicesSource = (typeof SERVICES_SOURCES)[number];
|
||||
|
||||
/** `USE_SPAN_METRICS` swaps the services card for the span-metrics one. */
|
||||
export const homeFeatureFlags = (source: ServicesSource): FeatureFlagProps[] =>
|
||||
defaultFeatureFlags.map((flag) =>
|
||||
flag.name === FeatureKeys.USE_SPAN_METRICS
|
||||
? { ...flag, active: source === 'span-metrics' }
|
||||
: flag,
|
||||
);
|
||||
|
||||
/** The span-metrics card takes its row set from the top level operations. */
|
||||
export const topLevelOperationsResponse = (count: number): ServiceDataProps =>
|
||||
Object.fromEntries(
|
||||
buildServices(count).map((service) => [
|
||||
service.serviceName,
|
||||
['HTTP GET /', 'HTTP POST /checkout'],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Span-metrics latency, error rate and ops per second come off a table panel.
|
||||
* The page reads them from `newResult`, which the table branch of
|
||||
* `GetMetricQueryRange` never adds, so the columns render as zero however
|
||||
* complete this body is. That is the app's gap, not the mock's.
|
||||
*/
|
||||
export const spanMetricsResponse = (): {
|
||||
status: string;
|
||||
data: MetricRangePayloadV3['data'];
|
||||
} => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
resultType: 'table',
|
||||
result: [
|
||||
{
|
||||
queryName: '',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: {
|
||||
columns: [
|
||||
{ name: 'A', queryName: 'A', isValueColumn: true },
|
||||
{ name: 'D', queryName: 'D', isValueColumn: true },
|
||||
{ name: 'F1', queryName: 'F1', isValueColumn: true },
|
||||
],
|
||||
rows: [{ data: { A: 148_000_000, D: 12.4, F1: 1.8 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
466
frontend/src/pages/InfrastructureMonitoring/InfrastructureMonitoring.stories.mocks.tsx
generated
Normal file
466
frontend/src/pages/InfrastructureMonitoring/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,58 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { infraMonitoringMocks } from './InfrastructureMonitoring.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from './InfrastructureMonitoringPage';
|
||||
|
||||
type InfraArgs = PageStoryArgs<typeof infraMonitoringMocks>;
|
||||
|
||||
const pageStory = storyMocks(infraMonitoringMocks);
|
||||
|
||||
/**
|
||||
* The host list, and the way into the Kubernetes tabs: the table, its quick
|
||||
* filters and the entity drawer.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/hosts`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Overview',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<InfraArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<InfraArgs>;
|
||||
|
||||
/**
|
||||
* The Hosts tab with a full page of hosts: quick filters on the left, the status
|
||||
* toggle and group-by toolbar above the table, and an instrumentation checks
|
||||
* callout reporting an optional metric and a required attribute nobody is
|
||||
* sending yet. The Kubernetes tab is the other half of the module, one story per
|
||||
* resource under `Kubernetes`.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* Hosts grouped by one attribute: one expandable row per OS, with the active and
|
||||
* inactive counts of the hosts behind it.
|
||||
*/
|
||||
export const GroupedRows: Story = {
|
||||
args: { groupRows: true },
|
||||
};
|
||||
|
||||
/** Nothing reporting yet, which is what a fleet with no collector shows. */
|
||||
export const NoData: Story = {
|
||||
args: { rows: 0, checks: 'no-checks' },
|
||||
};
|
||||
|
||||
/** The table and the drawer widgets mid-load, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { clustersMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type ClustersArgs = PageStoryArgs<typeof clustersMocks>;
|
||||
|
||||
const pageStory = storyMocks(clustersMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes clusters tab: CPU, memory and pod counts per cluster, each row
|
||||
* opening the drawer.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=clusters`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Clusters',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ClustersArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Clusters with their node readiness and pod status counts, and CPU and memory
|
||||
* against the cluster's allocatable capacity. The drawer carries the counts cards
|
||||
* that jump to the nodes and pods of the cluster.
|
||||
*/
|
||||
export const Default: StoryObj<ClustersArgs> = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { daemonSetsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type DaemonSetsArgs = PageStoryArgs<typeof daemonSetsMocks>;
|
||||
|
||||
const pageStory = storyMocks(daemonSetsMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes daemon sets tab: desired against ready nodes, with CPU and
|
||||
* memory beside them.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=daemonsets`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/DaemonSets',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DaemonSetsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* DaemonSets by namespace: ready, current, desired and misscheduled nodes beside
|
||||
* the CPU and memory of the pods they run. The drawer adds the by-pod metrics
|
||||
* tab.
|
||||
*/
|
||||
export const Default: StoryObj<DaemonSetsArgs> = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { deploymentsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type DeploymentsArgs = PageStoryArgs<typeof deploymentsMocks>;
|
||||
|
||||
const pageStory = storyMocks(deploymentsMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes deployments tab: desired against available replicas, with CPU and
|
||||
* memory against request and limit.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=deployments`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Deployments',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DeploymentsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Deployments by namespace: available against desired pods, the replica count and
|
||||
* the pod status counts. The drawer adds the by-pod metrics tab, one series per
|
||||
* pod of the deployment.
|
||||
*/
|
||||
export const Default: StoryObj<DeploymentsArgs> = {};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { jobsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type JobsArgs = PageStoryArgs<typeof jobsMocks>;
|
||||
|
||||
const pageStory = storyMocks(jobsMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes jobs tab: active, succeeded and failed pods per job, and how long
|
||||
* each has been running.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=jobs`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Jobs',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<JobsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Jobs by namespace, with active, failed and successful pods against the
|
||||
* completions the job asked for. The drawer adds the by-pod metrics tab.
|
||||
*/
|
||||
export const Default: StoryObj<JobsArgs> = {};
|
||||
32
frontend/src/pages/InfrastructureMonitoring/Kubernetes/Kubernetes.stories.mocks.tsx
generated
Normal file
32
frontend/src/pages/InfrastructureMonitoring/Kubernetes/Kubernetes.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { infraStoryMocks } from '../InfrastructureMonitoring.stories.mocks';
|
||||
|
||||
/**
|
||||
* One mocks module per resource the Kubernetes tab lists. They all answer the
|
||||
* same endpoints; the entity they carry is what picks the list resource, the
|
||||
* checks type, the group-by attribute and the tabs the drawer offers, and it is
|
||||
* what puts `?category=` on the route the story starts on.
|
||||
*/
|
||||
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
|
||||
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
|
||||
export const namespacesMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
);
|
||||
export const clustersMocks = infraStoryMocks(InfraMonitoringEntity.CLUSTERS);
|
||||
export const deploymentsMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
);
|
||||
export const jobsMocks = infraStoryMocks(InfraMonitoringEntity.JOBS);
|
||||
export const daemonSetsMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.DAEMONSETS,
|
||||
);
|
||||
export const statefulSetsMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
);
|
||||
export const volumesMocks = infraStoryMocks(InfraMonitoringEntity.VOLUMES);
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { namespacesMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type NamespacesArgs = PageStoryArgs<typeof namespacesMocks>;
|
||||
|
||||
const pageStory = storyMocks(namespacesMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes namespaces tab: CPU and memory aggregated per namespace, each row
|
||||
* opening the drawer.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=namespaces`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Namespaces',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<NamespacesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Namespaces with the pod counts by status behind each one. The drawer carries
|
||||
* the counts cards that jump back to this list filtered, plus the by-pod metrics
|
||||
* tab.
|
||||
*/
|
||||
export const Default: StoryObj<NamespacesArgs> = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { nodesMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type NodesArgs = PageStoryArgs<typeof nodesMocks>;
|
||||
|
||||
const pageStory = storyMocks(nodesMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes nodes tab: allocatable against used CPU and memory, and the
|
||||
* node's condition.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=nodes`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Nodes',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<NodesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Nodes of the cluster, with the Ready and Not Ready condition counts, the pods
|
||||
* scheduled on each and CPU and memory read against what the node has left to
|
||||
* allocate.
|
||||
*/
|
||||
export const Default: StoryObj<NodesArgs> = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { podsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
|
||||
const pageStory = storyMocks(podsMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes pods tab: status, restarts and age beside CPU and memory read
|
||||
* against the pod's own request and limit. The drawer adds the pod's events.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=pods`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Pods',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PodsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Pods across every namespace: the status pill, restart count and age beside the
|
||||
* CPU and memory columns, each of those reading against the pod's own request and
|
||||
* limit. The drawer adds the events tab, which is the pod's Kubernetes events.
|
||||
*/
|
||||
export const Default: StoryObj<PodsArgs> = {};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { statefulSetsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type StatefulSetsArgs = PageStoryArgs<typeof statefulSetsMocks>;
|
||||
|
||||
const pageStory = storyMocks(statefulSetsMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes stateful sets tab: desired against ready replicas, with CPU and
|
||||
* memory beside them.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=statefulsets`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/StatefulSets',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<StatefulSetsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* StatefulSets by namespace: current against desired pods, the replica count and
|
||||
* the pod status counts. The drawer adds the by-pod metrics tab.
|
||||
*/
|
||||
export const Default: StoryObj<StatefulSetsArgs> = {};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { volumesMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type VolumesArgs = PageStoryArgs<typeof volumesMocks>;
|
||||
|
||||
const pageStory = storyMocks(volumesMocks);
|
||||
|
||||
/**
|
||||
* The Kubernetes volumes tab: capacity, used and available per persistent volume
|
||||
* claim.
|
||||
*
|
||||
* Route: `/infrastructure-monitoring/kubernetes?category=volumes`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Infrastructure/Kubernetes/Volumes',
|
||||
component: InfrastructureMonitoringPage,
|
||||
decorators: [withAppLayout],
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<VolumesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/**
|
||||
* Persistent volume claims by namespace, with usage against capacity and the
|
||||
* inode counts beside it. The volume drawer hides its tab strip, so it opens on
|
||||
* the metrics widgets and stays there.
|
||||
*/
|
||||
export const Default: StoryObj<VolumesArgs> = {};
|
||||
1165
frontend/src/pages/InfrastructureMonitoring/__story_mockdata__/infraMonitoring.ts
generated
Normal file
1165
frontend/src/pages/InfrastructureMonitoring/__story_mockdata__/infraMonitoring.ts
generated
Normal file
File diff suppressed because it is too large
Load Diff
150
frontend/src/pages/IntegrationsDetailsPage/CloudIntegration/CloudIntegration.stories.mocks.tsx
generated
Normal file
150
frontend/src/pages/IntegrationsDetailsPage/CloudIntegration/CloudIntegration.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
ACCOUNT_CAP,
|
||||
accountResponse,
|
||||
accountsResponse,
|
||||
CLOUD_PROVIDERS,
|
||||
CLOUD_SERVICE_CAP,
|
||||
type CloudProvider,
|
||||
createdAccountResponse,
|
||||
BUILT_IN_INTEGRATION_NOT_FOUND,
|
||||
credentialsResponse,
|
||||
seedServiceSignals,
|
||||
serviceResponse,
|
||||
servicesMetadataResponse,
|
||||
setServiceSignals,
|
||||
} from './__story_mockdata__/cloudIntegration';
|
||||
|
||||
/**
|
||||
* Answered whatever the Data control says, because it is not the page's data:
|
||||
* the request goes out and its failure is discarded either way.
|
||||
*/
|
||||
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
|
||||
|
||||
const PROVIDER = 'Cloud integration · provider';
|
||||
const SERVICES = 'Cloud integration · services';
|
||||
|
||||
export const cloudIntegrationMocks = defineStoryMocks({
|
||||
controls: {
|
||||
provider: choiceControl<CloudProvider>('Provider', {
|
||||
group: PROVIDER,
|
||||
description:
|
||||
'The last segment of the pathname, so it decides which provider the page is for: its logo and copy, which setup flow Add New Account opens, and the account configuration the settings drawer edits.',
|
||||
options: CLOUD_PROVIDERS,
|
||||
value: 'aws',
|
||||
}),
|
||||
accounts: countControl('Connected accounts', {
|
||||
group: PROVIDER,
|
||||
description:
|
||||
'At 0 the hero offers Integrate Now and the service list is read-only: the page falls back to the provider catalogue and the collection switches are disabled.',
|
||||
value: 1,
|
||||
max: ACCOUNT_CAP,
|
||||
}),
|
||||
services: countControl('Services', {
|
||||
group: SERVICES,
|
||||
description:
|
||||
'Services the provider answers with. GCP ships six, so anything past that shows the same list.',
|
||||
value: 8,
|
||||
max: CLOUD_SERVICE_CAP,
|
||||
}),
|
||||
enabledServices: countControl('Enabled', {
|
||||
group: SERVICES,
|
||||
description:
|
||||
'How many of them have a signal switched on, taken from the top of the list. At 0 the sidebar says so and every service sits under Not Enabled.',
|
||||
value: 3,
|
||||
max: CLOUD_SERVICE_CAP,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/integrations/:integrationId',
|
||||
builtInIntegrationNotFound,
|
||||
),
|
||||
|
||||
rest.put(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id/services/:serviceId',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as CloudintegrationtypesUpdatableServiceDTO;
|
||||
|
||||
setServiceSignals(String(req.params.serviceId), body.config ?? {});
|
||||
|
||||
return { status: 'success', data: null };
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id/services/:serviceId',
|
||||
response.json((req) =>
|
||||
serviceResponse(values.provider, String(req.params.serviceId), true),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id/services',
|
||||
response.json(() =>
|
||||
servicesMetadataResponse(values.provider, values.services, true),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/services/:serviceId',
|
||||
response.json((req) =>
|
||||
serviceResponse(values.provider, String(req.params.serviceId), false),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/services',
|
||||
response.json(() =>
|
||||
servicesMetadataResponse(values.provider, values.services, false),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/credentials',
|
||||
response.json(() => credentialsResponse()),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
|
||||
response.json(() => createdAccountResponse(values.provider)),
|
||||
),
|
||||
|
||||
rest.put(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
|
||||
response.json((req) =>
|
||||
accountResponse(values.provider, String(req.params.id)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
|
||||
response.json((req) =>
|
||||
accountResponse(values.provider, String(req.params.id)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
|
||||
response.json(() => accountsResponse(values.provider, values.accounts)),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
route: `/integrations/${values.provider}`,
|
||||
}),
|
||||
effect: (values) => {
|
||||
seedServiceSignals(values.provider, values.services, values.enabledServices);
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user