Compare commits

...

3 Commits

Author SHA1 Message Date
Vinícius Lourenço
8cc685bfeb feat(storybook): add story for home page 2026-08-20 19:34:21 -03:00
Vinícius Lourenço
9857737c1e feat(storybook): add skill to create stories for pages 2026-08-20 19:34:20 -03:00
Vinícius Lourenço
4a41b0db33 feat(storybook): add initial support for storybook 2026-08-20 19:34:20 -03:00
58 changed files with 5170 additions and 156 deletions

View 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

View 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.

View File

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

View File

@@ -0,0 +1,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
View File

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

View 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;

View File

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

View File

@@ -0,0 +1,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;

View 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)
}

View File

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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

@@ -0,0 +1,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) },
}),
});

View 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' },
};

View 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 } }],
},
},
],
},
});

View 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.

View 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),
};
};

View 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)),
});

View 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],
},
});

View 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 } },
});

View 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;

View 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>
);

View 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>
);
};

View 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>;

View 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>;

View 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;

View 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());

View 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;

View 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;

View 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,
},
],
},
],
};

View 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: {},
},
},
};
};

View 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)),
),
];

View 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,
];

View 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();
};

View 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;
}

View File

@@ -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;
}

View File

@@ -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;

View 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();
};

View 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());
};

View 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;

View File

@@ -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;
};
};

View 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;
};

View File

@@ -0,0 +1,10 @@
import { useSyncExternalStore } from 'react';
import {
BlockedNavigation,
getBlockedNavigations,
subscribeToBlockedNavigations,
} from './blockedNavigationStore';
export const useBlockedNavigations = (): BlockedNavigation[] =>
useSyncExternalStore(subscribeToBlockedNavigations, getBlockedNavigations);

View 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;

View 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);
};

View File

@@ -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,
},
},
});

View 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),
);

View 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]);

View 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;
};

View 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));
};

View 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;
}

View 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;
}

View 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,
};
}

View File

@@ -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({

View File

@@ -76,6 +76,7 @@
],
"include": [
"./src",
"./.storybook",
"./__mocks__",
"./babel.config.cjs",
"./jest.config.ts",