mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-22 20:50:34 +01:00
Compare commits
3 Commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc685bfeb | ||
|
|
9857737c1e | ||
|
|
4a41b0db33 |
78
.claude/skills/signoz-page-story/SKILL.md
Normal file
78
.claude/skills/signoz-page-story/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
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 `__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.
|
||||
|
||||
## 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>/__mockdata__/<page>.ts`. Nothing page-specific in
|
||||
`src/storybook/controls/`.
|
||||
- **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/__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
|
||||
`__mockdata__/`.
|
||||
- **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
|
||||
- [ ] 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
|
||||
163
.claude/skills/signoz-page-story/references/controls.md
Normal file
163
.claude/skills/signoz-page-story/references/controls.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 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/__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
|
||||
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 meta = {
|
||||
title: 'Pages/Services',
|
||||
component: Services,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION }),
|
||||
} 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.
|
||||
|
||||
## 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.
|
||||
110
.claude/skills/signoz-page-story/references/verify.md
Normal file
110
.claude/skills/signoz-page-story/references/verify.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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` → `pages-services`, plus the
|
||||
story export in kebab-case. Render one story on its own:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services--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 |
|
||||
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
|
||||
|
||||
89
frontend/.storybook/main.ts
Normal file
89
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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: __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/**/*.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: [],
|
||||
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,
|
||||
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>
|
||||
110
frontend/.storybook/preview.tsx
Normal file
110
frontend/.storybook/preview.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
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';
|
||||
|
||||
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 },
|
||||
},
|
||||
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;
|
||||
303
frontend/.storybook/public/mockServiceWorker.js
Normal file
303
frontend/.storybook/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/* 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') || ''
|
||||
|
||||
// Bypass server-sent events.
|
||||
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
|
||||
|
||||
@@ -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,7 @@
|
||||
"@commitlint/config-conventional": "20.4.4",
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
@@ -203,6 +206,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",
|
||||
|
||||
1080
frontend/pnpm-lock.yaml
generated
1080
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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;
|
||||
123
frontend/src/harness/AppHarness.tsx
Normal file
123
frontend/src/harness/AppHarness.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
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. Both harnesses that render pages outside
|
||||
* the app (jest's `test-utils`, Storybook's `StorybookProviders`) go through
|
||||
* here, so a provider added to the app is added once and both see it.
|
||||
*/
|
||||
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();
|
||||
|
||||
195
frontend/src/pages/HomePage/HomePage.stories.mocks.tsx
Normal file
195
frontend/src/pages/HomePage/HomePage.stories.mocks.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
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 './__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) },
|
||||
}),
|
||||
});
|
||||
55
frontend/src/pages/HomePage/HomePage.stories.tsx
Normal file
55
frontend/src/pages/HomePage/HomePage.stories.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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 meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME }),
|
||||
} 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' },
|
||||
};
|
||||
311
frontend/src/pages/HomePage/__mockdata__/home.ts
Normal file
311
frontend/src/pages/HomePage/__mockdata__/home.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
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/__mockdata__/appShell';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__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 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
305
frontend/src/storybook/README.md
Normal file
305
frontend/src/storybook/README.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Storybook
|
||||
|
||||
Runs SigNoz pages and components with no backend: every request is answered by
|
||||
msw, and the providers the app mounts at boot are replaced by story-controlled
|
||||
values.
|
||||
|
||||
```bash
|
||||
pnpm storybook # dev server on :6006
|
||||
pnpm storybook:build # static build into storybook-static/
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What lives there |
|
||||
| ----------------- | ---------------------------------------------------------------------- |
|
||||
| `runtime/` | `resolveStory`: story context in, the world the story renders in out |
|
||||
| `controls/` | Declaring controls (`defineStoryMocks`) and composing mock modules |
|
||||
| `globals/` | The mock modules every story carries: app shell and access |
|
||||
| `access/` | What a permission grant allows, and the legacy role it derives |
|
||||
| `providers/` | The Storybook adapter over `src/harness/AppHarness` |
|
||||
| `navigation/` | Keeping a story on its page, and reporting what it tried to leave for |
|
||||
| `msw/` | The default handler set and the shell's endpoints |
|
||||
| `mocks/` | Modules aliased in place of the app's own |
|
||||
| `decorators/` | `withProviders` (global) and `withAppLayout` (opt-in per page) |
|
||||
|
||||
A page's own mocks live with the page, not here. See [Adding a page
|
||||
story](#adding-a-page-story).
|
||||
|
||||
## What a story gets for free
|
||||
|
||||
`withProviders` (global decorator, `.storybook/preview.tsx`) wraps every story in
|
||||
`StorybookProviders`, the Storybook adapter over `src/harness/AppHarness`.
|
||||
`AppHarness` is the app's provider tree from `src/index.tsx` +
|
||||
`src/AppRoutes/index.tsx`, minus Sentry, posthog and `AppProvider`, with the
|
||||
pieces a runner has to choose left as props: the router, the nuqs adapter, the
|
||||
store, the query client and the mocked `AppContext`.
|
||||
|
||||
`tests/test-utils` mounts its own, smaller tree for jest and does not go through
|
||||
`AppHarness`: the suite has ~20 files that mock `hooks/useDarkMode`,
|
||||
`hooks/useNotifications` or `providers/cmdKProvider` down to a single export, so
|
||||
the providers those modules also carry would come back `undefined`. A provider
|
||||
added to the app therefore still needs adding in both places.
|
||||
|
||||
Storybook fills the seams with:
|
||||
|
||||
- `AppContext` from `tests/fixtures/appContextMock`, the same fixture the jest
|
||||
suite uses, so a story and a test see the same user, license and flags.
|
||||
- A fresh react-query client and redux store per story: no cache or state bleed.
|
||||
- `nuqs` on its testing adapter, so query-param state lives in memory and never
|
||||
touches the iframe URL.
|
||||
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
|
||||
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
|
||||
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it
|
||||
`--l1-background` and friends resolve to nothing and the page renders
|
||||
unstyled) and the `darkMode`/`dark`/`lightMode` classes.
|
||||
|
||||
## The story runtime
|
||||
|
||||
`runtime/resolveStory.ts` is the one place that turns a story's parameters and
|
||||
the controls panel's current values into everything the story runs on: the msw
|
||||
handlers in resolution order, the provider config, the theme, the remount key,
|
||||
and the module-level state to seed. The preview loader applies it before the
|
||||
decorators run; the decorator reads the same result, memoised on the story and
|
||||
its args.
|
||||
|
||||
Handlers resolve first-match-wins, in this order:
|
||||
|
||||
1. the story's own `parameters.msw.handlers`;
|
||||
2. the page's control-driven handlers;
|
||||
3. the global mocks' handlers (access);
|
||||
4. `msw/appShellHandlers.ts`, the endpoints the shell hits on every route, and
|
||||
the ones whose jest fixture is too thin to show it doing its job;
|
||||
5. `src/mocks-server/handlers.ts`, the jest handlers verbatim. An endpoint both
|
||||
runners need belongs here so jest gets it too;
|
||||
6. a catch-all for `http://localhost/api/*` that logs and answers 501, so an
|
||||
endpoint nobody mocked fails loudly instead of hanging on a refused
|
||||
connection.
|
||||
|
||||
The whole set is re-registered on every story render rather than handed to
|
||||
`setupWorker` once. Editing a handler module then takes effect on the next
|
||||
render; with the handlers baked in at worker creation, a long-lived dev server
|
||||
kept answering with the set it started with, and endpoints added later showed up
|
||||
as failed requests.
|
||||
|
||||
The handlers are declared against `http://localhost`, which is why
|
||||
`constants/env` is mocked to that origin. msw intercepts before the request
|
||||
leaves the page, so nothing reaches the network.
|
||||
|
||||
## Overrides
|
||||
|
||||
Per-story, through `parameters`:
|
||||
|
||||
```tsx
|
||||
export const Elsewhere: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
route: '/home?relativeTime=1h',
|
||||
appContext: { featureFlags: [] },
|
||||
reduxState: { globalTime: { ... } },
|
||||
theme: 'light',
|
||||
},
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.get('http://localhost/api/v2/rules', handleInternalServerError),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`parameters.signoz` is typed by `SignozStoryConfig` in `src/storybook/types.ts`.
|
||||
Who the story runs as is not in there. See [Access](#access).
|
||||
|
||||
Anything a page declares as a control belongs in `args`, not in `parameters`.
|
||||
|
||||
## The app shell
|
||||
|
||||
A page story always runs inside the real `AppLayout` (side nav, top nav,
|
||||
banners). A page without its shell is not the page anyone sees. Declare it once
|
||||
on the meta so every story of that page inherits it:
|
||||
|
||||
```tsx
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
} satisfies Meta<typeof HomePage>;
|
||||
```
|
||||
|
||||
## Controls
|
||||
|
||||
A page declares what about its mocks is adjustable, and the controls panel drives
|
||||
it. Every control is a knob on the response, not a prop on the component: turning
|
||||
one re-registers the msw handlers and remounts the story with an empty query
|
||||
cache, so the page fetches again and renders the new data.
|
||||
|
||||
```tsx
|
||||
// src/pages/HomePage/HomePage.stories.mocks.ts
|
||||
export const homeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
logsIngestion: toggleControl('Logs ingestion', { group: SIGNALS, value: true }),
|
||||
dashboards: countControl('Recent dashboards', { group: LISTS, value: 5, max: 8 }),
|
||||
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
|
||||
group: ONBOARDING,
|
||||
options: CHECKLIST_VISIBILITY,
|
||||
value: 'visible',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => recentDashboardsResponse(values.dashboards)),
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/HomePage/HomePage.stories.tsx
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME }),
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export const NoIngestion: StoryObj<HomeArgs> = {
|
||||
args: { logsIngestion: false, tracesIngestion: false, metricsIngestion: false },
|
||||
};
|
||||
```
|
||||
|
||||
`toggleControl`, `countControl`, `choiceControl` and `multiChoiceControl` build
|
||||
the panel row and carry the value's type, so `values` inside `handlers` is typed
|
||||
and a story's `args` are checked.
|
||||
|
||||
The hooks a mock module can answer, all optional. `handlers` answers the page's
|
||||
endpoints; `config` returns the provider-level knobs no endpoint covers;
|
||||
`responseState` says how the endpoints declared through `response` answer;
|
||||
`effect` seeds module-level state no provider exposes, such as no-auth mode; and
|
||||
`role` derives the legacy role, which only `authzMocks` does.
|
||||
|
||||
Endpoints declared through `response.json` follow the response state (`loaded`,
|
||||
`loading` or `error`) which the `Data` control drives, so one declaration covers
|
||||
all three. Endpoints the page cannot render without, such as ingestion detection
|
||||
and preferences, take a plain msw resolver so they keep answering while the rest
|
||||
of the page hangs or fails.
|
||||
|
||||
The mock modules every story carries are registered in `globals/index.ts`:
|
||||
`appShellMocks` (app-wide banners, side nav state, `Data`) and `authzMocks`
|
||||
(below). Adding one there publishes its controls and widens `PageStoryArgs` in
|
||||
the same edit.
|
||||
|
||||
## Access
|
||||
|
||||
Permissions are the knob, not roles. `POST /api/v1/authz/check` is the single
|
||||
gate the app reads: route guards, `AuthZGuard`, `AuthZButton` and `user.role` all
|
||||
resolve through it, so the controls answer that endpoint and everything
|
||||
downstream follows. `access/access.ts` is what decides:
|
||||
`accessFor(preset, extra)` returns the permission set, whether a given check is
|
||||
allowed, and the legacy role it derives.
|
||||
|
||||
- **Access**: `admin`, `editor`, `viewer`, `anonymous`, `grant-all`, `deny-all`,
|
||||
`custom`, `dev-tools`.
|
||||
- **Permissions**: granted on top of the preset, as `relation:kind`
|
||||
(`read:logs`, `create:serviceaccount`, …); `custom` starts from nothing, so
|
||||
there the list is the whole grant. Generated from
|
||||
`lib/authz/hooks/useAuthZ/permissions.type.ts`, so a resource added to the
|
||||
catalogue shows up without touching Storybook. A selector-scoped check
|
||||
(`update` on `role:some-id`) matches the entry for its kind; the legacy
|
||||
`assignee:role:signoz-*` permissions are listed individually.
|
||||
- **Check state**: `loaded`, `loading` or `error`, the same forcing the AuthZ
|
||||
dev modal offers.
|
||||
|
||||
`user.role` comes from the same grant, derived the way `AppProvider` derives it,
|
||||
so the legacy role, `hasEditPermission`, `routePermission` and
|
||||
`componentPermission` all follow the same control and no story can set them to
|
||||
something the check endpoint disagrees with. The runtime writes the result to
|
||||
`<body data-signoz-story-role>`, and the provider tree writes what
|
||||
`useAppContext()` actually yields to `<body data-signoz-context-role>`, so
|
||||
whether the page is reading it is one glance away in the Elements panel.
|
||||
|
||||
Granting no legacy role at all (`deny-all`, or `custom` without one ticked)
|
||||
derives `ANONYMOUS`, exactly as `AppProvider` does. The legacy checks are written
|
||||
as `role !== VIEWER`, so an anonymous user passes them and sees *more* than a
|
||||
viewer. That is the app's gap, faithfully reproduced: to see the viewer UI, grant
|
||||
the viewer role. The role-named presets exist because those checks still exist;
|
||||
when the roles go, delete the presets and the derivation. The permission list
|
||||
stays.
|
||||
|
||||
For anything finer than a preset, the app's own dev tools are mounted in every
|
||||
story: `⌘K` → **AuthZ DevTools** lists the permissions the page actually checked
|
||||
and overrides them one by one (granted, denied, delayed, error). Set Access to
|
||||
`dev-tools` first, because the other values reset the override store on render, so
|
||||
a leftover override from a real dev session cannot answer for the controls panel.
|
||||
Overrides only apply while `IS_DEV` is true, which means the dev server, not a
|
||||
static build.
|
||||
|
||||
Adding or renaming a project-level control needs a tab reload: Vite hot-updates
|
||||
`preview.tsx` without re-preparing the open stories, so the panel keeps the
|
||||
controls, and the arg values, it was built with.
|
||||
|
||||
## Module mocks
|
||||
|
||||
Aliased for every story in `.storybook/main.ts`, the same way `jest.config.ts`
|
||||
does it through `moduleNameMapper`. Each replacement is typed as the module it
|
||||
stands in for, so an export added to the real module is a compile error here
|
||||
rather than a story that fails at render:
|
||||
|
||||
| Module | Replacement | Why |
|
||||
| --------------------- | ---------------------------------- | ------------------------------------------ |
|
||||
| `lib/history` | `navigation/history.alias.ts` | keeps a story on its page, see below |
|
||||
| `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 |
|
||||
|
||||
Mocks use `fn()` from `storybook/test`, so a play function can assert on them:
|
||||
|
||||
```tsx
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
play: async () => {
|
||||
await expect(logEvent).toHaveBeenCalledWith('Homepage: Visited', {});
|
||||
},
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
A story renders one page, so leaving that page would unmount it.
|
||||
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` is what the
|
||||
app sees in place of `lib/history`, and the two tell a navigation apart by
|
||||
pathname:
|
||||
|
||||
- **Same page**: a query-param or hash change, which is how tabs, filters,
|
||||
pagination and time ranges are driven. It is applied, and the page re-renders
|
||||
the way it does in the app. Anchors are covered too: an in-page `<a href="?tab=x">`
|
||||
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's history
|
||||
rather than followed, which would navigate the iframe out of the story.
|
||||
- **Another page**: a different pathname, an off-site href, `window.open` (what
|
||||
`useSafeNavigate({ newTab })` calls) or a relative `go`/`goBack`, which carries
|
||||
no target to compare against. It is swallowed and reported to
|
||||
`NavigationBlockedOverlay`, which lists what was attempted. Nothing is silently
|
||||
dropped.
|
||||
|
||||
`nuqs` is the one gap: it runs on its testing adapter and keeps its own copy of
|
||||
the query string, seeded from the story's `route`. A page that writes params
|
||||
through both `useQueryState` and `history.push({ search })` sees the two diverge
|
||||
inside a story; a page that stays on one mechanism does not.
|
||||
|
||||
## Adding a page story
|
||||
|
||||
The `signoz-page-story` skill in `.claude/skills/` carries this as a workflow:
|
||||
mapping the page, deriving its controls, and the checks a story has to pass.
|
||||
|
||||
1. Point the story at the page component under `src/pages/<Page>`.
|
||||
2. Declare the page's mocks in `<Page>.stories.mocks.ts` next to it, with its
|
||||
payload builders under `<Page>/__mockdata__/`, and spread
|
||||
`storyMocks(<page>Mocks, { route })` into the meta.
|
||||
3. Add `decorators: [withAppLayout]` to the meta.
|
||||
4. Give the default story every widget populated. A page story earns its keep by
|
||||
showing what the page looks like with data, not with empty states.
|
||||
5. Run it and watch the console: an msw warning or a `[storybook] no msw handler`
|
||||
line is an endpoint the page hits that no handler covers yet.
|
||||
6. Reach for a control before a story. A variant earns a story only when it is
|
||||
worth linking to; anything else is a control someone can turn.
|
||||
155
frontend/src/storybook/access/access.ts
Normal file
155
frontend/src/storybook/access/access.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IsAdminPermission,
|
||||
IsAnonymousPermission,
|
||||
IsEditorPermission,
|
||||
IsViewerPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
formatPermission,
|
||||
gettableTransactionToPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
/**
|
||||
* `relation:kind` for every verb the backend allows on a resource, from the
|
||||
* generated permission catalogue, so a resource added there shows up here
|
||||
* without anyone touching this file. Selector-scoped checks (`role:some-id`)
|
||||
* match the entry for their kind.
|
||||
*/
|
||||
export const permissionCatalogue = (): string[] => {
|
||||
const entries = new Set<string>();
|
||||
|
||||
for (const [relation, types] of Object.entries(
|
||||
permissionsType.data.relations,
|
||||
)) {
|
||||
for (const resource of permissionsType.data.resources) {
|
||||
const appliesToResource = (types as readonly string[]).includes(
|
||||
resource.type,
|
||||
);
|
||||
|
||||
const allowsVerb = (resource.allowedVerbs as readonly string[]).includes(
|
||||
relation,
|
||||
);
|
||||
|
||||
if (appliesToResource && allowsVerb) {
|
||||
entries.add(`${relation}:${resource.kind}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries].sort();
|
||||
};
|
||||
|
||||
const CATALOGUE = permissionCatalogue();
|
||||
|
||||
const TELEMETRY_READS = CATALOGUE.filter((permission) =>
|
||||
permissionsType.data.resources.some(
|
||||
(resource) =>
|
||||
resource.type === 'telemetryresource' &&
|
||||
permission === `read:${resource.kind}`,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* `user.role` is itself an authz check in the real app (`AppProvider` derives it
|
||||
* from these), so a caller that wants a role grants the matching permission
|
||||
* rather than setting the role directly.
|
||||
*/
|
||||
export const LEGACY_ROLE_PERMISSIONS = {
|
||||
[USER_ROLES.ADMIN]: formatPermission(IsAdminPermission),
|
||||
[USER_ROLES.EDITOR]: formatPermission(IsEditorPermission),
|
||||
[USER_ROLES.VIEWER]: formatPermission(IsViewerPermission),
|
||||
[USER_ROLES.ANONYMOUS]: formatPermission(IsAnonymousPermission),
|
||||
};
|
||||
|
||||
export const ACCESS_PRESETS = [
|
||||
'admin',
|
||||
'editor',
|
||||
'viewer',
|
||||
'anonymous',
|
||||
'grant-all',
|
||||
'deny-all',
|
||||
'custom',
|
||||
'dev-tools',
|
||||
] as const;
|
||||
|
||||
export type AccessPreset = (typeof ACCESS_PRESETS)[number];
|
||||
|
||||
/**
|
||||
* The legacy roles only differ in authz for the resources the backend already
|
||||
* covers: an admin manages roles, service accounts and API keys, while editor
|
||||
* and viewer are telemetry readers and differ through `user.role` alone. The
|
||||
* presets go away with the roles; the permission list does not.
|
||||
*/
|
||||
const ADMIN_PERMISSIONS = [
|
||||
LEGACY_ROLE_PERMISSIONS[USER_ROLES.ADMIN],
|
||||
// Without the `assignee` wildcard, which would hand out every legacy role at
|
||||
// once. That is what `grant-all` is for.
|
||||
...CATALOGUE.filter((permission) => permission !== 'assignee:role'),
|
||||
];
|
||||
|
||||
const PRESET_PERMISSIONS: Record<AccessPreset, readonly string[]> = {
|
||||
admin: ADMIN_PERMISSIONS,
|
||||
editor: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.EDITOR], ...TELEMETRY_READS],
|
||||
viewer: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.VIEWER], ...TELEMETRY_READS],
|
||||
anonymous: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.ANONYMOUS]],
|
||||
'grant-all': [...Object.values(LEGACY_ROLE_PERMISSIONS), ...CATALOGUE],
|
||||
'deny-all': [],
|
||||
custom: [],
|
||||
'dev-tools': ADMIN_PERMISSIONS,
|
||||
};
|
||||
|
||||
/** Every permission a caller can grant on top of a preset. */
|
||||
export const PERMISSION_OPTIONS = [
|
||||
...Object.values(LEGACY_ROLE_PERMISSIONS),
|
||||
...CATALOGUE,
|
||||
];
|
||||
|
||||
export interface AccessGrant {
|
||||
permissions: ReadonlySet<string>;
|
||||
/** Answers one `authz/check` transaction the way the backend would. */
|
||||
allows(transaction: AuthtypesTransactionDTO): boolean;
|
||||
/**
|
||||
* The legacy role the granted `assignee:role:signoz-*` permissions derive,
|
||||
* the way `AppProvider` derives it. No legacy role granted lands on
|
||||
* `ANONYMOUS`.
|
||||
*/
|
||||
legacyRole: ROLES;
|
||||
}
|
||||
|
||||
const deriveLegacyRole = (granted: ReadonlySet<string>): ROLES => {
|
||||
const role = Object.entries(LEGACY_ROLE_PERMISSIONS).find(([, permission]) =>
|
||||
granted.has(permission),
|
||||
);
|
||||
|
||||
return (role?.[0] ?? USER_ROLES.ANONYMOUS) as ROLES;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission set a preset plus its extra grants resolve to, and the two
|
||||
* questions the app asks of it. `custom` and `deny-all` start from nothing, so
|
||||
* there the extra grants are the whole set.
|
||||
*/
|
||||
export const accessFor = (
|
||||
preset: AccessPreset,
|
||||
extraPermissions: readonly string[] = [],
|
||||
): AccessGrant => {
|
||||
const permissions = new Set([
|
||||
...PRESET_PERMISSIONS[preset],
|
||||
...extraPermissions,
|
||||
]);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
allows: (transaction): boolean =>
|
||||
permissions.has(
|
||||
formatPermission(gettableTransactionToPermission(transaction)),
|
||||
) ||
|
||||
permissions.has(
|
||||
`${transaction.relation}:${transaction.object.resource.kind}`,
|
||||
),
|
||||
legacyRole: deriveLegacyRole(permissions),
|
||||
};
|
||||
};
|
||||
34
frontend/src/storybook/controls/composeStoryMocks.ts
Normal file
34
frontend/src/storybook/controls/composeStoryMocks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AnyStoryMocks, ControlDescriptor, StoryMockArgs } from './types';
|
||||
|
||||
type UnionToIntersection<TUnion> = (
|
||||
TUnion extends unknown ? (arg: TUnion) => void : never
|
||||
) extends (arg: infer TIntersection) => void
|
||||
? TIntersection
|
||||
: never;
|
||||
|
||||
/** The args of every mock module in a list, as one object type. */
|
||||
export type ComposedMockArgs<TMocks extends readonly AnyStoryMocks[]> =
|
||||
UnionToIntersection<StoryMockArgs<TMocks[number]>>;
|
||||
|
||||
export interface ComposedStoryMocks<TMocks extends readonly AnyStoryMocks[]> {
|
||||
/** In resolution order: the first module to answer a question wins. */
|
||||
members: TMocks;
|
||||
args: ComposedMockArgs<TMocks>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One registration point for a set of mock modules: the panel rows they publish
|
||||
* and the args type a story is checked against are both derived from the list,
|
||||
* so adding a module is a single edit.
|
||||
*/
|
||||
export const composeStoryMocks = <TMocks extends readonly AnyStoryMocks[]>(
|
||||
...members: TMocks
|
||||
): ComposedStoryMocks<TMocks> => ({
|
||||
members,
|
||||
args: Object.assign(
|
||||
{},
|
||||
...members.map((mocks) => mocks.args),
|
||||
) as ComposedMockArgs<TMocks>,
|
||||
argTypes: Object.assign({}, ...members.map((mocks) => mocks.argTypes)),
|
||||
});
|
||||
73
frontend/src/storybook/controls/controls.ts
Normal file
73
frontend/src/storybook/controls/controls.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ControlDescriptor, MockControl } from './types';
|
||||
|
||||
interface ControlOptions {
|
||||
/** Controls-panel group, so a page's knobs stay together. */
|
||||
group: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const describe = (
|
||||
name: string,
|
||||
{ group, description }: ControlOptions,
|
||||
value: unknown,
|
||||
): ControlDescriptor => ({
|
||||
name,
|
||||
description,
|
||||
table: {
|
||||
category: group,
|
||||
defaultValue: { summary: JSON.stringify(value) },
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleControl = (
|
||||
name: string,
|
||||
options: ControlOptions & { value: boolean },
|
||||
): MockControl<boolean> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Item count for a list. `max` should go past what the page renders so a story
|
||||
* can show the cap being hit.
|
||||
*/
|
||||
export const countControl = (
|
||||
name: string,
|
||||
options: ControlOptions & { value: number; max: number },
|
||||
): MockControl<number> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'range', min: 0, max: options.max, step: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
export const choiceControl = <TOption extends string>(
|
||||
name: string,
|
||||
options: ControlOptions & { value: TOption; options: readonly TOption[] },
|
||||
): MockControl<TOption> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'select' },
|
||||
options: [...options.options],
|
||||
},
|
||||
});
|
||||
|
||||
export const multiChoiceControl = <TOption extends string>(
|
||||
name: string,
|
||||
options: ControlOptions & {
|
||||
value: readonly TOption[];
|
||||
options: readonly TOption[];
|
||||
},
|
||||
): MockControl<TOption[]> => ({
|
||||
defaultValue: [...options.value],
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'check' },
|
||||
options: [...options.options],
|
||||
},
|
||||
});
|
||||
59
frontend/src/storybook/controls/defineStoryMocks.ts
Normal file
59
frontend/src/storybook/controls/defineStoryMocks.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ControlDescriptor,
|
||||
MockControlMap,
|
||||
MockControlValues,
|
||||
StoryMocks,
|
||||
StoryMocksDefinition,
|
||||
} from './types';
|
||||
import type { SignozStoryConfig, StoryOwnedConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Turns a page's control declarations into the `args` / `argTypes` its meta
|
||||
* exposes and the reader the runtime uses to fold the panel's current values
|
||||
* back into mock responses.
|
||||
*/
|
||||
export const defineStoryMocks = <TControls extends MockControlMap>(
|
||||
definition: StoryMocksDefinition<TControls>,
|
||||
): StoryMocks<TControls> => {
|
||||
const entries = Object.entries(definition.controls);
|
||||
|
||||
const args = Object.fromEntries(
|
||||
entries.map(([name, control]) => [name, control.defaultValue]),
|
||||
) as MockControlValues<TControls>;
|
||||
|
||||
const argTypes: Record<string, ControlDescriptor> = Object.fromEntries(
|
||||
entries.map(([name, control]) => [name, control.argType]),
|
||||
);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
args,
|
||||
argTypes,
|
||||
read: (storyArgs): MockControlValues<TControls> =>
|
||||
Object.fromEntries(
|
||||
entries.map(([name, control]) => [
|
||||
name,
|
||||
storyArgs[name] ?? control.defaultValue,
|
||||
]),
|
||||
) as MockControlValues<TControls>,
|
||||
};
|
||||
};
|
||||
|
||||
interface StoryMocksMeta<TControls extends MockControlMap> {
|
||||
args: MockControlValues<TControls>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
parameters: { signoz: SignozStoryConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta fragment a page spreads to publish its controls:
|
||||
* `...storyMocks(homeMocks, { route: ROUTES.HOME })`.
|
||||
*/
|
||||
export const storyMocks = <TControls extends MockControlMap>(
|
||||
mocks: StoryMocks<TControls>,
|
||||
config?: StoryOwnedConfig,
|
||||
): StoryMocksMeta<TControls> => ({
|
||||
args: mocks.args,
|
||||
argTypes: mocks.argTypes,
|
||||
parameters: { signoz: { ...config, mocks } },
|
||||
});
|
||||
63
frontend/src/storybook/controls/types.ts
Normal file
63
frontend/src/storybook/controls/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ArgTypes } from '@storybook/react-vite';
|
||||
import type { RequestHandler } from 'msw';
|
||||
|
||||
import type { MockResponse } from '../msw/types';
|
||||
import type { ResponseState } from '../runtime/responseState';
|
||||
import type { StoryOwnedConfig, StoryRole } from '../types';
|
||||
|
||||
export type ControlDescriptor = ArgTypes[string];
|
||||
|
||||
/** One row of the Storybook controls panel plus the value it starts at. */
|
||||
export interface MockControl<TValue> {
|
||||
defaultValue: TValue;
|
||||
argType: ControlDescriptor;
|
||||
}
|
||||
|
||||
export type MockControlMap = Record<string, MockControl<unknown>>;
|
||||
|
||||
export type MockControlValues<TControls extends MockControlMap> = {
|
||||
[TName in keyof TControls]: TControls[TName] extends MockControl<infer TValue>
|
||||
? TValue
|
||||
: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a mock module contributes to the story it runs in. Only `controls` is
|
||||
* required; the rest are the ways a control can reach the page.
|
||||
*/
|
||||
export interface StoryMocksDefinition<TControls extends MockControlMap> {
|
||||
controls: TControls;
|
||||
handlers?(
|
||||
values: MockControlValues<TControls>,
|
||||
response: MockResponse,
|
||||
): RequestHandler[];
|
||||
/** Provider-level knobs no endpoint covers. */
|
||||
config?(values: MockControlValues<TControls>): Partial<StoryOwnedConfig>;
|
||||
/** Seeds module-level app state no provider exposes, e.g. no-auth mode. */
|
||||
effect?(values: MockControlValues<TControls>): void;
|
||||
/**
|
||||
* How the endpoints declared through `response` answer. The first module that
|
||||
* answers decides, page modules ahead of the global ones.
|
||||
*/
|
||||
responseState?(values: MockControlValues<TControls>): ResponseState;
|
||||
/**
|
||||
* The legacy role the story runs as. Derived, never set by a story. See
|
||||
* `authzMocks`, which derives it from the permissions it grants.
|
||||
*/
|
||||
role?(values: MockControlValues<TControls>): StoryRole;
|
||||
}
|
||||
|
||||
export interface StoryMocks<
|
||||
TControls extends MockControlMap,
|
||||
> extends StoryMocksDefinition<TControls> {
|
||||
args: MockControlValues<TControls>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
read(args: Record<string, unknown>): MockControlValues<TControls>;
|
||||
}
|
||||
|
||||
export type AnyStoryMocks = StoryMocks<MockControlMap>;
|
||||
|
||||
export type StoryMockArgs<TMocks extends AnyStoryMocks> =
|
||||
TMocks extends StoryMocks<infer TControls>
|
||||
? MockControlValues<TControls>
|
||||
: never;
|
||||
9
frontend/src/storybook/decorators/withAppLayout.tsx
Normal file
9
frontend/src/storybook/decorators/withAppLayout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Decorator } from '@storybook/react-vite';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
|
||||
/** Opt in per story with `decorators: [withAppLayout]`. */
|
||||
export const withAppLayout: Decorator = (Story) => (
|
||||
<AppLayout>
|
||||
<Story />
|
||||
</AppLayout>
|
||||
);
|
||||
23
frontend/src/storybook/decorators/withProviders.tsx
Normal file
23
frontend/src/storybook/decorators/withProviders.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Decorator } from '@storybook/react-vite';
|
||||
|
||||
import StorybookProviders from '../providers/StorybookProviders';
|
||||
import {
|
||||
resolveStory,
|
||||
type StoryRuntimeContext,
|
||||
} from '../runtime/resolveStory';
|
||||
|
||||
/**
|
||||
* Global decorator: every story renders inside the mocked provider tree the
|
||||
* story runtime resolved. Everything the tree needs in place first (handlers,
|
||||
* module-level state, theme) is applied by the preview loader, which runs
|
||||
* ahead of this.
|
||||
*/
|
||||
export const withProviders: Decorator = (Story, context) => {
|
||||
const world = resolveStory(context as unknown as StoryRuntimeContext);
|
||||
|
||||
return (
|
||||
<StorybookProviders key={world.key} {...world.config}>
|
||||
<Story />
|
||||
</StorybookProviders>
|
||||
);
|
||||
};
|
||||
267
frontend/src/storybook/globals/appShellMocks.ts
Normal file
267
frontend/src/storybook/globals/appShellMocks.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import APIError from 'types/api/error';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
type LicenseResModel,
|
||||
LicenseState,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { setNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import { choiceControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
import type { StoryMockArgs } from '../controls/types';
|
||||
import { RESPONSE_STATES, type ResponseState } from '../runtime/responseState';
|
||||
|
||||
const APP_SHELL = 'App shell';
|
||||
const DATA = 'Data';
|
||||
const LICENSE = 'License';
|
||||
|
||||
const DAY_IN_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const LICENSES = [
|
||||
'cloud',
|
||||
'enterprise',
|
||||
'community-enterprise',
|
||||
'community',
|
||||
] as const;
|
||||
|
||||
const BANNERS = [
|
||||
'none',
|
||||
'trial-expiry',
|
||||
'payment-failed',
|
||||
'license-expired',
|
||||
'license-terminated',
|
||||
'no-auth',
|
||||
] as const;
|
||||
|
||||
type License = (typeof LICENSES)[number];
|
||||
|
||||
type Banner = (typeof BANNERS)[number];
|
||||
|
||||
const SIDENAV_STATES = ['pinned', 'collapsed'] as const;
|
||||
|
||||
type SidenavState = (typeof SIDENAV_STATES)[number];
|
||||
|
||||
const {
|
||||
activeLicense: baseLicense,
|
||||
trialInfo: baseTrialInfo,
|
||||
featureFlags: baseFeatureFlags,
|
||||
versionData: baseVersionData,
|
||||
} = createAppContextMock(USER_ROLES.ADMIN);
|
||||
|
||||
/**
|
||||
* The status code `/licenses/active` failed with is itself the signal
|
||||
* `useGetTenantLicense` reads: 404 is the enterprise build running unlicensed,
|
||||
* 501 the community build, where the endpoint does not exist at all.
|
||||
*/
|
||||
const licenseFetchError = (httpStatusCode: StatusCodes): APIError =>
|
||||
new APIError({
|
||||
httpStatusCode,
|
||||
error: {
|
||||
code: 'license_unavailable',
|
||||
message: 'storybook: no active license',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const feature = (name: FeatureKeys, active: boolean): FeatureFlagProps => ({
|
||||
name,
|
||||
active,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* What the backend serves an unlicensed enterprise build — the same keys as the
|
||||
* enterprise plan, all inactive. Mirrors `BasicPlan` in
|
||||
* `pkg/types/licensetypes/plan.go`; the community build serves none at all.
|
||||
*/
|
||||
const BASIC_PLAN: FeatureFlagProps[] = [
|
||||
FeatureKeys.SSO,
|
||||
FeatureKeys.GATEWAY,
|
||||
FeatureKeys.PREMIUM_SUPPORT,
|
||||
FeatureKeys.ANOMALY_DETECTION,
|
||||
].map((name) => feature(name, false));
|
||||
|
||||
/**
|
||||
* Which of the four deployments `useGetTenantLicense` distinguishes the story
|
||||
* runs on. The license drives the plan the app believes it is on, so the feature
|
||||
* flags and the enterprise/community build marker follow it.
|
||||
*/
|
||||
const licenseContext = (license: License): Partial<IAppContext> => {
|
||||
switch (license) {
|
||||
case 'enterprise':
|
||||
return {
|
||||
activeLicense: baseLicense && {
|
||||
...baseLicense,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
},
|
||||
activeLicenseFetchError: null,
|
||||
};
|
||||
|
||||
case 'community-enterprise':
|
||||
return {
|
||||
activeLicense: null,
|
||||
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_FOUND),
|
||||
featureFlags: BASIC_PLAN,
|
||||
};
|
||||
|
||||
case 'community':
|
||||
return {
|
||||
activeLicense: null,
|
||||
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_IMPLEMENTED),
|
||||
featureFlags: [],
|
||||
versionData: baseVersionData && { ...baseVersionData, ee: 'N' },
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
activeLicense: baseLicense,
|
||||
activeLicenseFetchError: null,
|
||||
featureFlags: baseFeatureFlags,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A banner that reads the license needs one to read, so the community
|
||||
* deployments fall back to the licensed fixture rather than showing nothing.
|
||||
*/
|
||||
const licensedBanner = (
|
||||
activeLicense: LicenseResModel | null,
|
||||
extend: (license: LicenseResModel) => LicenseResModel,
|
||||
): Partial<IAppContext> => {
|
||||
const license = activeLicense ?? baseLicense;
|
||||
|
||||
return {
|
||||
activeLicense: license && extend(license),
|
||||
activeLicenseFetchError: null,
|
||||
};
|
||||
};
|
||||
|
||||
const bannerContext = (
|
||||
banner: Banner,
|
||||
activeLicense: LicenseResModel | null,
|
||||
): Partial<IAppContext> => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
switch (banner) {
|
||||
case 'trial-expiry':
|
||||
return {
|
||||
trialInfo: {
|
||||
...baseTrialInfo,
|
||||
onTrial: true,
|
||||
trialStart: nowInSeconds - 27 * DAY_IN_SECONDS,
|
||||
trialEnd: nowInSeconds + 3 * DAY_IN_SECONDS,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
};
|
||||
|
||||
case 'payment-failed':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
event_queue: {
|
||||
...license.event_queue,
|
||||
event: LicenseEvent.DEFAULT,
|
||||
scheduled_at: new Date(
|
||||
Date.now() + 7 * DAY_IN_SECONDS * 1000,
|
||||
).toISOString(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Both restricted-workspace banners need a self-hosted license; the cloud
|
||||
// platform never reaches that branch.
|
||||
case 'license-expired':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
state: LicenseState.EXPIRED,
|
||||
}));
|
||||
|
||||
case 'license-terminated':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
state: LicenseState.TERMINATED,
|
||||
}));
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `AppLayout` lays the shell out from the context rather than the API, so the
|
||||
* side nav only matches the real app when this is seeded.
|
||||
*/
|
||||
const sidenavPreferences = (pinned: boolean): UserPreference[] => [
|
||||
{
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
description: 'Keep the side navigation pinned open',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['user'],
|
||||
value: pinned,
|
||||
},
|
||||
];
|
||||
|
||||
/** Who is looking at the page is `authzMocks`; everything else is here. */
|
||||
export const appShellMocks = defineStoryMocks({
|
||||
controls: {
|
||||
license: choiceControl<License>('License', {
|
||||
group: LICENSE,
|
||||
description:
|
||||
'The deployment the story runs on, as `useGetTenantLicense` reads it. `cloud` and `enterprise` are licensed and carry the enterprise plan; `community-enterprise` is the enterprise build with no license (basic plan, every feature inactive) and `community` the open-source build (no plan, `ee: N`).',
|
||||
options: LICENSES,
|
||||
value: 'cloud',
|
||||
}),
|
||||
banner: choiceControl<Banner>('Banner', {
|
||||
group: APP_SHELL,
|
||||
description:
|
||||
'License, trial and no-auth banners above the shell. The license ones need a license to read, so they override an unlicensed License control.',
|
||||
options: BANNERS,
|
||||
value: 'none',
|
||||
}),
|
||||
sidenav: choiceControl<SidenavState>('Side nav', {
|
||||
group: APP_SHELL,
|
||||
options: SIDENAV_STATES,
|
||||
value: 'pinned',
|
||||
}),
|
||||
dataState: choiceControl<ResponseState>('State', {
|
||||
group: DATA,
|
||||
description: 'How the endpoints the page owns answer.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
},
|
||||
responseState: ({ dataState }) => dataState,
|
||||
config: ({ license, banner, sidenav }) => {
|
||||
const tenant = licenseContext(license);
|
||||
|
||||
return {
|
||||
appContext: {
|
||||
...tenant,
|
||||
...bannerContext(banner, tenant.activeLicense ?? null),
|
||||
userPreferences: sidenavPreferences(sidenav === 'pinned'),
|
||||
},
|
||||
};
|
||||
},
|
||||
effect: ({ banner }) => {
|
||||
setNoAuthMode(banner === 'no-auth');
|
||||
},
|
||||
});
|
||||
|
||||
export type AppShellArgs = StoryMockArgs<typeof appShellMocks>;
|
||||
83
frontend/src/storybook/globals/authzMocks.ts
Normal file
83
frontend/src/storybook/globals/authzMocks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { rest } from 'msw';
|
||||
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { clearAllAuthZDevOverrides } from 'lib/authz/devtools/useAuthZDevStore';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import {
|
||||
accessFor,
|
||||
ACCESS_PRESETS,
|
||||
type AccessPreset,
|
||||
PERMISSION_OPTIONS,
|
||||
} from '../access/access';
|
||||
import { choiceControl, multiChoiceControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
import type { StoryMockArgs } from '../controls/types';
|
||||
import {
|
||||
respondWith,
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
} from '../runtime/responseState';
|
||||
|
||||
const ACCESS = 'Access';
|
||||
|
||||
/**
|
||||
* Every permission check a story makes, answered from the controls panel rather
|
||||
* than from a role. `POST /api/v1/authz/check` is the single gate the app reads:
|
||||
* route guards, `AuthZGuard`, `AuthZButton` and `user.role` all resolve through
|
||||
* it, and `access/access.ts` decides what the grant allows.
|
||||
*/
|
||||
export const authzMocks = defineStoryMocks({
|
||||
controls: {
|
||||
access: choiceControl<AccessPreset>('Access', {
|
||||
group: ACCESS,
|
||||
description:
|
||||
'Base permission set the check endpoint answers with. `custom` starts from nothing, so only the list below counts; `dev-tools` grants an admin set and leaves the AuthZ dev modal (⌘K) in charge. Granting no legacy role lands on `ANONYMOUS`, which the role-based checks still treat as "not a viewer".',
|
||||
options: ACCESS_PRESETS,
|
||||
value: 'admin',
|
||||
}),
|
||||
permissions: multiChoiceControl('Permissions', {
|
||||
group: ACCESS,
|
||||
description:
|
||||
'Granted on top of the preset, as `relation:kind`. A selector-scoped check matches its kind. With Access on `custom` this is the whole list.',
|
||||
options: PERMISSION_OPTIONS,
|
||||
value: [],
|
||||
}),
|
||||
authzState: choiceControl<ResponseState>('Check state', {
|
||||
group: ACCESS,
|
||||
description:
|
||||
'How `authz/check` answers, the way the dev modal can force it.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
},
|
||||
handlers: ({ access, permissions, authzState }) => {
|
||||
const granted = accessFor(access, permissions);
|
||||
|
||||
return [
|
||||
rest.post(
|
||||
AUTHZ_CHECK_URL,
|
||||
respondWith(authzState, async (req) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
|
||||
return authzMockResponse(
|
||||
payload,
|
||||
payload.map((transaction) => granted.allows(transaction)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
];
|
||||
},
|
||||
role: ({ access, permissions }) => accessFor(access, permissions).legacyRole,
|
||||
// Overrides persist in localStorage, so a leftover one from a real dev session
|
||||
// would silently answer for the controls panel.
|
||||
effect: ({ access }) => {
|
||||
if (access !== 'dev-tools') {
|
||||
clearAllAuthZDevOverrides();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export type AuthzArgs = StoryMockArgs<typeof authzMocks>;
|
||||
12
frontend/src/storybook/globals/index.ts
Normal file
12
frontend/src/storybook/globals/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { composeStoryMocks } from '../controls/composeStoryMocks';
|
||||
import { appShellMocks } from './appShellMocks';
|
||||
import { authzMocks } from './authzMocks';
|
||||
|
||||
/**
|
||||
* The mock modules every story carries, page or component, declared at project
|
||||
* level in `.storybook/preview.tsx`. Adding one here publishes its controls and
|
||||
* widens `PageStoryArgs` in the same edit.
|
||||
*/
|
||||
export const globalMocks = composeStoryMocks(authzMocks, appShellMocks);
|
||||
|
||||
export type GlobalMockArgs = typeof globalMocks.args;
|
||||
8
frontend/src/storybook/mocks/createStoryAppContext.ts
Normal file
8
frontend/src/storybook/mocks/createStoryAppContext.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { fn } from 'storybook/test';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
|
||||
export const createStoryAppContext = (
|
||||
role: string,
|
||||
overrides?: Partial<IAppContext>,
|
||||
): IAppContext => createAppContextMock(role, overrides, () => fn());
|
||||
20
frontend/src/storybook/mocks/env.mock.ts
Normal file
20
frontend/src/storybook/mocks/env.mock.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Replaces `constants/env` in Storybook (aliased in `.storybook/main.ts`).
|
||||
*
|
||||
* The base URL must stay `http://localhost` so the msw handlers shared with
|
||||
* jest (`src/mocks-server/handlers.ts`), which are declared against that
|
||||
* origin, match requests issued from the Storybook iframe. msw intercepts
|
||||
* before the request leaves the page, so the cross-origin URL never hits the
|
||||
* network and CORS never applies.
|
||||
*
|
||||
* The annotation checks the module's shape against the real one, so a value
|
||||
* added to `constants/env` fails to compile here rather than at render.
|
||||
*/
|
||||
const libEnv: typeof import('constants/env') = {
|
||||
ENVIRONMENT: {
|
||||
baseURL: 'http://localhost',
|
||||
wsURL: 'ws://localhost',
|
||||
},
|
||||
};
|
||||
|
||||
export const { ENVIRONMENT } = libEnv;
|
||||
20
frontend/src/storybook/mocks/logEvent.mock.ts
Normal file
20
frontend/src/storybook/mocks/logEvent.mock.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
/**
|
||||
* Replaces `api/common/logEvent` in Storybook (aliased in `.storybook/main.ts`)
|
||||
* so analytics never leave the iframe. Stories can assert on the calls:
|
||||
* `import logEvent from 'api/common/logEvent'` then `expect(logEvent)...`.
|
||||
*
|
||||
* The annotation checks the module's shape against the real one, so a change to
|
||||
* `logEvent`'s signature fails to compile here rather than at render.
|
||||
*/
|
||||
const libLogEvent: typeof import('api/common/logEvent') = {
|
||||
default: fn(async () => ({
|
||||
statusCode: 200 as const,
|
||||
error: null,
|
||||
message: 'success',
|
||||
payload: { status: 'success', data: '' },
|
||||
})).mockName('logEvent'),
|
||||
};
|
||||
|
||||
export default libLogEvent.default;
|
||||
105
frontend/src/storybook/msw/__mockdata__/appShell.ts
Normal file
105
frontend/src/storybook/msw/__mockdata__/appShell.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
/**
|
||||
* Wire-shaped payloads for the endpoints the app shell calls on every route.
|
||||
* Shapes follow the fields the components read, not the full generated DTOs.
|
||||
*/
|
||||
|
||||
export const baseUserPreferences: UserPreference[] = [
|
||||
{
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
description: 'Keep the side navigation pinned open',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['user'],
|
||||
value: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const userPreferencesResponse = (
|
||||
preferences: UserPreference[] = baseUserPreferences,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: preferences,
|
||||
});
|
||||
|
||||
export const zeusHostsResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
hosts: [{ url: 'https://ingest.us.signoz.cloud:443', is_default: true }],
|
||||
},
|
||||
};
|
||||
|
||||
export const versionResponse = {
|
||||
version: 'v0.99.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
};
|
||||
|
||||
export const latestGithubReleaseResponse = {
|
||||
tag_name: 'v0.99.0',
|
||||
name: 'v0.99.0',
|
||||
html_url: 'https://github.com/SigNoz/signoz/releases/tag/v0.99.0',
|
||||
};
|
||||
|
||||
export const globalConfigResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
ai_assistant_url: null,
|
||||
external_url: 'https://storybook.signoz.local',
|
||||
ingestion_url: 'https://ingest.us.signoz.cloud:443',
|
||||
mcp_url: null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `ChangelogSchema` for the current version. Kept non-empty because
|
||||
* `getChangelogByVersion` treats an empty list as a failure, and media is left
|
||||
* null so no story reaches out for an image.
|
||||
*/
|
||||
export const changelogResponse = {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
documentId: 'changelog-v0-99-0',
|
||||
version: 'v0.99.0',
|
||||
release_date: '2026-08-12',
|
||||
bug_fixes:
|
||||
'Fixed dashboard variables losing their selection on refresh.\nFixed alert history pagination.',
|
||||
maintenance: 'Upgraded the query service to Go 1.24.',
|
||||
createdAt: '2026-08-12T09:00:00.000Z',
|
||||
updatedAt: '2026-08-12T09:00:00.000Z',
|
||||
publishedAt: '2026-08-12T09:00:00.000Z',
|
||||
features: [
|
||||
{
|
||||
id: 11,
|
||||
documentId: 'feature-metrics-explorer',
|
||||
title: 'Metrics explorer',
|
||||
sort_order: 1,
|
||||
createdAt: '2026-08-12T09:00:00.000Z',
|
||||
updatedAt: '2026-08-12T09:00:00.000Z',
|
||||
publishedAt: '2026-08-12T09:00:00.000Z',
|
||||
description:
|
||||
'Browse every metric you send, inspect its labels and turn it into a panel without writing a query.',
|
||||
deployment_type: 'All',
|
||||
media: null,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
documentId: 'feature-trace-funnels',
|
||||
title: 'Trace funnels',
|
||||
sort_order: 2,
|
||||
createdAt: '2026-08-12T09:00:00.000Z',
|
||||
updatedAt: '2026-08-12T09:00:00.000Z',
|
||||
publishedAt: '2026-08-12T09:00:00.000Z',
|
||||
description:
|
||||
'Measure conversion and drop-off across a multi-service request path.',
|
||||
deployment_type: 'All',
|
||||
media: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
68
frontend/src/storybook/msw/__mockdata__/queryRange.ts
Normal file
68
frontend/src/storybook/msw/__mockdata__/queryRange.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
/** Typed builders for the query_range v5 response shapes. */
|
||||
|
||||
export const queryRangeV5ScalarResponse = (
|
||||
value: number,
|
||||
queryName = 'A',
|
||||
): MetricRangePayloadV5 => ({
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: '__result_0',
|
||||
queryName,
|
||||
aggregationIndex: 0,
|
||||
columnType: 'aggregation',
|
||||
},
|
||||
],
|
||||
data: [[value]],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
},
|
||||
});
|
||||
|
||||
export const queryRangeV5EmptyResponse = (
|
||||
queryName = 'A',
|
||||
): MetricRangePayloadV5 => ({
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [{ queryName, nextCursor: '', rows: [] }],
|
||||
},
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
},
|
||||
});
|
||||
|
||||
export const queryRangeV5RawResponse = <T>(
|
||||
rows: Array<{ timestamp: string; data: T }>,
|
||||
options: { queryName?: string; hasMore?: boolean } = {},
|
||||
): MetricRangePayloadV5 => {
|
||||
const { queryName = 'A', hasMore = false } = options;
|
||||
|
||||
return {
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName,
|
||||
nextCursor: hasMore ? 'next-cursor-token' : '',
|
||||
rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rowsScanned: rows.length,
|
||||
bytesScanned: 0,
|
||||
durationMs: 0,
|
||||
stepIntervals: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
48
frontend/src/storybook/msw/appShellHandlers.ts
Normal file
48
frontend/src/storybook/msw/appShellHandlers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
changelogResponse,
|
||||
globalConfigResponse,
|
||||
latestGithubReleaseResponse,
|
||||
userPreferencesResponse,
|
||||
versionResponse,
|
||||
zeusHostsResponse,
|
||||
} from './__mockdata__/appShell';
|
||||
|
||||
/**
|
||||
* Endpoints the app shell hits on every route that the jest handlers in
|
||||
* `src/mocks-server/handlers.ts` either do not cover or answer with fixtures
|
||||
* too thin to show the shell doing its job. Resolved ahead of the shared set,
|
||||
* and a page's own control-driven handlers are resolved ahead of these.
|
||||
*/
|
||||
export const appShellHandlers = [
|
||||
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(userPreferencesResponse())),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/user/preferences/:name', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/zeus/hosts', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(zeusHostsResponse)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/global/config', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(globalConfigResponse)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/version', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(versionResponse)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'https://api.github.com/repos/signoz/signoz/releases/latest',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(latestGithubReleaseResponse)),
|
||||
),
|
||||
|
||||
rest.get('https://cms.signoz.cloud/api/release-changelogs', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(changelogResponse)),
|
||||
),
|
||||
];
|
||||
36
frontend/src/storybook/msw/handlers.ts
Normal file
36
frontend/src/storybook/msw/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { rest } from 'msw';
|
||||
import { handlers as sharedHandlers } from 'mocks-server/handlers';
|
||||
|
||||
import { appShellHandlers } from './appShellHandlers';
|
||||
|
||||
/**
|
||||
* Last resort: every axios instance is built on `ENVIRONMENT.baseURL`, which
|
||||
* `mocks/env.mock.ts` pins to `http://localhost`, so an endpoint nobody mocked
|
||||
* lands here instead of leaving the browser. Failing loudly beats a request
|
||||
* that hangs until the connection is refused.
|
||||
*/
|
||||
const unmockedApiGuard = [
|
||||
rest.all('http://localhost/api/*', (req, res, ctx) => {
|
||||
console.error(
|
||||
`[storybook] no msw handler for ${req.method} ${req.url.pathname}. Add one to the page's mocks or to src/storybook/msw/appShellHandlers.ts`,
|
||||
);
|
||||
|
||||
return res(
|
||||
ctx.status(501),
|
||||
ctx.json({ status: 'error', error: 'not mocked in Storybook' }),
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* Default handler set for every story, resolved first match wins: the
|
||||
* Storybook-only shell handlers override the jest ones where the shell needs
|
||||
* richer data, and both a page's control-driven handlers and a story's own
|
||||
* `parameters.msw.handlers` are layered on top at render time. An endpoint both
|
||||
* runners need belongs in `src/mocks-server/handlers.ts` instead.
|
||||
*/
|
||||
export const storybookHandlers = [
|
||||
...appShellHandlers,
|
||||
...sharedHandlers,
|
||||
...unmockedApiGuard,
|
||||
];
|
||||
35
frontend/src/storybook/msw/storyHandlers.ts
Normal file
35
frontend/src/storybook/msw/storyHandlers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { RequestHandler } from 'msw';
|
||||
|
||||
/**
|
||||
* Handlers may be grouped under names, so unrelated overrides in the same story
|
||||
* stay readable.
|
||||
*/
|
||||
export type StoryMswParameter =
|
||||
| RequestHandler[]
|
||||
| {
|
||||
handlers?: RequestHandler[] | Record<string, RequestHandler[] | undefined>;
|
||||
};
|
||||
|
||||
export const collectStoryHandlers = (
|
||||
msw: StoryMswParameter | undefined,
|
||||
): RequestHandler[] => {
|
||||
if (!msw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(msw)) {
|
||||
return msw;
|
||||
}
|
||||
|
||||
const { handlers } = msw;
|
||||
|
||||
if (!handlers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(handlers)
|
||||
? handlers
|
||||
: Object.values(handlers)
|
||||
.filter((group): group is RequestHandler[] => Boolean(group))
|
||||
.flat();
|
||||
};
|
||||
28
frontend/src/storybook/msw/types.ts
Normal file
28
frontend/src/storybook/msw/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
DefaultBodyType,
|
||||
PathParams,
|
||||
ResponseResolver,
|
||||
RestContext,
|
||||
RestRequest,
|
||||
} from 'msw';
|
||||
|
||||
export type MockRequest = RestRequest<DefaultBodyType, PathParams>;
|
||||
|
||||
export type MockResolver = ResponseResolver<
|
||||
MockRequest,
|
||||
RestContext,
|
||||
DefaultBodyType
|
||||
>;
|
||||
|
||||
/**
|
||||
* Resolver factory handed to a mock module's `handlers`. Endpoints declared
|
||||
* through it follow the response state, so one declaration covers the loaded,
|
||||
* loading and failed states. Endpoints that have to answer for the page to
|
||||
* render at all (ingestion detection, preferences) take a plain resolver
|
||||
* instead.
|
||||
*/
|
||||
export interface MockResponse {
|
||||
json: <TBody>(
|
||||
build: (req: MockRequest) => TBody | Promise<TBody>,
|
||||
) => MockResolver;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
right: var(--spacing-8);
|
||||
bottom: var(--spacing-8);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
max-width: 420px;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
border: 1px solid var(--accent-amber);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--base-black) 45%, transparent);
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
max-height: 160px;
|
||||
padding-left: var(--spacing-8);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
margin-bottom: var(--spacing-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { clearBlockedNavigations } from './blockedNavigationStore';
|
||||
import { useBlockedNavigations } from './useBlockedNavigations';
|
||||
|
||||
import styles from './NavigationBlockedOverlay.module.scss';
|
||||
|
||||
/**
|
||||
* Surfaces every navigation the story swallowed. Rendered by `withProviders`,
|
||||
* so any story that tries to leave the page says so instead of silently
|
||||
* doing nothing.
|
||||
*/
|
||||
function NavigationBlockedOverlay(): JSX.Element | null {
|
||||
const blockedNavigations = useBlockedNavigations();
|
||||
|
||||
if (blockedNavigations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={styles.overlay}
|
||||
aria-live="polite"
|
||||
data-testid="navigation-blocked-overlay"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text as="span" size="small" weight="semibold" color="warning">
|
||||
Navigation blocked in Storybook
|
||||
</Typography.Text>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
onClick={clearBlockedNavigations}
|
||||
data-testid="navigation-blocked-clear"
|
||||
>
|
||||
<Typography.Text as="span" size="small" weight="medium">
|
||||
clear
|
||||
</Typography.Text>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{blockedNavigations.map((navigation) => (
|
||||
<li key={navigation.id} className={styles.item}>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
{navigation.via}
|
||||
</Typography.Text>{' '}
|
||||
<Typography.Text as="span" size="small">
|
||||
→ {navigation.to}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavigationBlockedOverlay;
|
||||
40
frontend/src/storybook/navigation/blockedNavigationStore.ts
Normal file
40
frontend/src/storybook/navigation/blockedNavigationStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface BlockedNavigation {
|
||||
id: number;
|
||||
/** History method the app called, e.g. `push`, `replace`, `window.open`. */
|
||||
via: string;
|
||||
/** Target the app tried to reach, already resolved to an href. */
|
||||
to: string;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let blockedNavigations: BlockedNavigation[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const emit = (): void => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const subscribeToBlockedNavigations = (
|
||||
listener: Listener,
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return (): void => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getBlockedNavigations = (): BlockedNavigation[] =>
|
||||
blockedNavigations;
|
||||
|
||||
export const recordBlockedNavigation = (via: string, to: string): void => {
|
||||
blockedNavigations = [...blockedNavigations, { id: nextId, via, to }];
|
||||
nextId += 1;
|
||||
emit();
|
||||
};
|
||||
|
||||
export const clearBlockedNavigations = (): void => {
|
||||
blockedNavigations = [];
|
||||
emit();
|
||||
};
|
||||
65
frontend/src/storybook/navigation/containment.ts
Normal file
65
frontend/src/storybook/navigation/containment.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
History,
|
||||
LocationDescriptor,
|
||||
LocationDescriptorObject,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
import { fn, type Mock } from 'storybook/test';
|
||||
|
||||
import { recordBlockedNavigation } from './blockedNavigationStore';
|
||||
import { navigateWithinPage, storyHistory, toHref } from './pageScope';
|
||||
|
||||
const guardedNavigate = (
|
||||
via: 'push' | 'replace',
|
||||
): Mock<(to: LocationDescriptor, state?: unknown) => void> =>
|
||||
fn((to: LocationDescriptor, state?: unknown): void => {
|
||||
const target: LocationDescriptorObject =
|
||||
typeof to === 'string' ? { ...parsePath(to), state } : { state, ...to };
|
||||
|
||||
if (navigateWithinPage(target, { replace: via === 'replace' })) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordBlockedNavigation(via, toHref(to));
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const blockedRelativeNavigate = (via: string): Mock<(delta?: number) => void> =>
|
||||
fn((delta?: number): void => {
|
||||
recordBlockedNavigation(via, delta === undefined ? via : `${via}(${delta})`);
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const overriddenMethods = {
|
||||
push: guardedNavigate('push'),
|
||||
replace: guardedNavigate('replace'),
|
||||
go: blockedRelativeNavigate('go'),
|
||||
goBack: blockedRelativeNavigate('goBack'),
|
||||
goForward: blockedRelativeNavigate('goForward'),
|
||||
} as const;
|
||||
|
||||
type OverriddenMethod = keyof typeof overriddenMethods;
|
||||
|
||||
const isOverriddenMethod = (prop: string | symbol): prop is OverriddenMethod =>
|
||||
typeof prop === 'string' && prop in overriddenMethods;
|
||||
|
||||
/**
|
||||
* What the app sees in place of `lib/history`. Reads (`location`, `action`,
|
||||
* `listen`) are proxied to the story's memory history so react-router renders
|
||||
* normally; navigation goes through `pageScope`, and whatever would leave the
|
||||
* page is swallowed and reported to `blockedNavigationStore`.
|
||||
* `react-router-dom-v5-compat` drives its `useNavigate` through this same
|
||||
* object, so `useSafeNavigate` is covered too.
|
||||
*/
|
||||
export const containedHistory: History = new Proxy(storyHistory, {
|
||||
get(target, prop, receiver) {
|
||||
if (isOverriddenMethod(prop)) {
|
||||
return overriddenMethods[prop];
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
export const hasInAppHistory = (): boolean => false;
|
||||
|
||||
export const resetStoryHistory = (): void => {
|
||||
Object.values(overriddenMethods).forEach((method) => method.mockClear());
|
||||
};
|
||||
20
frontend/src/storybook/navigation/history.alias.ts
Normal file
20
frontend/src/storybook/navigation/history.alias.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
containedHistory,
|
||||
hasInAppHistory as containedHasInAppHistory,
|
||||
} from './containment';
|
||||
|
||||
/**
|
||||
* Replaces `lib/history` in Storybook (aliased in `.storybook/main.ts`). The
|
||||
* containment rule lives in `containment.ts`; this file only has to keep the
|
||||
* module's shape, and the annotation is what checks it against the real one.
|
||||
* An export added to `lib/history` fails to compile here instead of failing at
|
||||
* render in whichever component imports it.
|
||||
*/
|
||||
const libHistory: typeof import('lib/history') = {
|
||||
default: containedHistory,
|
||||
hasInAppHistory: containedHasInAppHistory,
|
||||
};
|
||||
|
||||
export default libHistory.default;
|
||||
|
||||
export const { hasInAppHistory } = libHistory;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { recordBlockedNavigation } from './blockedNavigationStore';
|
||||
import {
|
||||
isBlockableHref,
|
||||
navigateWithinPage,
|
||||
toStoryLocation,
|
||||
} from './pageScope';
|
||||
|
||||
/**
|
||||
* Takes over the navigations that never reach the story's history: plain anchors
|
||||
* and `window.open` (used by `useSafeNavigate` for `newTab`). An anchor staying
|
||||
* on the story's page is applied, because letting the browser follow it would
|
||||
* navigate the iframe away and unmount the story. Anything else is reported as
|
||||
* blocked. Returns the teardown.
|
||||
*/
|
||||
export const interceptExternalNavigation = (): (() => void) => {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.('a[href]');
|
||||
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
|
||||
if (!isBlockableHref(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ahead of react-router's own `Link` handler, which skips a click that is
|
||||
// already handled, so an in-page link is never pushed twice.
|
||||
event.preventDefault();
|
||||
|
||||
const to = toStoryLocation(href, window.location.href);
|
||||
|
||||
if (to && navigateWithinPage(to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordBlockedNavigation('link', href);
|
||||
};
|
||||
|
||||
document.addEventListener('click', onClick, true);
|
||||
|
||||
const originalOpen = window.open;
|
||||
window.open = (url?: string | URL): null => {
|
||||
recordBlockedNavigation('window.open', String(url ?? ''));
|
||||
return null;
|
||||
};
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener('click', onClick, true);
|
||||
window.open = originalOpen;
|
||||
};
|
||||
};
|
||||
85
frontend/src/storybook/navigation/pageScope.ts
Normal file
85
frontend/src/storybook/navigation/pageScope.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
createMemoryHistory,
|
||||
LocationDescriptor,
|
||||
MemoryHistory,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
|
||||
/**
|
||||
* The story's own history. A story renders one page, so this never leaves it:
|
||||
* `pageScope` decides what counts as staying, and `containment.ts` is what the
|
||||
* app sees in place of `lib/history`.
|
||||
*/
|
||||
export const storyHistory: MemoryHistory = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
export const toHref = (to: LocationDescriptor): string =>
|
||||
typeof to === 'string' ? to : storyHistory.createHref(to);
|
||||
|
||||
/** `/home/` and `/home` are the same page as far as a story is concerned. */
|
||||
const normalizePathname = (pathname: string): string =>
|
||||
pathname.length > 1 && pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
|
||||
export const isSamePagePathname = (pathname: string | undefined): boolean =>
|
||||
!pathname ||
|
||||
normalizePathname(pathname) ===
|
||||
normalizePathname(storyHistory.location.pathname);
|
||||
|
||||
/**
|
||||
* Applies a navigation that stays on the story's page: a query-param or hash
|
||||
* change, which is how tabs, filters and pagination are driven. Returns false
|
||||
* when the target is another page, leaving the caller to report it as blocked.
|
||||
*/
|
||||
export const navigateWithinPage = (
|
||||
to: LocationDescriptor,
|
||||
{ replace = false }: { replace?: boolean } = {},
|
||||
): boolean => {
|
||||
const target = typeof to === 'string' ? parsePath(to) : to;
|
||||
|
||||
if (!isSamePagePathname(target.pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storyHistory[replace ? 'replace' : 'push']({
|
||||
...target,
|
||||
pathname: storyHistory.location.pathname,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Places the story at a route without going through the block. */
|
||||
export const setStoryLocation = (to: LocationDescriptor): void => {
|
||||
storyHistory.replace(to);
|
||||
};
|
||||
|
||||
/**
|
||||
* An in-page anchor or a `javascript:` href is the browser's business, not the
|
||||
* story's: it is left alone rather than applied or reported.
|
||||
*/
|
||||
export const isBlockableHref = (href: string): boolean =>
|
||||
href.length > 0 && !href.startsWith('#') && !href.startsWith('javascript:');
|
||||
|
||||
/**
|
||||
* An anchor href as a location the story's history understands, or `undefined`
|
||||
* when it leads off the page. Relative hrefs (`?tab=logs`) carry no pathname and
|
||||
* stay on the page; app links do, and are resolved against the iframe so an
|
||||
* off-site href fails the host check before its path is compared.
|
||||
*/
|
||||
export const toStoryLocation = (
|
||||
href: string,
|
||||
base: string,
|
||||
): string | undefined => {
|
||||
if (href.startsWith('?')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const url = new URL(href, base);
|
||||
|
||||
return url.host === new URL(base).host
|
||||
? `${url.pathname}${url.search}${url.hash}`
|
||||
: undefined;
|
||||
};
|
||||
10
frontend/src/storybook/navigation/useBlockedNavigations.ts
Normal file
10
frontend/src/storybook/navigation/useBlockedNavigations.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
BlockedNavigation,
|
||||
getBlockedNavigations,
|
||||
subscribeToBlockedNavigations,
|
||||
} from './blockedNavigationStore';
|
||||
|
||||
export const useBlockedNavigations = (): BlockedNavigation[] =>
|
||||
useSyncExternalStore(subscribeToBlockedNavigations, getBlockedNavigations);
|
||||
92
frontend/src/storybook/providers/StorybookProviders.tsx
Normal file
92
frontend/src/storybook/providers/StorybookProviders.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ReactNode, useEffect, useMemo } from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import AppHarness from '@/harness/AppHarness';
|
||||
import history from 'lib/history';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { createStoryAppContext } from '../mocks/createStoryAppContext';
|
||||
import { interceptExternalNavigation } from '../navigation/interceptExternalNavigation';
|
||||
import NavigationBlockedOverlay from '../navigation/NavigationBlockedOverlay';
|
||||
import { ResolvedStoryConfig } from '../types';
|
||||
import { createStorybookQueryClient } from './createStorybookQueryClient';
|
||||
import { createStorybookStore } from './createStorybookStore';
|
||||
import { useStoryRoute } from './useStoryRoute';
|
||||
|
||||
interface StorybookProvidersProps extends ResolvedStoryConfig {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the role `useAppContext()` yields to `<body data-signoz-context-role>`,
|
||||
* next to the `data-signoz-story-role` the runtime resolved. Both come from the
|
||||
* same access grant, so a disagreement means the story is reading a different
|
||||
* `AppContext` than the one the runtime filled.
|
||||
*/
|
||||
function StoryContextProbe(): null {
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.dataset.signozContextRole = user?.role ?? '';
|
||||
}, [user?.role]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Storybook adapter over `AppHarness`: the app's provider tree, with the
|
||||
* router on the contained history, nuqs on its testing adapter, and a fresh
|
||||
* store and query cache per story.
|
||||
*/
|
||||
function StorybookProviders({
|
||||
children,
|
||||
role,
|
||||
appContext,
|
||||
queryBuilder,
|
||||
route = '/',
|
||||
reduxState,
|
||||
}: StorybookProvidersProps): JSX.Element {
|
||||
const searchParams = useStoryRoute(route);
|
||||
const queryClient = useMemo(createStorybookQueryClient, []);
|
||||
const store = useMemo(() => createStorybookStore(reduxState), [reduxState]);
|
||||
const appContextValue = useMemo(
|
||||
() => createStoryAppContext(role, appContext),
|
||||
[role, appContext],
|
||||
);
|
||||
|
||||
useEffect(interceptExternalNavigation, []);
|
||||
|
||||
return (
|
||||
<AppHarness
|
||||
appContext={appContextValue}
|
||||
store={store}
|
||||
queryClient={queryClient}
|
||||
queryBuilder={queryBuilder}
|
||||
router={(routed): ReactNode => (
|
||||
<Router history={history}>
|
||||
<CompatRouter>{routed}</CompatRouter>
|
||||
</Router>
|
||||
)}
|
||||
searchParams={(scoped): ReactNode => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
{scoped}
|
||||
</NuqsTestingAdapter>
|
||||
)}
|
||||
overlays={
|
||||
<>
|
||||
<StoryContextProbe />
|
||||
{/* The AuthZ dev modal and its floating indicator, so a story can
|
||||
override single permissions by hand. */}
|
||||
<CmdKPalette userRole={role} />
|
||||
<NavigationBlockedOverlay />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppHarness>
|
||||
);
|
||||
}
|
||||
|
||||
export default StorybookProviders;
|
||||
12
frontend/src/storybook/providers/applyThemeBodyClass.ts
Normal file
12
frontend/src/storybook/providers/applyThemeBodyClass.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
|
||||
import type { StoryTheme } from '../types';
|
||||
|
||||
export const applyThemeBodyClass = (theme: StoryTheme): void => {
|
||||
const isDarkMode = theme === THEME_MODE.DARK;
|
||||
|
||||
document.body.dataset.theme = 'default';
|
||||
document.body.classList.toggle('darkMode', isDarkMode);
|
||||
document.body.classList.toggle('dark', isDarkMode);
|
||||
document.body.classList.toggle('lightMode', !isDarkMode);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
|
||||
/**
|
||||
* One client per story: retries off so a deliberately failing handler renders
|
||||
* its error state immediately, and no cache carried over between stories.
|
||||
*/
|
||||
export const createStorybookQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
19
frontend/src/storybook/providers/createStorybookStore.ts
Normal file
19
frontend/src/storybook/providers/createStorybookStore.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
applyMiddleware,
|
||||
legacy_createStore as createStore,
|
||||
Store,
|
||||
} from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import reducers, { AppState } from 'store/reducers';
|
||||
|
||||
/**
|
||||
* A fresh store per story, seeded with the real reducers so dispatches keep
|
||||
* working, unlike the mock store used in jest. Nothing leaks between stories.
|
||||
*/
|
||||
export const createStorybookStore = (reduxState?: Partial<AppState>): Store =>
|
||||
createStore(
|
||||
reducers,
|
||||
reduxState as ReturnType<typeof reducers> | undefined,
|
||||
applyMiddleware(thunk),
|
||||
);
|
||||
14
frontend/src/storybook/providers/useStoryRoute.ts
Normal file
14
frontend/src/storybook/providers/useStoryRoute.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { setStoryLocation } from '../navigation/pageScope';
|
||||
|
||||
/**
|
||||
* Places the story's history at its route before the router mounts and hands
|
||||
* back the search params for the nuqs testing adapter.
|
||||
*/
|
||||
export const useStoryRoute = (route: string): URLSearchParams =>
|
||||
useMemo(() => {
|
||||
setStoryLocation(route);
|
||||
const [, search = ''] = route.split('?');
|
||||
return new URLSearchParams(search);
|
||||
}, [route]);
|
||||
173
frontend/src/storybook/runtime/resolveStory.ts
Normal file
173
frontend/src/storybook/runtime/resolveStory.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { RequestHandler, SetupWorker } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import type { AnyStoryMocks, StoryMockArgs } from '../controls/types';
|
||||
import { globalMocks, type GlobalMockArgs } from '../globals';
|
||||
import { storybookHandlers } from '../msw/handlers';
|
||||
import { collectStoryHandlers } from '../msw/storyHandlers';
|
||||
import type { MockResolver, MockResponse } from '../msw/types';
|
||||
import { applyThemeBodyClass } from '../providers/applyThemeBodyClass';
|
||||
import type {
|
||||
ResolvedStoryConfig,
|
||||
SignozStoryConfig,
|
||||
SignozStoryParameters,
|
||||
StoryOwnedConfig,
|
||||
StoryRole,
|
||||
StoryTheme,
|
||||
} from '../types';
|
||||
import { respondWith, type ResponseState } from './responseState';
|
||||
|
||||
/** Args of a page story: its own controls plus the ones every story carries. */
|
||||
export type PageStoryArgs<TMocks extends AnyStoryMocks> = GlobalMockArgs &
|
||||
StoryMockArgs<TMocks>;
|
||||
|
||||
/**
|
||||
* What Storybook hands both the loader and the decorator. Declared structurally,
|
||||
* so the runtime does not depend on which lifecycle hook is calling it.
|
||||
*/
|
||||
export interface StoryRuntimeContext {
|
||||
id: string;
|
||||
parameters: SignozStoryParameters;
|
||||
args: Record<string, unknown>;
|
||||
globals?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StoryWorld {
|
||||
config: ResolvedStoryConfig;
|
||||
theme: StoryTheme;
|
||||
/**
|
||||
* Changes with every control a mock reads. The provider tree is keyed on it so
|
||||
* the story remounts with a fresh query cache instead of showing what the
|
||||
* previous control values resolved to.
|
||||
*/
|
||||
key: string;
|
||||
/** In resolution order: msw answers with the first handler that matches. */
|
||||
handlers: RequestHandler[];
|
||||
install(worker: SetupWorker): void;
|
||||
/**
|
||||
* Everything that has to be in place before the provider tree mounts:
|
||||
* module-level app state, the theme `ThemeProvider` reads at boot, and the
|
||||
* `<body>` markers that say what the controls resolved to.
|
||||
*/
|
||||
apply(): void;
|
||||
}
|
||||
|
||||
const withoutMocks = (config: SignozStoryConfig): StoryOwnedConfig => {
|
||||
const owned = { ...config };
|
||||
delete (owned as SignozStoryConfig).mocks;
|
||||
return owned;
|
||||
};
|
||||
|
||||
const mergeConfigs = (configs: StoryOwnedConfig[]): StoryOwnedConfig =>
|
||||
configs.reduce(
|
||||
(merged, config) => ({
|
||||
...merged,
|
||||
...config,
|
||||
appContext: { ...merged.appContext, ...config.appContext },
|
||||
}),
|
||||
{} as StoryOwnedConfig,
|
||||
);
|
||||
|
||||
const createMockResponse = (state: ResponseState): MockResponse => ({
|
||||
json: (build): MockResolver => respondWith(state, build),
|
||||
});
|
||||
|
||||
const firstAnswer = <TAnswer>(
|
||||
answers: (TAnswer | undefined)[],
|
||||
fallback: TAnswer,
|
||||
): TAnswer =>
|
||||
answers.find((answer): answer is TAnswer => answer !== undefined) ?? fallback;
|
||||
|
||||
const resolveWorld = (context: StoryRuntimeContext): StoryWorld => {
|
||||
const { parameters, args } = context;
|
||||
const storyConfig = parameters.signoz ?? {};
|
||||
|
||||
// A page's own mocks resolve ahead of the global ones, so the page wins every
|
||||
// question both answer.
|
||||
const members: AnyStoryMocks[] = storyConfig.mocks
|
||||
? [storyConfig.mocks, ...globalMocks.members]
|
||||
: [...globalMocks.members];
|
||||
|
||||
const values = members.map((mocks) => mocks.read(args));
|
||||
|
||||
const response = createMockResponse(
|
||||
firstAnswer(
|
||||
members.map((mocks, index) => mocks.responseState?.(values[index])),
|
||||
'loaded',
|
||||
),
|
||||
);
|
||||
|
||||
const role = firstAnswer(
|
||||
members.map((mocks, index) => mocks.role?.(values[index])),
|
||||
USER_ROLES.ADMIN as StoryRole,
|
||||
);
|
||||
|
||||
const config = mergeConfigs([
|
||||
// Reversed, so a page's own config wins over the global one, and the
|
||||
// story's own `parameters.signoz` is the last word over both.
|
||||
...members
|
||||
.map((mocks, index) => mocks.config?.(values[index]) ?? {})
|
||||
.reverse(),
|
||||
withoutMocks(storyConfig),
|
||||
]);
|
||||
|
||||
const theme = config.theme ?? (context.globals?.theme as StoryTheme) ?? 'dark';
|
||||
|
||||
const valuesKey = JSON.stringify(values);
|
||||
|
||||
const handlers = [
|
||||
// A story that declares its own handler always wins.
|
||||
...collectStoryHandlers(parameters.msw),
|
||||
...members.flatMap(
|
||||
(mocks, index) => mocks.handlers?.(values[index], response) ?? [],
|
||||
),
|
||||
// Shell endpoints, the jest handlers, then the catch-all that logs.
|
||||
...storybookHandlers,
|
||||
];
|
||||
|
||||
return {
|
||||
config: { ...config, role },
|
||||
theme,
|
||||
key: `${theme}|${valuesKey}`,
|
||||
handlers,
|
||||
install: (worker): void => {
|
||||
worker.resetHandlers(...handlers);
|
||||
},
|
||||
apply: (): void => {
|
||||
members.forEach((mocks, index) => mocks.effect?.(values[index]));
|
||||
|
||||
// `ThemeProvider` seeds its state from localStorage, so the value has to
|
||||
// be in place before it mounts; `key` forces the remount on a change.
|
||||
set(LOCALSTORAGE.THEME, theme);
|
||||
applyThemeBodyClass(theme);
|
||||
|
||||
// Readable from the Elements panel, so what the controls resolved to can
|
||||
// be checked without reaching into the story store.
|
||||
document.body.dataset.signozStoryRole = role;
|
||||
document.body.dataset.signozStoryMocks = valuesKey;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
let memo: { signature: string; world: StoryWorld } | undefined;
|
||||
|
||||
/**
|
||||
* The single owner of "story context → the world the story renders in". Both the
|
||||
* preview loader and the provider decorator ask for the same story render, so
|
||||
* the result is memoised on what the story is and what its controls hold.
|
||||
*/
|
||||
export const resolveStory = (context: StoryRuntimeContext): StoryWorld => {
|
||||
const signature = JSON.stringify([
|
||||
context.id,
|
||||
context.args,
|
||||
context.globals?.theme,
|
||||
]);
|
||||
|
||||
if (memo?.signature !== signature) {
|
||||
memo = { signature, world: resolveWorld(context) };
|
||||
}
|
||||
|
||||
return memo.world;
|
||||
};
|
||||
33
frontend/src/storybook/runtime/responseState.ts
Normal file
33
frontend/src/storybook/runtime/responseState.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { DefaultBodyType } from 'msw';
|
||||
|
||||
import type { MockRequest, MockResolver } from '../msw/types';
|
||||
|
||||
export const RESPONSE_STATES = ['loaded', 'loading', 'error'] as const;
|
||||
|
||||
export type ResponseState = (typeof RESPONSE_STATES)[number];
|
||||
|
||||
/**
|
||||
* The three answers every mocked endpoint can give, in one place: the payload
|
||||
* the caller built, a request that never resolves, or a failure. A story reaches
|
||||
* all three by turning one control, and a fourth state added here reaches every
|
||||
* endpoint declared through it.
|
||||
*/
|
||||
export const respondWith =
|
||||
<TBody>(
|
||||
state: ResponseState,
|
||||
build: (req: MockRequest) => TBody | Promise<TBody>,
|
||||
): MockResolver =>
|
||||
async (req, res, ctx) => {
|
||||
if (state === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.json({ status: 'error', error: 'storybook: forced failure' }),
|
||||
);
|
||||
}
|
||||
|
||||
return res(ctx.status(200), ctx.json((await build(req)) as DefaultBodyType));
|
||||
};
|
||||
6
frontend/src/storybook/storybook-root.scss
Normal file
6
frontend/src/storybook/storybook-root.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
/* `styles.scss` sizes `#root`; the Storybook preview mounts into
|
||||
`#storybook-root`, which needs the same box for `AppLayout` to lay out. */
|
||||
#storybook-root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
50
frontend/src/storybook/types.ts
Normal file
50
frontend/src/storybook/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { AnyStoryMocks } from './controls/types';
|
||||
import type { StoryMswParameter } from './msw/storyHandlers';
|
||||
|
||||
export type StoryTheme = 'dark' | 'light';
|
||||
|
||||
export type StoryRole = ROLES;
|
||||
|
||||
/** What a story or a mock module may set about the tree it renders in. */
|
||||
export interface StoryOwnedConfig {
|
||||
/** Deep-merged over the default mocked `AppContext` value. */
|
||||
appContext?: Partial<IAppContext>;
|
||||
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
|
||||
queryBuilder?: Partial<QueryBuilderContextType>;
|
||||
/** Initial route, search included, e.g. `/home?relativeTime=1h`. */
|
||||
route?: string;
|
||||
/** Overrides the theme toolbar for this story. */
|
||||
theme?: StoryTheme;
|
||||
/**
|
||||
* When set, the real redux store is seeded with this state instead of the
|
||||
* reducers' own initial state.
|
||||
*/
|
||||
reduxState?: Partial<AppState>;
|
||||
}
|
||||
|
||||
export interface SignozStoryConfig extends StoryOwnedConfig {
|
||||
/**
|
||||
* The page's control-driven mocks, declared with `defineStoryMocks` and
|
||||
* attached by spreading `storyMocks(...)` into the meta.
|
||||
*/
|
||||
mocks?: AnyStoryMocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the story runtime hands the provider tree. `role` is derived from the
|
||||
* Access controls rather than set by a story, so the mocked `AppContext` and the
|
||||
* mocked `authz/check` endpoint always answer from the same grant.
|
||||
*/
|
||||
export interface ResolvedStoryConfig extends StoryOwnedConfig {
|
||||
role: StoryRole;
|
||||
}
|
||||
|
||||
export interface SignozStoryParameters {
|
||||
signoz?: SignozStoryConfig;
|
||||
msw?: StoryMswParameter;
|
||||
}
|
||||
170
frontend/src/tests/fixtures/appContextMock.ts
vendored
Normal file
170
frontend/src/tests/fixtures/appContextMock.ts
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
/**
|
||||
* Factory for the spies assigned to the callable members of `IAppContext`.
|
||||
* Jest passes `jest.fn`, Storybook passes `fn` from `storybook/test`; the
|
||||
* fixture itself stays free of any test-runner import so both can consume it.
|
||||
*/
|
||||
export type SpyFactory = () => (...args: unknown[]) => void;
|
||||
|
||||
const noopSpyFactory: SpyFactory = () => (): void => {};
|
||||
|
||||
export const defaultFeatureFlags = [
|
||||
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function createAppContextMock(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
createSpy: SpyFactory = noopSpyFactory,
|
||||
): IAppContext {
|
||||
return {
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
state: LicenseState.ACTIVATED,
|
||||
status: LicenseStatus.VALID,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
created_at: '0',
|
||||
plan: {
|
||||
created_at: '0',
|
||||
description: '',
|
||||
is_active: true,
|
||||
name: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan_id: '0',
|
||||
free_until: '0',
|
||||
updated_at: '0',
|
||||
valid_from: 0,
|
||||
valid_until: 0,
|
||||
},
|
||||
trialInfo: {
|
||||
trialStart: -1,
|
||||
trialEnd: -1,
|
||||
onTrial: false,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicenseFetchError: null,
|
||||
changelog: null,
|
||||
user: {
|
||||
accessJwt: 'some-token',
|
||||
refreshJwt: 'some-refresh-token',
|
||||
id: 'some-user-id',
|
||||
email: 'does-not-matter@signoz.io',
|
||||
displayName: 'John Doe',
|
||||
createdAt: 1732544623,
|
||||
organization: 'Nightswatch',
|
||||
orgId: 'does-not-matter-id',
|
||||
role: role as ROLES,
|
||||
},
|
||||
org: [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: 'does-not-matter-id',
|
||||
displayName: 'Pentagon',
|
||||
},
|
||||
],
|
||||
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
|
||||
isFetchingUser: false,
|
||||
userFetchError: null,
|
||||
featureFlags: defaultFeatureFlags,
|
||||
isFetchingFeatureFlags: false,
|
||||
featureFlagsFetchError: null,
|
||||
hostsData: null,
|
||||
isFetchingHosts: false,
|
||||
hostsFetchError: null,
|
||||
orgPreferences: [
|
||||
{
|
||||
name: ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
description: 'Organisation Onboarding',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: createSpy(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: createSpy(),
|
||||
updateOrg: createSpy(),
|
||||
updateOrgPreferences: createSpy(),
|
||||
activeLicenseRefetch: createSpy(),
|
||||
updateChangelog: createSpy(),
|
||||
toggleChangelogModal: createSpy(),
|
||||
versionData: {
|
||||
version: '1.0.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
...appContextOverrides,
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
@@ -19,16 +17,10 @@ import {
|
||||
} from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
// import { MemoryRouter as V5MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
|
||||
// Mock ResizeObserver
|
||||
@@ -106,154 +98,13 @@ jest.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const defaultFeatureFlags = [
|
||||
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
export { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
|
||||
export function getAppContextMock(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
): IAppContext {
|
||||
return {
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
state: LicenseState.ACTIVATED,
|
||||
status: LicenseStatus.VALID,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
created_at: '0',
|
||||
plan: {
|
||||
created_at: '0',
|
||||
description: '',
|
||||
is_active: true,
|
||||
name: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan_id: '0',
|
||||
free_until: '0',
|
||||
updated_at: '0',
|
||||
valid_from: 0,
|
||||
valid_until: 0,
|
||||
},
|
||||
trialInfo: {
|
||||
trialStart: -1,
|
||||
trialEnd: -1,
|
||||
onTrial: false,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicenseFetchError: null,
|
||||
changelog: null,
|
||||
user: {
|
||||
accessJwt: 'some-token',
|
||||
refreshJwt: 'some-refresh-token',
|
||||
id: 'some-user-id',
|
||||
email: 'does-not-matter@signoz.io',
|
||||
displayName: 'John Doe',
|
||||
createdAt: 1732544623,
|
||||
organization: 'Nightswatch',
|
||||
orgId: 'does-not-matter-id',
|
||||
role: role as ROLES,
|
||||
},
|
||||
org: [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: 'does-not-matter-id',
|
||||
displayName: 'Pentagon',
|
||||
},
|
||||
],
|
||||
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
|
||||
isFetchingUser: false,
|
||||
userFetchError: null,
|
||||
featureFlags: defaultFeatureFlags,
|
||||
isFetchingFeatureFlags: false,
|
||||
featureFlagsFetchError: null,
|
||||
hostsData: null,
|
||||
isFetchingHosts: false,
|
||||
hostsFetchError: null,
|
||||
orgPreferences: [
|
||||
{
|
||||
name: ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
description: 'Organisation Onboarding',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: jest.fn(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: jest.fn(),
|
||||
updateOrg: jest.fn(),
|
||||
updateOrgPreferences: jest.fn(),
|
||||
activeLicenseRefetch: jest.fn(),
|
||||
updateChangelog: jest.fn(),
|
||||
toggleChangelogModal: jest.fn(),
|
||||
versionData: {
|
||||
version: '1.0.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
...appContextOverrides,
|
||||
};
|
||||
return createAppContextMock(role, appContextOverrides, () => jest.fn());
|
||||
}
|
||||
|
||||
export function AllTheProviders({
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
],
|
||||
"include": [
|
||||
"./src",
|
||||
"./.storybook",
|
||||
"./__mocks__",
|
||||
"./babel.config.cjs",
|
||||
"./jest.config.ts",
|
||||
|
||||
Reference in New Issue
Block a user