mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-10 21:40:41 +01:00
Compare commits
7 Commits
promql-rem
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cd4d31b4 | ||
|
|
2387266df5 | ||
|
|
a6346183e8 | ||
|
|
aed6f3b111 | ||
|
|
8fa9178b6c | ||
|
|
8e00c04056 | ||
|
|
ad68a1b991 |
@@ -7,6 +7,7 @@ paths:
|
||||
|
||||
- When making Go changes, always ensure they follow the contributing guildelines in [`docs/contributing/go/`](../../docs/contributing/go/).
|
||||
- Look for existing patterns in the codebase for any change before implementing the changes.
|
||||
- Any ClickHouse identifier or literal built from a name or a value goes through `pkg/clickhousesql`; compiled sqlbuilder text is wrapped with `sqlbuilder.Escape` once. See [`docs/contributing/go/clickhousesql.md`](../../docs/contributing/go/clickhousesql.md).
|
||||
- If any API contract is modified, generate the OpenAPI specs with `make gen-openapi-specs`.
|
||||
- Always keep the OpenAPI spec generated in a separate commit, so the whole commit can be dropped in case of conflicts during merge. Do not try to resolve conflict in generated files, instead just generate them again.
|
||||
- Avoid breaking function calls unncessarily into multilines for couple of arguments.
|
||||
|
||||
95
.claude/skills/signoz-page-story/SKILL.md
Normal file
95
.claude/skills/signoz-page-story/SKILL.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: signoz-page-story
|
||||
description: Explore a SigNoz page, map the endpoints, states and query params it has, then write its Storybook page story with control-driven msw mocks that reach every state. Use when asked to create, extend or review a Storybook story for a page under frontend/src/pages, to add controls to an existing page story, or to write defineStoryMocks handlers and mock data for a page.
|
||||
---
|
||||
|
||||
# SigNoz page stories
|
||||
|
||||
A page story renders the real page inside the real app shell against msw, and its
|
||||
controls panel can reach every state the page has. The panel is the deliverable,
|
||||
not the story list.
|
||||
|
||||
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
|
||||
control builders, module mocks, navigation). Read it first; this skill is the
|
||||
process on top of it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
|
||||
the inventory (endpoints, states, params, permissions, caps) before writing
|
||||
code. No inventory, no story.
|
||||
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
|
||||
names the endpoints step 1 missed.
|
||||
3. **Inventory to controls**: [references/controls.md](references/controls.md).
|
||||
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's
|
||||
mocks module.
|
||||
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
|
||||
report the story as done without it.
|
||||
|
||||
## 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**: every story file for a page lives under
|
||||
`src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`,
|
||||
payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing
|
||||
page-specific in `src/storybook/controls/`.
|
||||
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file
|
||||
under a `__story_mockdata__/` open with this banner, above the imports:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
```
|
||||
|
||||
The root `.gitattributes` marks both paths `linguist-generated=true`, so the
|
||||
reviewer gets them collapsed and spends the attention on the rendered page. The
|
||||
story file is the human surface and never carries the banner. A file with the
|
||||
banner has to stay regenerable from the page alone: no page knowledge in
|
||||
`src/storybook/`, and builders typed from `src/api/generated` where the endpoint
|
||||
has types, so a contract change is a compile error instead of a mock that lies.
|
||||
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
|
||||
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
|
||||
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed
|
||||
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
|
||||
writing a response shape inline, check if a builder exists; if not and the
|
||||
shape will repeat, add it there. Page-specific builders stay in the page's
|
||||
`__story_mockdata__/`.
|
||||
- **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
|
||||
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
|
||||
- [ ] every control flipped once, its effect seen on screen
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled
|
||||
request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
|
||||
`pnpm exec oxfmt --check <files>` all clean. The repo has no
|
||||
prettier: `pnpm exec prettier` prints a pass while exiting 254
|
||||
165
.claude/skills/signoz-page-story/references/controls.md
Normal file
165
.claude/skills/signoz-page-story/references/controls.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 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` |
|
||||
| the page's mocks, from the story | `./<Page>.stories.mocks` |
|
||||
| `queryRangeV5ScalarResponse`, `queryRangeV5RawResponse`, etc. | `@/storybook/msw/__story_mockdata__/queryRange` |
|
||||
|
||||
## Which builder
|
||||
|
||||
`src/storybook/controls/controls.ts`:
|
||||
|
||||
| The state is | Builder |
|
||||
| --- | --- |
|
||||
| on or off (a signal ingesting, a feature present) | `toggleControl` |
|
||||
| how many rows a list has | `countControl` |
|
||||
| one of several modes (tab, visibility, plan, severity filter) | `choiceControl` |
|
||||
| a subset (steps skipped, columns shown, signals selected) | `multiChoiceControl` |
|
||||
|
||||
Rules that come with them:
|
||||
|
||||
- `countControl` `max` goes past what the page renders, so a story can show the
|
||||
cap being hit. `0` is the empty state, which is why an empty list rarely needs
|
||||
its own story. When the cap is in the *request* (`?limit=5`) rather than the
|
||||
renderer, stop `max` at the limit: a longer response is a body the backend
|
||||
cannot send.
|
||||
- `choiceControl` options come from a `const` array typed with
|
||||
`(typeof X)[number]`, not from string literals scattered in the handlers.
|
||||
- Defaults describe the fully-populated page. The panel starts where `Default`
|
||||
starts.
|
||||
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
|
||||
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
|
||||
control.
|
||||
- `description` only when the name does not carry the effect (what dismissing
|
||||
does, what the cap is, which widget it feeds).
|
||||
|
||||
## Which hook
|
||||
|
||||
`defineStoryMocks` takes three, all optional:
|
||||
|
||||
- `handlers(values, response)`: the page's endpoints. Everything the page owns
|
||||
goes through `response.json`, so the global Data control turns the whole page
|
||||
into loading or failed without a second declaration. An endpoint the page
|
||||
cannot render at all without (ingestion detection, preferences, license
|
||||
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
|
||||
visible while the rest hangs or fails.
|
||||
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
|
||||
`appContext`, `reduxState`, `queryBuilder`, `theme`.
|
||||
- `effect(values)`: module-level state no provider exposes.
|
||||
|
||||
One endpoint feeding several widgets stays one handler that reads the request.
|
||||
`response.json` hands the request to the builder and awaits it, so reading a
|
||||
query param, or a POST body, does not cost the Data control:
|
||||
|
||||
```ts
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) =>
|
||||
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
```ts
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const signal = body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
return countResponse(values[`${signal}Ingestion`] ? 4213 : 0);
|
||||
}),
|
||||
),
|
||||
```
|
||||
|
||||
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
|
||||
endpoint has to keep answering while the Data control is on `loading` or
|
||||
`error`: detection calls the page cannot render without.
|
||||
|
||||
## Mutations
|
||||
|
||||
A control drives the response, so a write the page makes against state a control
|
||||
owns does not stick: the refetch answers with the control's value and the button
|
||||
appears to do nothing. Two honest options: leave it declarative and say so in
|
||||
the PR, or move the state into `effect` so the handler can read what the page
|
||||
wrote. Never fake the write by mutating a builder's module state without saying
|
||||
where the state lives.
|
||||
|
||||
## Wiring it up
|
||||
|
||||
```ts
|
||||
// src/pages/Services/stories/Services.stories.mocks.tsx
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
export const servicesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
services: countControl('Services', { group: LISTS, value: 8, max: 12 }),
|
||||
apdex: choiceControl<ApdexState>('Apdex', {
|
||||
group: HEALTH,
|
||||
options: APDEX_STATES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => buildServices(values.services, values.apdex)),
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/Services/stories/Services.stories.tsx
|
||||
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Services',
|
||||
component: Services,
|
||||
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION, layout: 'app' }),
|
||||
} satisfies Meta<ServicesArgs>;
|
||||
```
|
||||
|
||||
`PageStoryArgs` folds in the global controls, so a story's `args` can set
|
||||
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
|
||||
|
||||
## Not a control
|
||||
|
||||
- Anything the global controls already cover: banner, side nav, data state,
|
||||
access preset, permissions, check state.
|
||||
- A knob whose effect nobody can see on the page. Delete it or find the widget it
|
||||
was supposed to drive.
|
||||
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
|
||||
`viewer`), and the builder turns intent into the payload.
|
||||
- Anything that needs a module mock or a component prop to work. If the state
|
||||
cannot be produced from a response, config or module state, say so in the PR
|
||||
instead of faking it.
|
||||
|
||||
## Control or story
|
||||
|
||||
Default to a control. Write a story when the state is worth a link:
|
||||
|
||||
- the fresh workspace, because that is what a new user sees
|
||||
- the restricted user, when permissions visibly change the page
|
||||
- a page-defining mode (a tab, a category) that has its own layout
|
||||
|
||||
Combinations of controls do not need stories, which is what the panel is for.
|
||||
|
||||
Each story gets one prose doc comment: what it shows, in the page's own terms.
|
||||
Everywhere else the comment rule in SKILL.md applies: write one only for what
|
||||
the code cannot show.
|
||||
64
.claude/skills/signoz-page-story/references/discovery.md
Normal file
64
.claude/skills/signoz-page-story/references/discovery.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Mapping a page
|
||||
|
||||
Two passes: read the code, then let the running story correct you. Write the
|
||||
inventory down: it is what the controls are derived from, and the only
|
||||
protection against a story that renders one state and calls it a page.
|
||||
|
||||
## Pass 1: read the page
|
||||
|
||||
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts
|
||||
(`src/container/<Feature>/`), the hooks those use, the components with their own
|
||||
fetches. Stop at leaf components that take props only.
|
||||
|
||||
Grep recipes, run against the page's directories:
|
||||
|
||||
| Looking for | Grep |
|
||||
| --- | --- |
|
||||
| endpoints | `useQuery\|useMutation\|useInfiniteQuery`, then the `api/` module it calls |
|
||||
| endpoint URLs | the api module's `axios.get\|post` |
|
||||
| endpoint URLs behind a generated hook | the hook lives in `src/api/generated/services/<name>/index.ts` and the URL only appears in the fetcher body: `rg 'url: \`' src/api/generated/services/<name>/` |
|
||||
| url state | `useUrlQuery\|useUrlQueryData\|useUrlSearchState\|useQueryState\|QueryParams\.` |
|
||||
| navigation | `useSafeNavigate\|history.push\|<Link` |
|
||||
| permissions | `useAuthZ\|AuthZGuard\|AuthZButton\|hasEditPermission\|routePermission` |
|
||||
| flags and prefs | `useFeatureFlag\|FeatureKeys\.\|USER_PREFERENCES\.\|userPreferences` |
|
||||
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
|
||||
| render caps | `slice(0,\|PAGE_SIZE\|pageSize\|limit` |
|
||||
|
||||
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names,
|
||||
`src/lib/authz/README.md` how a permission check resolves.
|
||||
|
||||
## The inventory
|
||||
|
||||
One table, in the story's PR or scratch notes:
|
||||
|
||||
| Endpoint | Feeds | States it can be in |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/x` | the header count | populated, zero, error |
|
||||
|
||||
Plus four short lists:
|
||||
|
||||
- **Query params** the page reads, and what each one switches.
|
||||
- **Permission checks** the page makes, and what disappears when each is denied.
|
||||
- **Preferences and flags** that change layout (dismissed banners, onboarding
|
||||
checklists, opt-in views).
|
||||
- **Caps**: how many rows each list renders before it truncates or paginates.
|
||||
|
||||
A state that appears in this inventory and not in the controls panel is a bug in
|
||||
the story.
|
||||
|
||||
## Pass 2: let it run
|
||||
|
||||
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the
|
||||
route with `layout: 'app'`, then open it (see verify.md). The console is the
|
||||
oracle:
|
||||
|
||||
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1
|
||||
missed. Add it to the inventory.
|
||||
- an msw unhandled-request warning: a request going to an origin the handlers do
|
||||
not answer on. handlers are declared against `http://localhost`.
|
||||
- a spinner that never resolves with the Data control on `loaded`: a handler
|
||||
whose URL does not match what the page calls.
|
||||
- the navigation overlay on mount: the page redirects, usually because `route`
|
||||
is wrong or a guard is failing on a permission the controls have not granted.
|
||||
|
||||
Repeat until the console is silent. Only then start declaring controls.
|
||||
110
.claude/skills/signoz-page-story/references/verify.md
Normal file
110
.claude/skills/signoz-page-story/references/verify.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Verifying a page story
|
||||
|
||||
A story is not done because it compiles. It is done when each control has been
|
||||
seen changing the page and the console is silent.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm storybook --ci --quiet # :6006, background it
|
||||
```
|
||||
|
||||
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
|
||||
already-running server; an empty first poll is not a broken `stories` glob.
|
||||
|
||||
Story ids come from the meta title: `Pages/Services` → `pages-services`, plus the
|
||||
story export in kebab-case. Render one story on its own:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
|
||||
```
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
Args are settable in the iframe URL, so a whole sweep runs headless without
|
||||
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
|
||||
indexed, several separated by `;`, and the theme through `globals`:
|
||||
|
||||
```
|
||||
&args=services:0;apdex:poor;access:viewer;dataState:loading
|
||||
&args=signals[0]:logs;signals[1]:traces
|
||||
&globals=theme:light
|
||||
```
|
||||
|
||||
That is the cheap way to check a control does something: load with and without
|
||||
it, diff the page text.
|
||||
|
||||
## Drive it
|
||||
|
||||
Playwright lives in the repo's e2e workspace, so a scratch script can use it
|
||||
directly:
|
||||
|
||||
```js
|
||||
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
|
||||
const { chromium } = pw;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error' || m.type() === 'warning') problems.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => problems.push(e.message));
|
||||
|
||||
await page.goto(`${story}&args=services:0`, { waitUntil: 'networkidle' });
|
||||
await page.locator('body').waitFor();
|
||||
console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
Screenshots are worth taking for `Default` in both themes
|
||||
(`&globals=theme:light`): text extraction does not catch an unstyled page.
|
||||
|
||||
## Gates
|
||||
|
||||
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
|
||||
msw unhandled-request warning, a React key or state warning: assume the story
|
||||
is wrong first. A warning that survives is sometimes the app's. Prove it by
|
||||
turning off the control that renders the widget and watching the warning go
|
||||
with it, and by finding the same component elsewhere doing it right. Then
|
||||
report the app bug in the PR. Never invent a field the API does not return to
|
||||
silence a warning.
|
||||
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
|
||||
means the page is trying to leave: wrong `route`, or a guard denying on a
|
||||
permission the controls did not grant.
|
||||
- **Every control moves something.** Sweep them one at a time from the URL and
|
||||
diff the page text. A control with no diff is either wired to nothing or aimed
|
||||
at a widget that is not rendering. Some only show their effect after an
|
||||
interaction, such as a tab that has to be clicked or a select that has to be
|
||||
opened. Drive that interaction rather than calling the control unobservable.
|
||||
- **Both themes render styled.** An unstyled page means the story is not inside
|
||||
the provider decorator, or `<body data-theme>` was lost.
|
||||
- **Roles agree.** `<body data-signoz-story-role>` and
|
||||
`<body data-signoz-context-role>` disagreeing means the page reads a different
|
||||
`AppContext` than the story config fills.
|
||||
- **The page's own navigation works.** Tabs, filters and pagination that write
|
||||
query params should re-render the page in place; only leaving the page belongs
|
||||
in the overlay.
|
||||
|
||||
## Then the usual
|
||||
|
||||
```bash
|
||||
pnpm tsgo --noEmit
|
||||
pnpm exec oxlint <changed files>
|
||||
pnpm exec oxfmt --check <changed files>
|
||||
```
|
||||
|
||||
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
|
||||
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
|
||||
not one.
|
||||
|
||||
## Common failures
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| endless spinner with Data on `loaded` | handler URL does not match the call; handlers answer on `http://localhost` |
|
||||
| page renders but empty | response shape wrong; compare against the api module's type, not a guess |
|
||||
| 501 in the console | endpoint nobody mocked; the catch-all is answering |
|
||||
| new control missing from the panel | project-level control added; the tab needs a reload |
|
||||
| control flips but nothing changes | the widget is gated by something else: a permission, a flag, a preference |
|
||||
| shell disappears in `loading` | an endpoint the shell needs went through `response.json`; give it a plain resolver |
|
||||
294
.claude/skills/storybook-visual-diff/SKILL.md
Normal file
294
.claude/skills/storybook-visual-diff/SKILL.md
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: storybook-visual-diff
|
||||
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
|
||||
---
|
||||
|
||||
# Storybook visual diff
|
||||
|
||||
Two scripts under `frontend/scripts`:
|
||||
|
||||
- `story-shots.mjs` — screenshots stories off a running Storybook dev server.
|
||||
- `story-shots-diff.mjs` — pixel-diffs two runs and paints what moved.
|
||||
|
||||
Output goes to `frontend/.story-shots/` (gitignored), one directory per run.
|
||||
|
||||
## 0. Settle what is being compared, first
|
||||
|
||||
A diff is only worth taking when the two runs straddle something. Run twice over
|
||||
the same tree and the answer is zero, or the noise floor: true, and useless.
|
||||
So before starting a server, pin down four things. Whatever the prompt already
|
||||
says, take it and do not ask again; ask only for what is genuinely missing, in
|
||||
**one** `AskUserQuestion` call.
|
||||
|
||||
| To settle | Ask | Options |
|
||||
| --- | --- | --- |
|
||||
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
|
||||
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
|
||||
| Themes | "Which themes?" | dark · dark + light |
|
||||
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
|
||||
|
||||
Skip a row when the prompt answers it, and skip the whole call when the prompt
|
||||
answers all of it ("shoot the pods tooltips in both themes" needs no question).
|
||||
Skip Read-out too whenever the job is *shoot only*, and take `none` for what it
|
||||
says: shoot both sides, report both paths, run no comparison. When the prompt
|
||||
says nothing at all, ask; a silent guess here burns ~6 min per sweep on the
|
||||
wrong stories.
|
||||
|
||||
The job decides which loop below to run:
|
||||
|
||||
| Job | Loop |
|
||||
| --- | --- |
|
||||
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
|
||||
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
|
||||
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
|
||||
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
|
||||
| **noise floor** | two runs, same tree, diff. The number is the harness's floor, not a finding. |
|
||||
| **config vs config** | same tree, two runs that differ only in flags: `--args`, `--clock`, `--width`, `--theme`, `--motion`. Filenames stay identical, so the pairs line up and the caption names what changed. |
|
||||
|
||||
## The loop
|
||||
|
||||
1. Capture the baseline **before touching anything**.
|
||||
2. Capture it a second time and diff the two. That is the noise floor: anything
|
||||
it reports is what the harness cannot hold still, and no conclusion about the
|
||||
change may rest on those stories. Cheap on a handful of stories, ~6 min per
|
||||
32, so on a wide sweep run it over the two or three stories the change is
|
||||
aimed at instead of all of them.
|
||||
3. Make the change.
|
||||
4. Capture again into a third directory.
|
||||
5. Diff, then read the tinted shot of the largest movers to judge the change.
|
||||
|
||||
## 1. One dev server, on a free port
|
||||
|
||||
`storybook dev` keys its Vite dep cache off the config dir, so two servers on the
|
||||
same `-c` serve mismatched prebundles and every story dies with `Invalid hook
|
||||
call`. Check what is already up first — port 6006 is often another repo's
|
||||
Storybook, and its `index.json` then indexes the wrong stories:
|
||||
|
||||
```bash
|
||||
for port in 6006 6007; do
|
||||
curl -s -m 2 "http://localhost:$port/index.json" | head -c 60 && echo " <- $port"
|
||||
done
|
||||
```
|
||||
|
||||
Start the SigNoz one on a free port, from the repo's own binary so no package
|
||||
manager shim is in the way:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
nohup ./node_modules/.bin/storybook dev -p 6007 --no-open --quiet \
|
||||
> "${TMPDIR:-/tmp}/signoz-storybook.log" 2>&1 &
|
||||
```
|
||||
|
||||
It is ready when `curl -s localhost:6007/index.json` returns JSON whose
|
||||
`entries` hold SigNoz story ids.
|
||||
|
||||
## 2. Capture
|
||||
|
||||
Playwright is not a frontend dependency. The script finds it in `tests/e2e`
|
||||
(`pnpm -C tests/e2e install`, `@playwright/test` is enough) or in a global
|
||||
install, and launches Playwright's own chromium, falling back to an installed
|
||||
Chrome. Two escape hatches when that is not what a machine has:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_MODULE=/path/to/playwright # a different install
|
||||
export CHROME_PATH=/path/to/chrome # a specific browser binary
|
||||
```
|
||||
|
||||
Then pick the stories. `--list` prints the selection without shooting anything:
|
||||
|
||||
```bash
|
||||
# every tooltip story of every page
|
||||
node scripts/story-shots.mjs .story-shots/baseline \
|
||||
--port 6007 --title Pages/ --name tooltip --theme dark
|
||||
|
||||
# a handful of stories by id or by title/name substring, both themes
|
||||
node scripts/story-shots.mjs .story-shots/baseline \
|
||||
--port 6007 --stories pages-noz,dashboards/detail --theme dark,light
|
||||
```
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--stories <match>` | id or `Title/Name` substring, repeatable or comma-separated. Omit for every story. |
|
||||
| `--title <prefix>` | only titles starting with the prefix (`Pages/`, `Components/`) |
|
||||
| `--name <match>` | only story names containing the match |
|
||||
| `--theme dark,light` | one pass per theme; omit for the story's own default (dark) |
|
||||
| `--args <k:v;k2:v2>` | arg overrides, Storybook's own `?args=` syntax, repeatable. A dotted value is dropped by Storybook itself, so map it to a slug inside the story's mocks |
|
||||
| `--port` | dev server port, or `$SB_PORT` |
|
||||
| `--width <px>` | the only fixed dimension, default 1680 |
|
||||
| `--height <px>` | shortest the viewport may be, default 1200 |
|
||||
| `--max-height <px>` | tallest it may grow to, default 8000 |
|
||||
| `--grow <what>` | `scrollers` (default) grows the viewport until the page's own scrollers fit, `document` only follows the document height, `none` keeps `--height` |
|
||||
| `--settle <ms>` | wait after the page goes quiet, default 1500 |
|
||||
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
|
||||
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
|
||||
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
|
||||
| `--flat` | write `<out>/<id>.png`, no theme directory |
|
||||
| `--no-caption` | leave the caption band off the shots |
|
||||
| `--list` | print the matched stories and exit |
|
||||
|
||||
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
|
||||
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
|
||||
rows) and how the run was configured (args, clock, width, height, grow, motion,
|
||||
settle, ignore). Keep the flags identical between the two runs or the diff pairs
|
||||
nothing.
|
||||
|
||||
Every shot carries the caption band described below, so a single screenshot says
|
||||
what it is on its own. `--no-caption` leaves it off, and so does a machine
|
||||
without ImageMagick (with a warning). The band never changes the shot's width
|
||||
(long text wraps rather than widening the canvas) and its height is recorded, so
|
||||
the diff crops it back off and never reports one caption against another. Two
|
||||
runs whose captions are different heights still diff to zero. A story that never held still for two
|
||||
identical frames is logged `busy` instead of `ok` — treat its diff as suspect.
|
||||
|
||||
Dark alone is enough while iterating on the harness; add `light` for the run you
|
||||
report.
|
||||
|
||||
## 3. Diff
|
||||
|
||||
```bash
|
||||
node scripts/story-shots-diff.mjs .story-shots/baseline .story-shots/capped .story-shots/diff
|
||||
```
|
||||
|
||||
Prints `<changed pixels> <theme>/<story>.png`, largest first, and writes one
|
||||
image per pair. Needs ImageMagick for PNG encode/decode (7's `magick`, or 6's
|
||||
`convert`/`identify`/`montage`); the comparison itself is in the script.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--mode green` | default. The after shot with the changed pixels painted over it, exactly the pixels that changed. What Chromatic shows. |
|
||||
| `--mode green-parallel` | `previous \| current \| diff` in one image, each tile labelled above it, on a gutter inverted from the theme. The diff tile is the `green` one, so the after shot stays readable underneath. |
|
||||
| `--mode red` | the after shot faded to 10%, changed pixels in red. A pixelmatch-style diff, easiest to read when the change is a thin edge. |
|
||||
| `--mode red-parallel` | the same three tiles, with the `red` diff. Best when the change is a thin edge that the unfaded shot would swallow. |
|
||||
| `--threshold <0..1>` | how far a pixel must move to count. Default 0.063, Chromatic's `diffThreshold`. |
|
||||
| `--include-aa` | count antialiasing changes too. Off by default, as in Chromatic. |
|
||||
| `--tint <#rrggbb>` | override the mode's colour. |
|
||||
| `--no-caption` | drop the caption band. |
|
||||
|
||||
### The caption
|
||||
|
||||
Both scripts stamp a band on top of what they write: `story-shots.mjs` on each
|
||||
shot, from the story and the run's own settings; `story-shots-diff.mjs` on each
|
||||
diff, read out of the two runs' `shots.json`. It carries the story's
|
||||
`Title/Name`, then its id, theme and `busy` flag, then the settings both runs
|
||||
shared, each reading `key:value`. Whatever the two runs did **differently** goes
|
||||
on the side it belongs to: under `previous` and `current` on the parallel tiles,
|
||||
on two lines of the band otherwise. So a pair that differs only in `--args` says
|
||||
so on its face, which is what makes several shots of one story tellable apart.
|
||||
|
||||
The shots' own bands are cropped off before comparing and before going into the
|
||||
tiles, so nothing in the output is a diff of a caption. Type size follows the
|
||||
image width, so it stays readable with the whole image viewed at fit-to-width;
|
||||
the heading is set in an installed sans and the detail lines in a mono, falling
|
||||
back to ImageMagick's default when neither is on the machine. Without a manifest
|
||||
the band falls back to the file path, and a directory of captioned shots whose
|
||||
`shots.json` is missing has nothing to crop by, so its captions do land in the
|
||||
diff. Keep `shots.json` next to the shots.
|
||||
|
||||
### How the comparison works
|
||||
|
||||
Chromatic's own capture and diff run server-side — `chromatic-cli` uploads a
|
||||
built Storybook and contains no capture or comparison code at all. What is public
|
||||
is the parameter contract, and the numbers in it say what the comparison is:
|
||||
`diffThreshold` defaults to `0.063` on a 0-1 scale, which is pixelmatch's
|
||||
`threshold`, and `diffIncludeAntiAliasing` defaults to false, which is
|
||||
pixelmatch's `includeAA: false`. So the script implements that comparison:
|
||||
|
||||
1. Both PNGs are read as raw RGBA through `magick … RGBA:-`.
|
||||
2. Per pixel, the squared YIQ distance between the two colours (weights
|
||||
`0.5053 / 0.299 / 0.1957`), compared against `35215 * threshold²` — 35215 is
|
||||
the largest distance two 8-bit colours can have. Chroma is included, so a
|
||||
colour swap at equal brightness still counts.
|
||||
3. A pixel over the threshold is dropped when it is only antialiasing: it is the
|
||||
darkest or lightest of its eight neighbours, and the other image has a pixel
|
||||
around there doing the same job. This is what keeps a subpixel glyph edge from
|
||||
reading as a change.
|
||||
4. What survives is painted at full opacity, one output pixel per changed input
|
||||
pixel. No dilation, no blobs — a one-pixel shift shows as a one-pixel line.
|
||||
|
||||
A pair whose shots are different sizes is compared over the overlap, and every
|
||||
row and column that exists in only one of them counts as changed.
|
||||
|
||||
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
|
||||
(new on the feature branch, renamed, retitled) has nothing to pair with and is
|
||||
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
|
||||
before reading the numbers.
|
||||
|
||||
## What makes a shot reproducible
|
||||
|
||||
Most of it is in the preview, not in the script, so a Chromatic build in the
|
||||
cloud shoots the same page: `.storybook/preview-head.html` freezes the clock,
|
||||
and `settleForCapture` (the preview's `afterEach`, which runs after `play`)
|
||||
parks the animations and snaps the bottom-pinned lists. The script drives the
|
||||
rest:
|
||||
|
||||
- **Storybook's own render phase is the readiness signal.** It waits for
|
||||
`window.__STORYBOOK_PREVIEW__.storyRenders[].phase === 'finished'`, which is
|
||||
reached only after the loaders, the decorators and the story's `play` are done.
|
||||
A DOM check cannot see a `play` still running. (Storybook 10 spells the final
|
||||
phase `finished`, not `completed`.)
|
||||
- **Network quiescence, not `networkidle`.** react-query retries and msw keep
|
||||
requests going after load, and a few stories hang a request by design, so the
|
||||
wait is "no request for 600ms", capped at 15s.
|
||||
- **The clock is frozen** (`2026-06-15T12:00:00Z`), by the preview itself. Chart windows, `4 mins ago`
|
||||
labels and trial countdowns all derive from `now`; a live clock alone moved
|
||||
8000 pixels on the dashboards list and redrew every chart axis.
|
||||
- **Animations are parked on their last frame** by `html.sb-still`, a
|
||||
zero-length single iteration with `forwards` fill, plus `prefers-reduced-
|
||||
motion`. The Motion toolbar item (`still` by default) turns it off. An infinite
|
||||
spinner is otherwise caught at a random angle.
|
||||
- **`document.fonts.ready`**, because text reflows when a face lands late.
|
||||
- **Lists pinned to their bottom are snapped onto it**, once by the preview and
|
||||
again by the script after the page goes quiet. A virtuoso list settles a
|
||||
few pixels short of the end depending on the order its items were measured in.
|
||||
- **Two identical frames in a row**, because what a page is still waiting on is
|
||||
often not observable from outside it.
|
||||
- **`[data-shot-ignore]`, `[data-chromatic="ignore"]` and `--ignore <selector>`**
|
||||
hide a region that cannot be held still; Chromatic excludes the same attribute
|
||||
from its comparison.
|
||||
- **The width is the only fixed dimension.** Chromatic's `viewports` are widths;
|
||||
the height follows the page. `src/styles.scss` pins `html, body, #root` to
|
||||
`height: 100%; overflow: hidden`, so the document never outgrows the viewport
|
||||
and its height says nothing: what overflows are the shell's inner scrollers.
|
||||
`--grow scrollers`, the default, grows the viewport until the tallest in-flow
|
||||
scroller fits, so nothing is cut off and no scrollbar is left in the shot (the
|
||||
dashboards list goes to 2226px in one round). Popups are skipped — they are out
|
||||
of the flow, and a tall dropdown would otherwise drag the shot to a height
|
||||
nothing on the page needs. A page that sizes a panel in `vh` grows its own
|
||||
content as the viewport grows, so no height ever fits it and the rounds only
|
||||
chase — `.alert-chart-container` is `57vh`, which puts Create Alert's fixed
|
||||
point at 4344px with an empty band on top. Those pages are shot at `--height`
|
||||
with their own scrollbar, which is what they look like in a browser, and the
|
||||
log says `(viewport-sized content, stopped chasing Npx)`.
|
||||
|
||||
With all of that, 29 of the 32 page tooltip stories are byte-identical across
|
||||
runs. The three that are not, and why:
|
||||
|
||||
| Story | Residual | Cause |
|
||||
| --- | --- | --- |
|
||||
| `kubernetes-pods--tooltips-in-options-panel` | ~13k px | 24 tooltips held open in an overlapping cluster; they portal to `body` in mount order, and the drawer's own tooltips mount before or after the list's depending on when their data lands, so overlapping tooltips stack differently. Panel geometry itself is stable. |
|
||||
| `settings-role-editor--tooltips-in-json-editor` | ~2.5k px | monaco re-measures and lands one pixel off. |
|
||||
| `traces-trace-details--tooltips` | ~800 px | same class, one row of the waterfall. |
|
||||
|
||||
Each is bimodal — two stable arrangements — so the same number reappears run
|
||||
after run. Diff a story against itself before believing its number, and reach
|
||||
for `--ignore` when a region cannot be settled.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Zero pixels is a real answer.** A story whose tooltips are all short is
|
||||
unaffected by a tooltip rule; it is not a broken capture.
|
||||
- **The selector matters more than the rule.** A global rule on
|
||||
`[data-slot='…']` only reaches design-system components. antd's own tooltips
|
||||
(`.ant-tooltip-inner`, e.g. the Create Alert help popups) are untouched, which
|
||||
is why some stories show no diff at all.
|
||||
- **Global style overrides need `!important`.** `src/styles.scss` loads before
|
||||
the design system injects its CSS-module styles at runtime, so a plain rule on
|
||||
a `[data-slot='…']` element loses. A component-level `!important` of the same
|
||||
specificity still wins over it — `PanelStatusPopover.module.scss` keeps its own
|
||||
`max-width: 520px !important`.
|
||||
- **A fresh context per story** is why a full sweep takes ~6 min for 32 stories.
|
||||
Reusing one page loses the msw service worker re-registration race and stories
|
||||
start failing after a few navigations.
|
||||
- **Stories behind a hover, drawer or modal** only render what their `play`
|
||||
reaches. If a state is missing from the shot, the story needs the `play`, not
|
||||
the script.
|
||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,3 @@
|
||||
*.css linguist-detectable=false
|
||||
*.css linguist-detectable=false
|
||||
*.stories.mocks.tsx linguist-generated=true
|
||||
**/__story_mockdata__/** linguist-generated=true
|
||||
|
||||
48
docs/contributing/go/clickhousesql.md
Normal file
48
docs/contributing/go/clickhousesql.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# ClickHouse SQL
|
||||
|
||||
Telemetry queries are generated as ClickHouse SQL text, most of it through [go-sqlbuilder](https://github.com/huandu/go-sqlbuilder) and executed with [clickhouse-go](https://github.com/ClickHouse/clickhouse-go). Attribute names, label names, body keys, aliases and dashboard variable values are user or telemetry input, so every one of them has to be quoted before it becomes part of the text.
|
||||
|
||||
## How do I quote a name or a value?
|
||||
|
||||
Use [pkg/clickhousesql](/pkg/clickhousesql/clickhousesql.go). Never build a quoted token with `fmt.Sprintf`, string concatenation or `strings.ReplaceAll`.
|
||||
|
||||
| You need | Use | Example |
|
||||
| --- | --- | --- |
|
||||
| a column, alias or JSON sub-column name | `clickhousesql.Identifier(name)` | `` body_v2.`user.name` `` |
|
||||
| a string in a map read, a function argument, an `IN` list | `clickhousesql.StringLiteral(value)` | `attributes_string['http.method']` |
|
||||
| a Go scalar or list rendered as a literal | `clickhousesql.Literal(value)` | `['a','b']` |
|
||||
| a needle for `LIKE` | `clickhousesql.LikePattern(value)` | `"%" + clickhousesql.LikePattern(name) + "%"` |
|
||||
|
||||
```go
|
||||
expr := fmt.Sprintf("multiIf(mapContains(%s, %s), %s[%s], NULL)", column, clickhousesql.StringLiteral(key.Name), column, clickhousesql.StringLiteral(key.Name))
|
||||
alias := clickhousesql.Identifier(fmt.Sprintf("__GROUP_BY_KEY_%d_%s", i, key.Name))
|
||||
```
|
||||
|
||||
Values compared against a column are bound as arguments (`sb.E(column, value)`), never rendered into the text.
|
||||
|
||||
Filter expressions are a different language. A value placed into a filter expression string goes through `querybuilder.FilterStringLiteral`, which quotes for that grammar.
|
||||
|
||||
## Where does `sqlbuilder.Escape` go?
|
||||
|
||||
go-sqlbuilder compiles the text it is given: `$0`, `$1`, `${name}` and `$?` are read as argument references and `$$` as a single `$`. The compiled text is everything passed to `Select`, `SelectMore`, `GroupBy`, `OrderBy`, `Having`, `From`, a raw string passed to `Where`, `And` or `Or`, and a compiled subquery joined or selected from another builder, which is compiled a second time. The `Cond` helpers (`sb.E`, `sb.Like`, `sb.In`, `sb.IsNotNull`, ...) write their field argument verbatim.
|
||||
|
||||
- A name or expression that ends up in compiled text is wrapped with `sqlbuilder.Escape` once, at the point where it enters that text. Materialized column names carry `$$`, so this is what keeps them intact.
|
||||
- A field passed to a `Cond` helper is not escaped.
|
||||
- Text that never passes through a builder is not escaped: CTE fragments joined with `querybuilder.CombineCTEs`, a `UNION` assembled with `fmt.Sprintf` from already compiled statements, raw queries sent straight to the store.
|
||||
- Bind placeholders produced by `sb.Var` or the `Cond` helpers must not be escaped, so escape the identifier-bearing part before combining it with them: `fmt.Sprintf("match(%s, %s)", sqlbuilder.Escape(fieldExpr), sb.Var(value))`.
|
||||
|
||||
## How do I check SQL a user wrote?
|
||||
|
||||
A statement typed into a ClickHouse query panel is validated with `clickhousesql.ErrIfStatementIsNotValid`. It parses the text with [clickhouse-sql-parser](https://github.com/AfterShip/clickhouse-sql-parser) and refuses anything but a single `SELECT`, a table function other than the row generators (`numbers`, `zeros`, `generate_series`), a function that reads a file, a dictionary or the server binary, the `system` and `information_schema` databases, and a `SETTINGS readonly` override. Each refusal carries one of the package's `Code*` values.
|
||||
|
||||
## Why is `$` written as `\x24`?
|
||||
|
||||
Inside an identifier or a literal, `clickhousesql` writes a `$` as `\x24` when a digit, `{` or `?` follows; ClickHouse decodes the escape, so the name is unchanged on the server. Two tools between the builder and ClickHouse read such a `$` as a placeholder: go-sqlbuilder resolves `$0` in a compiled fragment to its own WHERE clause and recurses, and clickhouse-go refuses a query that mixes a `$<digits>` numeric placeholder with `?` arguments. Any other `$` stays literal, so `resource_string_service$$name` renders exactly as written.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Every identifier and literal built from a name or a value goes through `pkg/clickhousesql`.
|
||||
- `sqlbuilder.Escape` wraps compiled text once; `Cond` fields and text assembled outside the builder are left alone.
|
||||
- Filter expression literals use `querybuilder.FilterStringLiteral`.
|
||||
- A statement written by a user is validated with `clickhousesql.ErrIfStatementIsNotValid`.
|
||||
- When adding a builder or a module that emits SQL, run its queries with a name containing a backtick, a quote, a backslash and `$0`; the integration suites under `tests/integration/tests/queriercommon` do this for the query builder.
|
||||
@@ -17,7 +17,7 @@ For example, the [prometheus](/pkg/prometheus) provider delivers a prometheus en
|
||||
|
||||
- `pkg/prometheus/prometheus.go` - Interface definition
|
||||
- `pkg/prometheus/config.go` - Configuration
|
||||
- `pkg/prometheus/clickhouseprometheusv2/provider.go` - Clickhouse-powered implementation
|
||||
- `pkg/prometheus/clickhouseprometheus/provider.go` - Clickhouse-powered implementation
|
||||
- `pkg/prometheus/prometheustest/provider.go` - Mock implementation
|
||||
|
||||
## How to wire it up?
|
||||
|
||||
@@ -12,6 +12,7 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
|
||||
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
|
||||
- [Authz](authz.md) - Authorization, roles, and access control
|
||||
- [ClickHouse SQL](clickhousesql.md) - Quoting names and values in generated ClickHouse queries
|
||||
- [Errors](errors.md) - Structured error handling
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
|
||||
@@ -9,16 +9,15 @@ change breaks an invariant, flag it and discuss it first.
|
||||
|
||||
---
|
||||
|
||||
## Why the provider looks like this
|
||||
## Why a second provider
|
||||
|
||||
The removed v1 provider served the promql engine through the remote-read
|
||||
protobuf adapter. It fetched every raw sample of a query's union window,
|
||||
serialized all of them, and gave them to the engine. The cost followed the
|
||||
ingested data, not the question. This is how a dashboard of PromQL panels
|
||||
could take an instance down. v2 replaced it after a byte-level parity
|
||||
rollout, and v1 was then deleted.
|
||||
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql
|
||||
engine through the remote-read protobuf adapter. It fetches every raw sample
|
||||
of a query's union window. It serializes all of them and gives them to the
|
||||
engine. The cost follows the ingested data, not the question. This is how a
|
||||
dashboard of PromQL panels can take an instance down.
|
||||
|
||||
Each query runs in one of two ways. The classifier decides per query:
|
||||
In v2, each query runs in one of two ways. The classifier decides per query:
|
||||
|
||||
- **Transpiled**: ClickHouse evaluates the query. Only final (or near-final)
|
||||
per-group grid arrays come back. The statements use the
|
||||
@@ -31,7 +30,7 @@ Each query runs in one of two ways. The classifier decides per query:
|
||||
lost user. A construct that cannot reproduce engine semantics exactly falls
|
||||
back. It does not approximate.** The conformance suite
|
||||
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
|
||||
corpus against the provider. It is the arbiter. The classification golden
|
||||
corpus against both providers. It is the arbiter. The classification golden
|
||||
(`testdata/classification_golden.json`) freezes the route of each corpus
|
||||
expression. The rest of this document is the PromQL-to-SQL story. That
|
||||
mapping is where correctness is won or lost.
|
||||
@@ -264,8 +263,7 @@ per-thread partials scaled memory with the thread count. The slide then
|
||||
combines each slot's at-most-W bucket partials by direct aggregation
|
||||
(`arraySum(arraySlice(...))`). Window sums are added the way the engine adds
|
||||
them. There is no prefix-sum differencing: its large-minus-large
|
||||
cancellation would drift past the conformance tolerance on counter-sized
|
||||
values.
|
||||
cancellation would drift past the shadow tolerance on counter-sized values.
|
||||
This is correct per slot because the bucket union is the exact window
|
||||
multiset, and avg/min/max/sum/count are order-insensitive on a multiset
|
||||
(sum/avg up to summation order; see the float caveat above). A slot with
|
||||
@@ -335,7 +333,7 @@ can carry them.
|
||||
## The engine path
|
||||
|
||||
Queries that do not transpile run in the stock engine over this package's
|
||||
`storage.Querier`. Samples are fetched per
|
||||
`storage.Querier`. This is still not the v1 path. Samples are fetched per
|
||||
selector with the engine's per-selector hints, not the query-wide union
|
||||
window. So `foo / foo offset 1d` reads two narrow windows, not the widest
|
||||
one twice. Instant selectors of subquery-free queries fetch only the last
|
||||
@@ -369,9 +367,9 @@ same predicates as a shard-local semi-join, not a GLOBAL broadcast of the
|
||||
matched set. The temporality filter on every samples statement is a
|
||||
semantic no-op: the matched fingerprints already come from those
|
||||
temporalities. It engages the leading samples primary-key column.
|
||||
Delta-temporality series stay invisible to PromQL here, as they were before
|
||||
v2. To make Delta visible is its own change with its own semantics to
|
||||
design. A Delta stream fed to `rate()`
|
||||
Delta-temporality series stay invisible to PromQL here, exactly as in v1.
|
||||
The rollout gate is parity with v1. To make Delta visible is its own change
|
||||
with its own semantics to design. A Delta stream fed to `rate()`
|
||||
as-if-cumulative would be wrong, not just new.
|
||||
|
||||
## Observability
|
||||
|
||||
@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.6`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
|
||||
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
|
||||
|
||||
// Variable to store promProvider for cleanup
|
||||
var promProvider prometheus.Prometheus
|
||||
var promProvider *prometheustest.Provider
|
||||
|
||||
// Create manager using test factory with hooks
|
||||
mgr := rules.NewTestManager(t, &rules.TestManagerOptions{
|
||||
@@ -185,29 +185,76 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
TelemetryStoreHook: func(store telemetrystore.TelemetryStore) {
|
||||
mockStore := store.(*telemetrystoretest.Provider)
|
||||
|
||||
// Grid the TestNotification eval computes over (see
|
||||
// Timestamps on base_rule); nil args match any window.
|
||||
// Set up Prometheus-specific mock data
|
||||
// Fingerprint columns for Prometheus queries
|
||||
fingerprintCols := []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "any(labels)", Type: "String"},
|
||||
}
|
||||
|
||||
// Samples columns for Prometheus queries
|
||||
samplesCols := []cmock.ColumnType{
|
||||
{Name: "metric_name", Type: "String"},
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "unix_milli", Type: "Int64"},
|
||||
{Name: "value", Type: "Float64"},
|
||||
{Name: "flags", Type: "UInt32"},
|
||||
}
|
||||
|
||||
// Calculate query time range similar to Prometheus rule tests
|
||||
// TestNotification uses time.Now().UTC() for evaluation
|
||||
// We calculate the query window based on current time to match what the actual evaluation will use
|
||||
evalTime := baseTime
|
||||
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
|
||||
gridEnd := (evalTime.UnixMilli() / 60000) * 60000
|
||||
gridStart := gridEnd - evalWindowMs
|
||||
evalTimeMs := evalTime.UnixMilli()
|
||||
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
|
||||
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
|
||||
|
||||
tsList := make([]int64, 0, len(tc.Values))
|
||||
vList := make([]float64, 0, len(tc.Values))
|
||||
// Create fingerprint data
|
||||
fingerprint := uint64(12345)
|
||||
labelsJSON := `{"__name__":"test_metric"}`
|
||||
fingerprintData := [][]interface{}{
|
||||
{fingerprint, labelsJSON},
|
||||
}
|
||||
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
|
||||
|
||||
// Create samples data from test case values, calculating timestamps relative to baseTime
|
||||
validSamplesData := make([][]interface{}, 0)
|
||||
for _, v := range tc.Values {
|
||||
// Skip NaN and Inf values in the samples data
|
||||
if math.IsNaN(v.Value) || math.IsInf(v.Value, 0) {
|
||||
continue
|
||||
}
|
||||
tsList = append(tsList, baseTime.Add(v.Offset).UnixMilli())
|
||||
vList = append(vList, v.Value)
|
||||
// Calculate timestamp relative to baseTime
|
||||
sampleTimestamp := baseTime.Add(v.Offset).UnixMilli()
|
||||
validSamplesData = append(validSamplesData, []interface{}{
|
||||
"test_metric",
|
||||
fingerprint,
|
||||
sampleTimestamp,
|
||||
v.Value,
|
||||
uint32(0), // flags - 0 means normal value
|
||||
})
|
||||
}
|
||||
grid := prometheustest.LastSampleGrid(tsList, vList, gridStart, gridEnd, 60_000, 300_000)
|
||||
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
|
||||
|
||||
mock := mockStore.Mock()
|
||||
mock.ExpectQuery("SELECT gkey").
|
||||
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
|
||||
WillReturnRows(cmock.NewRows(prometheustest.GridCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
|
||||
|
||||
// Mock the fingerprint query (for Prometheus label matching)
|
||||
// args: $1=metric_name (the __name__ matcher maps onto the column)
|
||||
mock.ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// Mock the samples query (for Prometheus metric data)
|
||||
// args: metric_name IN (discovered names), subquery metric_name, start, end
|
||||
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
WillReturnRows(samplesRows)
|
||||
|
||||
// Create Prometheus provider for this test
|
||||
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
|
||||
@@ -242,6 +289,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
assert.Empty(t, triggeredTestAlerts)
|
||||
}
|
||||
|
||||
promProvider.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
9
frontend/.gitignore
vendored
9
frontend/.gitignore
vendored
@@ -28,4 +28,11 @@ 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
|
||||
|
||||
# Storybook screenshot sweeps (scripts/story-shots.mjs)
|
||||
/.story-shots/
|
||||
|
||||
@@ -576,6 +576,17 @@
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
|
||||
// algorithms that read worse when split up.
|
||||
"files": [
|
||||
"scripts/**"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"sonarjs/cognitive-complexity": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
89
frontend/.storybook/main.ts
Normal file
89
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import type { Plugin, PluginOption } from 'vite';
|
||||
|
||||
const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
||||
|
||||
/**
|
||||
* Modules replaced for every story. Same idea as `moduleNameMapper` in
|
||||
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
|
||||
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
|
||||
* not catch `lib/historyUtils`.
|
||||
*
|
||||
* Each replacement is typed as the module it stands in for, so drift is a
|
||||
* compile error rather than a story that fails at render. The `jest` note on
|
||||
* each entry is where the same import lands under the other runner. The two
|
||||
* only diverge where the runner needs them to.
|
||||
*/
|
||||
const mockAliases = [
|
||||
{
|
||||
// jest: not replaced, jsdom drives a real browser history.
|
||||
find: /^(?:src\/)?lib\/history$/,
|
||||
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
|
||||
},
|
||||
{
|
||||
// jest: src/__tests__/logEventMock.ts
|
||||
find: /^(?:src\/)?api\/common\/logEvent$/,
|
||||
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
|
||||
// resolves a relative `/api/...` against `http://localhost`.
|
||||
find: /^(?:src\/)?constants\/env$/,
|
||||
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Plugins from `vite.config.ts` that either target the app's `index.html` or
|
||||
* only pay off in a production build.
|
||||
*/
|
||||
const EXCLUDED_PLUGINS = [
|
||||
'vite-plugin-checker',
|
||||
'dev-base-path',
|
||||
'dev-boot-data',
|
||||
'vite-plugin-image-optimizer',
|
||||
'vite-plugin-compression',
|
||||
];
|
||||
|
||||
const isExcluded = (plugin: PluginOption): boolean =>
|
||||
!!plugin &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
EXCLUDED_PLUGINS.includes((plugin as Plugin).name);
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
// `../public` carries the fonts, icons and i18n bundles the app expects at
|
||||
// the root; `./public` carries the msw worker, which must not ship in a
|
||||
// production build.
|
||||
staticDirs: ['../public', './public'],
|
||||
addons: ['@storybook/addon-a11y'],
|
||||
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;
|
||||
10
frontend/.storybook/modes.ts
Normal file
10
frontend/.storybook/modes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Chromatic modes: one snapshot per entry, per story. The globals in a mode are
|
||||
* Storybook's own, so `theme` is the toolbar's theme and the story renders the
|
||||
* way it does locally. The width matches `scripts/story-shots.mjs` (`--width`),
|
||||
* so a cloud snapshot and a local shot frame the same page.
|
||||
*/
|
||||
export const allModes = {
|
||||
dark: { theme: 'dark', viewport: { width: 1680, height: 1200 } },
|
||||
light: { theme: 'light', viewport: { width: 1680, height: 1200 } },
|
||||
} as const;
|
||||
24
frontend/.storybook/preview-head.html
Normal file
24
frontend/.storybook/preview-head.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
// i18n's language detector would pick the browser locale (e.g. `en-US`), which
|
||||
// /public/locales does not ship; pin it to the bundled language instead.
|
||||
window.localStorage.setItem('i18nextLng', 'en');
|
||||
|
||||
// The Go backend injects this at boot; every integration it enables is off in
|
||||
// Storybook so no third-party script loads inside the iframe.
|
||||
window.signozBootData = {
|
||||
settings: {
|
||||
posthog: { enabled: false, apiHost: '', key: '', uiHost: '' },
|
||||
appcues: { enabled: false, appId: '' },
|
||||
sentry: { enabled: false, dsn: '', tunnel: '' },
|
||||
pylon: { enabled: false, appId: '', identitySecret: '' },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
133
frontend/.storybook/preview.tsx
Normal file
133
frontend/.storybook/preview.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
|
||||
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 { allModes } from './modes';
|
||||
|
||||
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 },
|
||||
// One cloud snapshot per theme, for every story. A mode carries Storybook
|
||||
// globals, so `theme` here is the same toolbar global the app reads out of
|
||||
// localStorage. Widths are Chromatic's only real dimension, as they are
|
||||
// locally: the app shell sizes itself to the viewport, so the height is the
|
||||
// one it is given.
|
||||
chromatic: { modes: allModes },
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'SigNoz color scheme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
items: [
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'light', title: 'Light' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
motion: {
|
||||
description:
|
||||
'Park every animation on its last frame once the story has settled. Still is what both capture stacks shoot; Live is for watching a transition.',
|
||||
toolbar: {
|
||||
title: 'Motion',
|
||||
icon: 'play',
|
||||
items: [
|
||||
{ value: 'still', title: 'Still' },
|
||||
{ value: 'live', title: 'Live' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: { theme: 'dark', motion: 'still' },
|
||||
// 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();
|
||||
},
|
||||
// After `play`, which is the moment both capture stacks shoot at.
|
||||
afterEach: settleForCapture,
|
||||
};
|
||||
|
||||
export default preview;
|
||||
303
frontend/.storybook/public/mockServiceWorker.js
Normal file
303
frontend/.storybook/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (1.3.2).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = Math.random().toString(16).slice(2)
|
||||
|
||||
event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn(
|
||||
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const clonedResponse = response.clone()
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body:
|
||||
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
const clonedRequest = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||
|
||||
// Remove MSW-specific request headers so the bypassed requests
|
||||
// comply with the server's CORS preflight check.
|
||||
// Operate with the headers as an object because request "Headers"
|
||||
// are immutable.
|
||||
delete headers['x-msw-bypass']
|
||||
|
||||
return fetch(clonedRequest, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.text(),
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
})
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.data
|
||||
const networkError = new Error(message)
|
||||
networkError.name = name
|
||||
|
||||
// Rejecting a "respondWith" promise emulates a network error.
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(timeMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, timeMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
await sleep(response.delay)
|
||||
return new Response(response.body, response)
|
||||
}
|
||||
@@ -88,6 +88,17 @@ pnpm test
|
||||
pnpm tsgo --noEmit
|
||||
```
|
||||
|
||||
## Storybook
|
||||
|
||||
```bash
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
Opens [http://localhost:6006](http://localhost:6006). Pages run against msw
|
||||
mocks with no backend; query-param navigation works inside a story, leaving the
|
||||
page is blocked. See [`src/storybook/README.md`](src/storybook/README.md) for the
|
||||
override surface.
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
|
||||
"dev": "vite",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build -o storybook-static",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -158,6 +160,9 @@
|
||||
"@commitlint/config-conventional": "20.4.4",
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
@@ -203,6 +208,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",
|
||||
|
||||
1049
frontend/pnpm-lock.yaml
generated
1049
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
167
frontend/scripts/story-shots-caption.mjs
Normal file
167
frontend/scripts/story-shots-caption.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* The caption band both story-shots.mjs and story-shots-diff.mjs stamp on their
|
||||
* output, and the ImageMagick plumbing under it. A shot records the band's
|
||||
* height in `shots.json` so the diff can crop it back off before comparing:
|
||||
* otherwise two runs whose captions differ would report the caption as a change.
|
||||
*/
|
||||
export const CONFIG_KEYS = [
|
||||
'args',
|
||||
'clock',
|
||||
'width',
|
||||
'height',
|
||||
'grow',
|
||||
'motion',
|
||||
'settle',
|
||||
'ignore',
|
||||
];
|
||||
|
||||
let tools;
|
||||
|
||||
const detect = () =>
|
||||
(tools ??= {
|
||||
seven: spawnSync('magick', ['-version']).status === 0,
|
||||
six: spawnSync('convert', ['-version']).status === 0,
|
||||
});
|
||||
|
||||
export const hasMagick = () => {
|
||||
const { seven, six } = detect();
|
||||
return seven || six;
|
||||
};
|
||||
|
||||
export const requireMagick = () => {
|
||||
if (hasMagick()) {
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
'ImageMagick not found. Install it (brew install imagemagick, apt install imagemagick).',
|
||||
);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
export const magick = (args, input) => {
|
||||
// ImageMagick 6 has no `magick`: its tools are separate binaries.
|
||||
const [command, ...rest] = detect().seven
|
||||
? ['magick', ...args]
|
||||
: ['identify', 'montage'].includes(args[0])
|
||||
? args
|
||||
: ['convert', ...args];
|
||||
const result = spawnSync(command, rest, {
|
||||
input,
|
||||
maxBuffer: 1024 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${rest.join(' ')}: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* ImageMagick's built-in default is a serif that reads as a book, not as a
|
||||
* screenshot label, so the band asks for what is installed: a sans for the
|
||||
* heading, a mono for the lines that carry ids, args and numbers. An
|
||||
* unrecognised name is fatal to `convert`, hence the check against the list it
|
||||
* reports; a machine with none of them keeps the default.
|
||||
*/
|
||||
const FONTS = {
|
||||
heading: [
|
||||
'Helvetica-Bold',
|
||||
'DejaVu-Sans-Bold',
|
||||
'Liberation-Sans-Bold',
|
||||
'Arial-Bold',
|
||||
'Noto-Sans-Bold',
|
||||
'DejaVu-Sans',
|
||||
'Liberation-Sans',
|
||||
],
|
||||
body: [
|
||||
'Menlo',
|
||||
'DejaVu-Sans-Mono',
|
||||
'Liberation-Mono',
|
||||
'JetBrainsMono-NF-Regular',
|
||||
'Courier',
|
||||
],
|
||||
};
|
||||
|
||||
let installed;
|
||||
|
||||
const fontArgs = (role) => {
|
||||
installed ??= new Set(
|
||||
[
|
||||
...magick(['-list', 'font'])
|
||||
.toString()
|
||||
.matchAll(/^\s*Font:\s*(\S+)/gm),
|
||||
].map(([, name]) => name),
|
||||
);
|
||||
const font = FONTS[role].find((name) => installed.has(name));
|
||||
return font ? ['-font', font] : [];
|
||||
};
|
||||
|
||||
/** Readable at fit-to-width, whatever the image is. */
|
||||
export const pointsize = (width) =>
|
||||
Math.min(Math.max(Math.round(width / 45), 24), 140);
|
||||
|
||||
// `label:` expands ImageMagick's own escapes and reads a file when the text
|
||||
// starts with @, so story names and arg values go through neither.
|
||||
export const bodyFont = () => fontArgs('body');
|
||||
|
||||
export const literal = (text) => text.replaceAll('%', '%%').replace(/^@/, ' @');
|
||||
|
||||
/** The gutter is the opposite of the theme, so the band keeps an edge. */
|
||||
export const palette = (theme) =>
|
||||
theme === 'light'
|
||||
? { background: '#101014', foreground: '#f4f4f5' }
|
||||
: { background: '#f4f4f5', foreground: '#101014' };
|
||||
|
||||
export const settingsLine = (config, keys = CONFIG_KEYS) =>
|
||||
keys
|
||||
.filter((key) => config?.[key])
|
||||
.map((key) => `${key}:${config[key]}`)
|
||||
.join(' ');
|
||||
|
||||
const heightOf = (file) => Number(magick(['identify', '-format', '%h', file]));
|
||||
|
||||
/**
|
||||
* Writes `from` to `to` with `lines` above it, and returns how many rows that
|
||||
* added — which is what a reader has to crop off to get the original back, so
|
||||
* the band must never change the width. Each line is a `caption:` at the
|
||||
* image's own width, wrapping instead of widening the canvas: a run whose
|
||||
* caption is longer must still produce a shot the next run's shot pairs with.
|
||||
* Type size follows the width, since a three-tile montage of 1680px shots is
|
||||
* over 5000px wide and is read at fit-to-width.
|
||||
*/
|
||||
export const stamp = ({ lines, from, to, theme }) => {
|
||||
const { background, foreground } = palette(theme);
|
||||
const width = Number(magick(['identify', '-format', '%w', from]));
|
||||
const heading = pointsize(width);
|
||||
const before = heightOf(from);
|
||||
const spacer = [
|
||||
'-size',
|
||||
`${width}x${Math.round(heading * 0.4)}`,
|
||||
`xc:${background}`,
|
||||
];
|
||||
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
'-gravity',
|
||||
'center',
|
||||
...spacer,
|
||||
...lines.flatMap((line, index) => [
|
||||
...fontArgs(index ? 'body' : 'heading'),
|
||||
'-size',
|
||||
`${width}x`,
|
||||
'-pointsize',
|
||||
String(index ? Math.round(heading * 0.62) : heading),
|
||||
`caption:${literal(line)}`,
|
||||
]),
|
||||
...spacer,
|
||||
from,
|
||||
'-append',
|
||||
to,
|
||||
]);
|
||||
|
||||
return heightOf(to) - before;
|
||||
};
|
||||
460
frontend/scripts/story-shots-diff.mjs
Normal file
460
frontend/scripts/story-shots-diff.mjs
Normal file
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { parseArgs } from 'node:util';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
bodyFont,
|
||||
CONFIG_KEYS,
|
||||
literal,
|
||||
magick,
|
||||
palette,
|
||||
pointsize,
|
||||
requireMagick,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
|
||||
/**
|
||||
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
|
||||
* moved, per pair, largest first.
|
||||
*
|
||||
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
|
||||
* distance from the baseline pixel is over `threshold` of the largest distance
|
||||
* two colours can have, and pixels that are only antialiasing around an
|
||||
* otherwise identical edge do not count. `threshold` is their `diffThreshold`
|
||||
* and its default is theirs too.
|
||||
*/
|
||||
const MAX_YIQ_DELTA = 35_215;
|
||||
|
||||
const { values: opts, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
mode: { type: 'string', default: 'green' },
|
||||
threshold: { type: 'string', default: '0.063' },
|
||||
'include-aa': { type: 'boolean', default: false },
|
||||
tint: { type: 'string', default: '' },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const [baseDir, afterDir, outArg] = positionals;
|
||||
const MODES = new Set(['green', 'green-parallel', 'red', 'red-parallel']);
|
||||
|
||||
if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
|
||||
console.log(`usage: node scripts/story-shots-diff.mjs <baseline-dir> <after-dir> [diff-dir]
|
||||
|
||||
--mode green the after shot, changed pixels painted over it (default)
|
||||
--mode green-parallel previous | current | green diff, side by side and labelled
|
||||
--mode red the after shot faded out, changed pixels painted red
|
||||
--mode red-parallel previous | current | red diff, side by side and labelled
|
||||
--threshold <0..1> YIQ distance a pixel must move to count (default 0.063)
|
||||
--include-aa count antialiasing changes too (default: ignore them)
|
||||
--tint <#rrggbb> override the mode's highlight colour
|
||||
--no-caption do not stamp the story and the run settings on top
|
||||
|
||||
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const outDir = outArg ?? path.join(path.dirname(baseDir), 'diff');
|
||||
const threshold = Number(opts.threshold);
|
||||
const maxDelta = MAX_YIQ_DELTA * threshold * threshold;
|
||||
const highlight = hexToRgb(
|
||||
opts.tint || (opts.mode.startsWith('green') ? '#00e05a' : '#ff003a'),
|
||||
);
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const value = Number.parseInt(hex.replace('#', ''), 16);
|
||||
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
}
|
||||
|
||||
requireMagick();
|
||||
|
||||
/**
|
||||
* `top` rows are dropped: story-shots.mjs stamps a caption on its shots and
|
||||
* records how tall it is, and a caption is not part of what the two runs are
|
||||
* being compared on.
|
||||
*/
|
||||
const readRgba = (file, top = 0) => {
|
||||
const [width, height] = magick(['identify', '-format', '%w %h', file])
|
||||
.toString()
|
||||
.split(' ')
|
||||
.map(Number);
|
||||
const data = magick([file, '-depth', '8', 'RGBA:-']);
|
||||
return top > 0 && top < height
|
||||
? { width, height: height - top, data: data.subarray(top * width * 4) }
|
||||
: { width, height, data };
|
||||
};
|
||||
|
||||
const writeRgba = ({ width, height, data }, file) =>
|
||||
writeFile(
|
||||
file,
|
||||
magick(
|
||||
['-depth', '8', '-size', `${width}x${height}`, 'RGBA:-', 'png:-'],
|
||||
data,
|
||||
),
|
||||
);
|
||||
|
||||
/* The pixelmatch colour maths, which is what Chromatic's threshold is scaled to. */
|
||||
const y = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
|
||||
const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
|
||||
const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
|
||||
|
||||
/** Squared YIQ distance, signed by which pixel is brighter. */
|
||||
const colorDelta = (a, b, posA, posB, yOnly = false) => {
|
||||
let r1 = a[posA];
|
||||
let g1 = a[posA + 1];
|
||||
let b1 = a[posA + 2];
|
||||
const a1 = a[posA + 3];
|
||||
let r2 = b[posB];
|
||||
let g2 = b[posB + 1];
|
||||
let b2 = b[posB + 2];
|
||||
const a2 = b[posB + 3];
|
||||
|
||||
if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Anything translucent is composited over the same mid grey in both images,
|
||||
// so a difference in alpha alone still registers.
|
||||
if (a1 < 255) {
|
||||
const alpha = a1 / 255;
|
||||
r1 = r1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
g1 = g1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
b1 = b1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
}
|
||||
if (a2 < 255) {
|
||||
const alpha = a2 / 255;
|
||||
r2 = r2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
g2 = g2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
b2 = b2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
}
|
||||
|
||||
const deltaY = y(r1, g1, b1) - y(r2, g2, b2);
|
||||
if (yOnly) {
|
||||
return deltaY;
|
||||
}
|
||||
|
||||
const deltaI = i(r1, g1, b1) - i(r2, g2, b2);
|
||||
const deltaQ = q(r1, g1, b1) - q(r2, g2, b2);
|
||||
return (
|
||||
0.5053 * deltaY * deltaY + 0.299 * deltaI * deltaI + 0.1957 * deltaQ * deltaQ
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the pixel sits on an edge that is drawn one subpixel over rather
|
||||
* than moved: it is the darkest or lightest of its neighbours in one image, and
|
||||
* the other image has a pixel around there doing the same job.
|
||||
*/
|
||||
const antialiased = (a, x1, y1, width, height, b) => {
|
||||
const x0 = Math.max(x1 - 1, 0);
|
||||
const y0 = Math.max(y1 - 1, 0);
|
||||
const x2 = Math.min(x1 + 1, width - 1);
|
||||
const y2 = Math.min(y1 + 1, height - 1);
|
||||
const pos = (y1 * width + x1) * 4;
|
||||
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
||||
let min = 0;
|
||||
let max = 0;
|
||||
let minX = 0;
|
||||
let minY = 0;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
for (let x = x0; x <= x2; x += 1) {
|
||||
for (let yy = y0; yy <= y2; yy += 1) {
|
||||
if (x === x1 && yy === y1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = colorDelta(a, a, pos, (yy * width + x) * 4, true);
|
||||
if (delta === 0) {
|
||||
zeroes += 1;
|
||||
if (zeroes > 2) {
|
||||
return false;
|
||||
}
|
||||
} else if (delta < min) {
|
||||
min = delta;
|
||||
minX = x;
|
||||
minY = yy;
|
||||
} else if (delta > max) {
|
||||
max = delta;
|
||||
maxX = x;
|
||||
maxY = yy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (min === 0 || max === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(hasManySiblings(a, minX, minY, width, height) &&
|
||||
hasManySiblings(b, minX, minY, width, height)) ||
|
||||
(hasManySiblings(a, maxX, maxY, width, height) &&
|
||||
hasManySiblings(b, maxX, maxY, width, height))
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether the pixel has at least three identical neighbours. */
|
||||
const hasManySiblings = (img, x1, y1, width, height) => {
|
||||
const x0 = Math.max(x1 - 1, 0);
|
||||
const y0 = Math.max(y1 - 1, 0);
|
||||
const x2 = Math.min(x1 + 1, width - 1);
|
||||
const y2 = Math.min(y1 + 1, height - 1);
|
||||
const pos = (y1 * width + x1) * 4;
|
||||
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
||||
|
||||
for (let x = x0; x <= x2; x += 1) {
|
||||
for (let yy = y0; yy <= y2; yy += 1) {
|
||||
if (x === x1 && yy === y1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const other = (yy * width + x) * 4;
|
||||
if (
|
||||
img[pos] === img[other] &&
|
||||
img[pos + 1] === img[other + 1] &&
|
||||
img[pos + 2] === img[other + 2] &&
|
||||
img[pos + 3] === img[other + 3]
|
||||
) {
|
||||
zeroes += 1;
|
||||
if (zeroes > 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The changed pixels of the pair, painted over the after shot. The `red` modes
|
||||
* fade the shot out first, the way a pixelmatch diff reads; the `green` ones
|
||||
* leave it alone, the way Chromatic's does.
|
||||
*/
|
||||
const diffPair = (base, after, mode) => {
|
||||
const width = Math.min(base.width, after.width);
|
||||
const height = Math.min(base.height, after.height);
|
||||
const out = Buffer.from(after.data);
|
||||
const fade = !mode.startsWith('green');
|
||||
let changed = 0;
|
||||
|
||||
if (fade) {
|
||||
for (let pos = 0; pos < out.length; pos += 4) {
|
||||
const grey = y(out[pos], out[pos + 1], out[pos + 2]);
|
||||
const value = 255 + (grey - 255) * 0.1;
|
||||
out[pos] = value;
|
||||
out[pos + 1] = value;
|
||||
out[pos + 2] = value;
|
||||
out[pos + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let column = 0; column < width; column += 1) {
|
||||
const basePos = (row * base.width + column) * 4;
|
||||
const afterPos = (row * after.width + column) * 4;
|
||||
const delta = colorDelta(base.data, after.data, basePos, afterPos);
|
||||
if (Math.abs(delta) <= maxDelta) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!opts['include-aa'] &&
|
||||
(antialiased(base.data, column, row, base.width, base.height, after.data) ||
|
||||
antialiased(after.data, column, row, after.width, after.height, base.data))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changed += 1;
|
||||
out[afterPos] = highlight[0];
|
||||
out[afterPos + 1] = highlight[1];
|
||||
out[afterPos + 2] = highlight[2];
|
||||
out[afterPos + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// A shot that grew or shrank has no counterpart for the extra rows and
|
||||
// columns, so all of them are a change.
|
||||
const extra =
|
||||
after.width * after.height -
|
||||
width * height +
|
||||
(base.width * base.height - width * height);
|
||||
|
||||
return {
|
||||
data: out,
|
||||
width: after.width,
|
||||
height: after.height,
|
||||
changed: changed + extra,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What each run was and how it was configured, from the `shots.json`
|
||||
* story-shots.mjs leaves beside its output. A run shot before that existed, or
|
||||
* a directory assembled by hand, simply gets no caption.
|
||||
*/
|
||||
const manifest = async (dir) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(dir, 'shots.json'), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [baseRun, afterRun] = await Promise.all([
|
||||
manifest(baseDir),
|
||||
manifest(afterDir),
|
||||
]);
|
||||
|
||||
/** The settings the two runs disagree on: what a difference in the shots may be. */
|
||||
const changedKeys = CONFIG_KEYS.filter(
|
||||
(key) => (baseRun?.config?.[key] ?? '') !== (afterRun?.config?.[key] ?? ''),
|
||||
);
|
||||
|
||||
const settings = (run, keys) => settingsLine(run?.config, keys);
|
||||
|
||||
const shotOf = (run, rel) =>
|
||||
run?.shots?.find((shot) => shot.file === rel.split(path.sep).join('/'));
|
||||
|
||||
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
|
||||
|
||||
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
|
||||
const withoutCaption = (file, { width, height }, top) =>
|
||||
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
|
||||
|
||||
/** Story, then the settings both runs shared. One line each, widest font first. */
|
||||
const header = (rel) => {
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const shared = settings(
|
||||
afterRun,
|
||||
CONFIG_KEYS.filter((key) => !changedKeys.includes(key)),
|
||||
);
|
||||
return [
|
||||
shot ? `${shot.title}/${shot.name}` : rel.replace(/\.png$/, ''),
|
||||
[shot?.id ?? '', shot?.theme ?? '', shot?.status === 'busy' ? '(busy)' : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
shared,
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
/** A tile's own line: which side it is, and where its run differed. */
|
||||
const sideLabel = (side, run) =>
|
||||
[side, settings(run, changedKeys)].filter(Boolean).join(' ');
|
||||
|
||||
const captionLines = (rel, lines) =>
|
||||
opts['no-caption'] ? [] : [...header(rel), ...lines].filter(Boolean);
|
||||
|
||||
const pngs = async (dir, prefix = '') => {
|
||||
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const rel = path.join(prefix, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await pngs(dir, rel)));
|
||||
} else if (entry.name.endsWith('.png')) {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const results = [];
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
for (const rel of (await pngs(baseDir)).sort()) {
|
||||
const afterFile = path.join(afterDir, rel);
|
||||
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
|
||||
let after;
|
||||
try {
|
||||
after = readRgba(afterFile, captionOf(afterRun, rel));
|
||||
} catch {
|
||||
console.error(`missing in after: ${rel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
|
||||
const diff = diffPair(base, after, opts.mode);
|
||||
const target = path.join(outDir, rel);
|
||||
const parallel = opts.mode.endsWith('-parallel');
|
||||
// With no tiles to label, a run's own settings go in the caption instead.
|
||||
const caption = captionLines(
|
||||
rel,
|
||||
parallel || !changedKeys.length
|
||||
? []
|
||||
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
|
||||
);
|
||||
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
|
||||
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
|
||||
|
||||
// The gutter is the opposite of the theme's own background, so the tiles and
|
||||
// the caption keep an edge instead of bleeding into it.
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const theme = shot?.theme ?? rel.split(path.sep)[0];
|
||||
const { background, foreground } = palette(theme);
|
||||
|
||||
if (parallel) {
|
||||
await writeRgba(diff, diffFile);
|
||||
const tile = (label, file) => [
|
||||
'(',
|
||||
`label:${literal(label)}`,
|
||||
file,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
'-bordercolor',
|
||||
background,
|
||||
'-border',
|
||||
'12',
|
||||
')',
|
||||
];
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
// The tiles end up side by side, so they are read at the montage's width.
|
||||
String(Math.round(pointsize(after.width * 3) * 0.62)),
|
||||
...tile(
|
||||
sideLabel('previous', baseRun),
|
||||
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
|
||||
),
|
||||
...tile(
|
||||
sideLabel('current', afterRun),
|
||||
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
|
||||
),
|
||||
...tile('diff', diffFile),
|
||||
'-gravity',
|
||||
'north',
|
||||
'+append',
|
||||
caption.length ? body : target,
|
||||
]);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
} else {
|
||||
await writeRgba(diff, caption.length ? body : target);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
}
|
||||
|
||||
results.push([diff.changed, rel]);
|
||||
}
|
||||
|
||||
results
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([changed, rel]) =>
|
||||
console.log(`${String(changed).padStart(10)} ${rel}`),
|
||||
);
|
||||
|
||||
console.error(`diffs in ${outDir}`);
|
||||
514
frontend/scripts/story-shots.mjs
Executable file
514
frontend/scripts/story-shots.mjs
Executable file
@@ -0,0 +1,514 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
CONFIG_KEYS,
|
||||
hasMagick,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
|
||||
/**
|
||||
* The wall clock every shot is taken at, passed to the preview as `storyClock`.
|
||||
* `.storybook/preview-head.html` freezes the same instant by itself, so a
|
||||
* Chromatic build reads the clock this run does.
|
||||
*/
|
||||
const FROZEN_CLOCK = '2026-06-15T12:00:00.000Z';
|
||||
|
||||
const { values: opts, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
out: { type: 'string', short: 'o' },
|
||||
stories: { type: 'string', multiple: true, default: [] },
|
||||
title: { type: 'string', default: '' },
|
||||
name: { type: 'string', default: '' },
|
||||
theme: { type: 'string', multiple: true, default: [] },
|
||||
args: { type: 'string', multiple: true, default: [] },
|
||||
port: { type: 'string', default: process.env.SB_PORT ?? '6006' },
|
||||
width: { type: 'string', default: '1680' },
|
||||
height: { type: 'string', default: '1200' },
|
||||
'max-height': { type: 'string', default: '8000' },
|
||||
grow: { type: 'string', default: 'scrollers' },
|
||||
settle: { type: 'string', default: '1500' },
|
||||
clock: { type: 'string', default: FROZEN_CLOCK },
|
||||
motion: { type: 'boolean', default: false },
|
||||
ignore: { type: 'string', multiple: true, default: [] },
|
||||
flat: { type: 'boolean', default: false },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
list: { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const outDir = opts.out ?? positionals[0];
|
||||
const themes = opts.theme.flatMap((value) => value.split(',')).filter(Boolean);
|
||||
const storyArgs = opts.args.filter(Boolean).join(';');
|
||||
|
||||
if (opts.help || (!outDir && !opts.list)) {
|
||||
console.log(`usage: node scripts/story-shots.mjs <out-dir> [options]
|
||||
|
||||
--stories <match> only stories whose id or title/name path contains <match>
|
||||
(repeatable, comma-separated; default: every story)
|
||||
--title <prefix> only stories whose title starts with <prefix>
|
||||
--name <match> only stories whose name contains <match>
|
||||
--theme <themes> themes to shoot, e.g. dark,light (default: story default)
|
||||
--args <k:v;k2:v2> arg overrides, storybook's own ?args= syntax (repeatable).
|
||||
A value containing a dot is dropped by storybook itself
|
||||
--port <port> storybook dev server port (default 6006, or $SB_PORT)
|
||||
--width <px> viewport width, the only fixed dimension (default 1680)
|
||||
--height <px> shortest the viewport may be (default 1200)
|
||||
--max-height <px> tallest the viewport may grow to (default 8000)
|
||||
--grow <what> scrollers (default) grows the viewport until the page's
|
||||
own scrollers fit, document only follows the document
|
||||
height (a no-op on any page with the app shell), none
|
||||
keeps --height
|
||||
--settle <ms> wait after the page goes quiet (default 1500)
|
||||
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
|
||||
--motion keep animations and transitions running
|
||||
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
|
||||
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
|
||||
--no-caption do not stamp the story and the run settings on the shot
|
||||
--list print the matched stories and exit
|
||||
|
||||
Screenshots land in <out-dir>/<theme>/<story-id>.png, alongside a shots.json
|
||||
recording what each shot is, how the run was configured, and how tall the
|
||||
caption on it is. story-shots-diff.mjs reads that to crop the caption off before
|
||||
comparing, so two runs never diff their own captions.
|
||||
|
||||
Captioning needs ImageMagick; without it the shots are written bare.
|
||||
|
||||
Playwright is looked up in tests/e2e, then in the global install; override with
|
||||
PLAYWRIGHT_MODULE. The browser is playwright's own chromium, else an installed
|
||||
Chrome; override with CHROME_PATH.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const base = `http://localhost:${opts.port}`;
|
||||
|
||||
// `index.json` carries raw control characters from story jsdoc, so it is read as
|
||||
// text rather than piped through anything that revalidates it.
|
||||
const index = JSON.parse(await (await fetch(`${base}/index.json`)).text());
|
||||
|
||||
const matches = opts.stories
|
||||
.flatMap((value) => value.split(','))
|
||||
.filter(Boolean);
|
||||
|
||||
const stories = Object.values(index.entries)
|
||||
.filter((entry) => {
|
||||
if (entry.type !== 'story') {
|
||||
return false;
|
||||
}
|
||||
if (opts.title && !entry.title.startsWith(opts.title)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
opts.name &&
|
||||
!entry.name.toLowerCase().includes(opts.name.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!matches.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${entry.id} ${entry.title}/${entry.name}`.toLowerCase();
|
||||
return matches.some((match) => haystack.includes(match.toLowerCase()));
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
if (opts.list) {
|
||||
stories.forEach((story) =>
|
||||
console.log(`${story.id}\t${story.title}/${story.name}`),
|
||||
);
|
||||
console.log(`${stories.length} stories`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!stories.length) {
|
||||
console.error('no story matched');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ignoreSelectors = opts.ignore
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
|
||||
console.error(`--clock: not a date: ${opts.clock}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[data-shot-ignore]` and `--ignore` hide what cannot be settled, the local
|
||||
* half of Chromatic's `data-chromatic="ignore"`. Everything else the shot needs
|
||||
* held still - the frozen clock, the parked animations, the lists snapped onto
|
||||
* their bottom - is done by the preview itself, so a Chromatic build and a shot
|
||||
* from here see the same page.
|
||||
*/
|
||||
const ignoreCss = (
|
||||
ignore,
|
||||
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
|
||||
.map((selector) => `, ${selector}`)
|
||||
.join('')} {
|
||||
visibility: hidden !important;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
|
||||
* or wherever `$PLAYWRIGHT_MODULE` points. `@playwright/test` re-exports
|
||||
* `chromium`, so an e2e install alone is enough.
|
||||
*/
|
||||
const resolvePlaywright = () => {
|
||||
const specifiers = process.env.PLAYWRIGHT_MODULE
|
||||
? [process.env.PLAYWRIGHT_MODULE]
|
||||
: ['playwright', '@playwright/test'];
|
||||
|
||||
const find = (roots) => {
|
||||
for (const specifier of specifiers) {
|
||||
for (const root of roots) {
|
||||
try {
|
||||
return createRequire(path.join(root, '-')).resolve(specifier);
|
||||
} catch {
|
||||
/* next candidate */
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const local = find([
|
||||
import.meta.dirname,
|
||||
path.resolve(import.meta.dirname, '../../tests/e2e'),
|
||||
]);
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
|
||||
// `npm root -g` prints the global node_modules; resolution starts a level up.
|
||||
const globalRoot = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
|
||||
const global =
|
||||
globalRoot.status === 0 && find([path.dirname(globalRoot.stdout.trim())]);
|
||||
if (global) {
|
||||
return global;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'playwright not found. Install it (pnpm -C tests/e2e install, or npm i -g playwright) or set PLAYWRIGHT_MODULE.',
|
||||
);
|
||||
return process.exit(1);
|
||||
};
|
||||
|
||||
const pwModule = await import(pathToFileURL(resolvePlaywright()).href);
|
||||
const pw = pwModule.chromium ? pwModule : pwModule.default;
|
||||
|
||||
console.log(
|
||||
`${stories.length} stories x ${themes.length || 1} theme(s) -> ${outDir}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* A playwright install carries no browser of its own, and the revision it wants
|
||||
* is often not the one that was downloaded, so an installed Chrome is the
|
||||
* fallback before giving up.
|
||||
*/
|
||||
const launch = async () => {
|
||||
if (process.env.CHROME_PATH) {
|
||||
return pw.chromium.launch({ executablePath: process.env.CHROME_PATH });
|
||||
}
|
||||
try {
|
||||
return await pw.chromium.launch();
|
||||
} catch (error) {
|
||||
try {
|
||||
return await pw.chromium.launch({ channel: 'chrome' });
|
||||
} catch {
|
||||
console.error(
|
||||
`${error.message.split('\n')[0]}\nRun 'playwright install chromium' or set CHROME_PATH to a browser binary.`,
|
||||
);
|
||||
return process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await launch();
|
||||
|
||||
const failures = [];
|
||||
const shots = [];
|
||||
|
||||
const runConfig = {
|
||||
args: storyArgs,
|
||||
clock: opts.clock,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
grow: opts.grow,
|
||||
motion: opts.motion ? 'live' : 'still',
|
||||
settle: opts.settle,
|
||||
ignore: ignoreSelectors.join(', '),
|
||||
};
|
||||
|
||||
const captioning = !opts['no-caption'] && hasMagick();
|
||||
|
||||
if (!opts['no-caption'] && !captioning) {
|
||||
console.error('ImageMagick not found: shots are written without a caption.');
|
||||
}
|
||||
|
||||
const configLine = settingsLine(runConfig, CONFIG_KEYS);
|
||||
|
||||
for (const theme of themes.length ? themes : [null]) {
|
||||
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
|
||||
await mkdir(dir, { recursive: true });
|
||||
if (theme) {
|
||||
console.log(`\n[${theme}]`);
|
||||
}
|
||||
|
||||
for (const story of stories) {
|
||||
// A context per story: reusing one page loses the msw worker
|
||||
// re-registration race after a few navigations and the story then dies on
|
||||
// a missing worker.
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: Number(opts.width), height: Number(opts.height) },
|
||||
reducedMotion: opts.motion ? 'no-preference' : 'reduce',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// react-query retries and msw both keep requests going long after load, so
|
||||
// the settle waits on the page being quiet rather than on a fixed delay.
|
||||
let inFlight = 0;
|
||||
let lastActivity = Date.now();
|
||||
page.on('request', () => {
|
||||
inFlight += 1;
|
||||
lastActivity = Date.now();
|
||||
});
|
||||
const done = () => {
|
||||
inFlight = Math.max(inFlight - 1, 0);
|
||||
lastActivity = Date.now();
|
||||
};
|
||||
page.on('requestfinished', done);
|
||||
page.on('requestfailed', done);
|
||||
|
||||
// The height the rounds had reached when the page turned out to grow with
|
||||
// the viewport, kept only to flag the story in the log.
|
||||
let chasing = 0;
|
||||
|
||||
const url = new URL(`${base}/iframe.html`);
|
||||
url.searchParams.set('viewMode', 'story');
|
||||
url.searchParams.set('id', story.id);
|
||||
// The preview owns the clock and the motion state, so both are asked for in
|
||||
// the URL rather than injected here: a Chromatic build gets the defaults.
|
||||
url.searchParams.set('storyClock', opts.clock);
|
||||
const globals = [theme && `theme:${theme}`, opts.motion && 'motion:live']
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
if (globals) {
|
||||
url.searchParams.set('globals', globals);
|
||||
}
|
||||
if (storyArgs) {
|
||||
url.searchParams.set('args', storyArgs);
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Storybook's own render phase is the readiness signal: it reaches
|
||||
// `finished` only once the loaders, the decorators and the story's `play`
|
||||
// are all done, which a DOM check cannot see. The dev server transforms
|
||||
// each page module on first visit, so this is the slow wait.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window.__STORYBOOK_PREVIEW__?.storyRenders ?? []).some((render) =>
|
||||
['finished', 'errored', 'aborted'].includes(render.phase),
|
||||
) || document.body.classList.contains('sb-show-errordisplay'),
|
||||
undefined,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
|
||||
await page.addStyleTag({ content: ignoreCss(ignoreSelectors) });
|
||||
if (!opts.motion) {
|
||||
// Videos and GIFs are parked on their first frame, as Chromatic does.
|
||||
await page.evaluate(() =>
|
||||
document.querySelectorAll('video').forEach((video) => video.pause?.()),
|
||||
);
|
||||
}
|
||||
|
||||
// Text reflows when a webfont lands, so the shot waits for the faces the
|
||||
// page asked for. Some stories keep a request open by design, hence the
|
||||
// cap on the quiet wait rather than a plain networkidle.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const quietUntil = Date.now() + 15_000;
|
||||
while (
|
||||
Date.now() < quietUntil &&
|
||||
(inFlight > 0 || Date.now() - lastActivity < 600)
|
||||
) {
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
|
||||
// The width is the fixed dimension and the height follows the page, the
|
||||
// way a Chromatic viewport does. `src/styles.scss` pins
|
||||
// `html, body, #root` to `height: 100%; overflow: hidden`, so the
|
||||
// document can never outgrow the viewport and its height says nothing
|
||||
// about what is on the page: what overflows are the shell's inner
|
||||
// scrollers. `scrollers` grows the viewport until the tallest of those
|
||||
// fits, so nothing is cut off and no scrollbar is left in the shot.
|
||||
// Growing changes the layout, hence the rounds. A page that sizes a panel
|
||||
// in `vh` grows its own content as the viewport grows, so no height ever
|
||||
// fits it and the rounds only chase: `.alert-chart-container` is `57vh`,
|
||||
// which puts Create Alert's fixed point at 4344px with an empty band on
|
||||
// top. Such a page is shot at `--height` with its own scrollbar instead,
|
||||
// which is what it looks like in a browser.
|
||||
if (opts.grow !== 'none') {
|
||||
const maximum = Number(opts['max-height']);
|
||||
const requested = Number(opts.height);
|
||||
let height = requested;
|
||||
let fits = false;
|
||||
for (let round = 0; round < 3 && !fits; round += 1) {
|
||||
const needed = Math.min(
|
||||
maximum,
|
||||
await page.evaluate((withScrollers) => {
|
||||
const document_ = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
);
|
||||
if (!withScrollers) {
|
||||
return document_;
|
||||
}
|
||||
|
||||
// Popups are skipped: they are out of the flow, and a tall
|
||||
// dropdown or tooltip would otherwise drag the shot to a
|
||||
// height nothing on the page itself needs.
|
||||
const inFlow = (element) => {
|
||||
for (
|
||||
let node = element;
|
||||
node && node !== document.documentElement;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const { position } = getComputedStyle(node);
|
||||
if (position === 'fixed' || position === 'absolute') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [...document.querySelectorAll('*')].reduce((tallest, element) => {
|
||||
const { overflowY } = getComputedStyle(element);
|
||||
if (
|
||||
!['auto', 'scroll', 'overlay'].includes(overflowY) ||
|
||||
element.scrollHeight - element.clientHeight <= 1 ||
|
||||
!inFlow(element)
|
||||
) {
|
||||
return tallest;
|
||||
}
|
||||
|
||||
const box = element.getBoundingClientRect();
|
||||
const above = box.top + window.scrollY;
|
||||
const below = Math.max(0, document_ - (box.bottom + window.scrollY));
|
||||
return Math.max(tallest, above + element.scrollHeight + below);
|
||||
}, document_);
|
||||
}, opts.grow === 'scrollers'),
|
||||
);
|
||||
fits = needed <= height;
|
||||
if (fits) {
|
||||
break;
|
||||
}
|
||||
|
||||
height = needed;
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
|
||||
if (!fits && height !== requested) {
|
||||
chasing = height;
|
||||
height = requested;
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
}
|
||||
|
||||
// The preview snapped its bottom-pinned lists at `afterEach`, before the
|
||||
// page went quiet; a virtuoso list is usually still measuring then.
|
||||
await page.evaluate(() => window.__signozSnapPinnedScrollers?.());
|
||||
|
||||
// A page that is still moving — a list scrolling itself to the bottom, a
|
||||
// monaco editor re-measuring, a tooltip being repositioned — is shot
|
||||
// twice in a row until two frames come back identical, since what the
|
||||
// page is waiting on is not observable from here.
|
||||
let shot = await page.screenshot();
|
||||
let stable = false;
|
||||
for (let attempt = 0; attempt < 8 && !stable; attempt += 1) {
|
||||
await page.waitForTimeout(400);
|
||||
const next = await page.screenshot();
|
||||
stable = next.equals(shot);
|
||||
shot = next;
|
||||
}
|
||||
|
||||
const file = path.join(dir, `${story.id}.png`);
|
||||
await writeFile(file, shot);
|
||||
|
||||
// The band goes on the shot itself so a single screenshot says what it
|
||||
// is, and its height is recorded so a diff can take it back off.
|
||||
let caption = 0;
|
||||
if (captioning) {
|
||||
const temporary = path.join(
|
||||
os.tmpdir(),
|
||||
`story-shots-caption-${process.pid}.png`,
|
||||
);
|
||||
caption = stamp({
|
||||
lines: [
|
||||
`${story.title}/${story.name}`,
|
||||
[story.id, theme ?? 'default', stable ? '' : '(busy)']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
configLine,
|
||||
].filter(Boolean),
|
||||
from: file,
|
||||
to: temporary,
|
||||
theme: theme ?? 'dark',
|
||||
});
|
||||
await rename(temporary, file);
|
||||
}
|
||||
|
||||
shots.push({
|
||||
file: path.posix.join(
|
||||
opts.flat ? '' : (theme ?? 'default'),
|
||||
`${story.id}.png`,
|
||||
),
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
name: story.name,
|
||||
theme: theme ?? 'default',
|
||||
status: stable ? 'ok' : 'busy',
|
||||
caption,
|
||||
});
|
||||
console.log(
|
||||
` ${stable ? 'ok ' : 'busy'} ${story.id}${
|
||||
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(`${theme ?? 'default'}/${story.id}`);
|
||||
console.log(` FAIL ${story.id}: ${error.message.split('\n')[0]}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// The diff script captions its output from this, so the run's own settings sit
|
||||
// next to the shots they produced rather than only in the shell history.
|
||||
await writeFile(
|
||||
path.join(outDir, 'shots.json'),
|
||||
`${JSON.stringify({ config: runConfig, shots }, null, '\t')}\n`,
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed: ${failures.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { Route, Router, Switch } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AppPageProviders from 'app/AppPageProviders';
|
||||
import AppShell from 'app/AppShell';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
@@ -17,22 +18,15 @@ import ROUTES from 'constants/routes';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
import Hex from 'crypto-js/enc-hex';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256';
|
||||
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { NotificationProvider } from 'hooks/useNotifications';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import history from 'lib/history';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import posthog from 'posthog-js';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
|
||||
@@ -44,8 +38,17 @@ import defaultRoutes, {
|
||||
SUPPORT_ROUTE,
|
||||
} from './routes';
|
||||
|
||||
const appRouter = (children: ReactNode): ReactNode => (
|
||||
<Router history={history}>
|
||||
<CompatRouter>{children}</CompatRouter>
|
||||
</Router>
|
||||
);
|
||||
|
||||
const appLayout = (children: ReactNode): ReactNode => (
|
||||
<AppLayout>{children}</AppLayout>
|
||||
);
|
||||
|
||||
function App(): JSX.Element {
|
||||
const themeConfig = useThemeConfig();
|
||||
const {
|
||||
user,
|
||||
isFetchingUser,
|
||||
@@ -451,48 +454,36 @@ function App(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{isLoggedInState && <CmdKPalette userRole={user.role} />}
|
||||
{isLoggedInState && (
|
||||
<ShiftHoldOverlayController userRole={user.role} />
|
||||
)}
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
<AppShell
|
||||
router={appRouter}
|
||||
overlays={
|
||||
isLoggedInState && (
|
||||
<>
|
||||
<CmdKPalette userRole={user.role} />
|
||||
<ShiftHoldOverlayController userRole={user.role} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PrivateRoute>
|
||||
<AppPageProviders layout={appLayout}>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</AppPageProviders>
|
||||
</PrivateRoute>
|
||||
</AppShell>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
61
frontend/src/app/AppPageProviders.tsx
Normal file
61
frontend/src/app/AppPageProviders.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import {
|
||||
QueryBuilderContext,
|
||||
QueryBuilderProvider,
|
||||
} from 'providers/QueryBuilder';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import { AppLayer } from './types';
|
||||
|
||||
export interface AppPageProvidersProps {
|
||||
children: ReactNode;
|
||||
layout: AppLayer;
|
||||
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
|
||||
queryBuilder?: Partial<QueryBuilderContextType>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layers a routed page renders in, below `PrivateRoute` and inside the app
|
||||
* chrome. A new provider belongs here when only pages need it, or when it has to
|
||||
* sit inside `AppLayout`.
|
||||
*
|
||||
* One ordering constraint: `AppLayout` calls `useKeyboardHotkeys`, so the
|
||||
* hotkeys provider has to stay above the layout. The rest of the order is the
|
||||
* one `AppRoutes` has, kept as-is so a story and a route render the same tree.
|
||||
*/
|
||||
function AppPageProviders({
|
||||
children,
|
||||
layout,
|
||||
queryBuilder,
|
||||
}: AppPageProvidersProps): JSX.Element {
|
||||
const hotkeys = (
|
||||
<KeyboardHotkeysProvider>
|
||||
<>
|
||||
{layout(<PreferenceContextProvider>{children}</PreferenceContextProvider>)}
|
||||
</>
|
||||
</KeyboardHotkeysProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResourceProvider>
|
||||
{queryBuilder ? (
|
||||
<QueryBuilderContext.Provider
|
||||
value={queryBuilder as QueryBuilderContextType}
|
||||
>
|
||||
{hotkeys}
|
||||
</QueryBuilderContext.Provider>
|
||||
) : (
|
||||
<QueryBuilderProvider>{hotkeys}</QueryBuilderProvider>
|
||||
)}
|
||||
</ResourceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
AppPageProviders.defaultProps = {
|
||||
queryBuilder: undefined,
|
||||
};
|
||||
|
||||
export default AppPageProviders;
|
||||
56
frontend/src/app/AppProviders.tsx
Normal file
56
frontend/src/app/AppProviders.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
|
||||
import { AppLayer } from './types';
|
||||
|
||||
export interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
store: Store;
|
||||
queryClient: QueryClient;
|
||||
appContext: AppLayer;
|
||||
searchParams: AppLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layers that exist before the app knows anything. Mounted for the whole
|
||||
* session, including while `AppProvider` is still fetching the user and the boot
|
||||
* spinner is on screen, and never remounted after that.
|
||||
*
|
||||
* A new provider belongs here only if it holds process-wide state that does not
|
||||
* depend on the user, the license or the route. One that fetches on mount would
|
||||
* fire unauthenticated from here; put it in `AppShell` or lower.
|
||||
*/
|
||||
function AppProviders({
|
||||
children,
|
||||
store,
|
||||
queryClient,
|
||||
appContext,
|
||||
searchParams,
|
||||
}: AppProvidersProps): JSX.Element {
|
||||
return (
|
||||
<HelmetProvider>
|
||||
{searchParams(
|
||||
<ThemeProvider>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
{appContext(children)}
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</ThemeProvider>,
|
||||
)}
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppProviders;
|
||||
52
frontend/src/app/AppShell.tsx
Normal file
52
frontend/src/app/AppShell.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { useThemeConfig } from 'hooks/useDarkMode';
|
||||
import { NotificationProvider } from 'hooks/useNotifications';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { AppLayer } from './types';
|
||||
|
||||
export interface AppShellProps {
|
||||
children: ReactNode;
|
||||
router: AppLayer;
|
||||
/** Mounted beside the routed content: the command palette and its siblings. */
|
||||
overlays?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layers between a resolved session and a page. Mounted once the boot
|
||||
* fetches settle, above `PrivateRoute`, so it also covers the redirects and the
|
||||
* not-found route, and it survives every navigation.
|
||||
*
|
||||
* A new provider belongs here if it needs the router or the user and has to
|
||||
* outlive the page: a global overlay, a shortcut host, anything one route opens
|
||||
* and the next one keeps.
|
||||
*
|
||||
* `ConfigProvider` reads `useThemeConfig`, which needs `ThemeProvider` above it,
|
||||
* so the antd theme is settled here instead of by the caller.
|
||||
*/
|
||||
function AppShell({ children, router, overlays }: AppShellProps): JSX.Element {
|
||||
const themeConfig = useThemeConfig();
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
{router(
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{overlays}
|
||||
{children}
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>,
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
AppShell.defaultProps = {
|
||||
overlays: undefined,
|
||||
};
|
||||
|
||||
export default AppShell;
|
||||
3
frontend/src/app/types.ts
Normal file
3
frontend/src/app/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type AppLayer = (children: ReactNode) => ReactNode;
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
deriveCheckboxState,
|
||||
getNotInOperator,
|
||||
} from './checkboxFilterQuery';
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
@@ -505,7 +505,7 @@ describe('clearFilterFromQuery', () => {
|
||||
|
||||
const result = clearFilterFromQuery({
|
||||
currentQuery: query,
|
||||
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
|
||||
filterKey: KEY,
|
||||
activeQueryIndex: 0,
|
||||
});
|
||||
|
||||
@@ -523,4 +523,35 @@ describe('clearFilterFromQuery', () => {
|
||||
expect(other.filters?.items).toHaveLength(1);
|
||||
expect(other.filter?.expression).toBe(`${KEY} = 'a'`);
|
||||
});
|
||||
|
||||
it('without an operators list, clears non-managed clauses too (duration >= / <=)', () => {
|
||||
const query = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
toTagItem({ key: 'durationNano', op: '>=', value: 5000000 }, 0),
|
||||
toTagItem({ key: 'durationNano', op: '<=', value: 9000000 }, 1),
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: {
|
||||
expression: `durationNano >= 5000000 AND durationNano <= 9000000 AND http.method = 'GET'`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const result = clearFilterFromQuery({
|
||||
currentQuery: query,
|
||||
filterKey: 'durationNano',
|
||||
activeQueryIndex: 0,
|
||||
});
|
||||
|
||||
const active = result.builder.queryData[0];
|
||||
expect(active.filters?.items).toStrictEqual([]);
|
||||
expect(active.filter?.expression).toBe(`http.method = 'GET'`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,12 @@ export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
// The operators this algebra emits, and so the only ones it may rewrite out of an
|
||||
// expression. A hand-written clause on the same key (CONTAINS, EXISTS, a range) is
|
||||
// none of its business and has to survive a toggle.
|
||||
const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
|
||||
export const MANAGED_OPERATORS = [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
'in',
|
||||
'not in',
|
||||
];
|
||||
|
||||
/**
|
||||
* Drops this filter's own clauses for `key` from `expression`, leaving every other
|
||||
@@ -31,7 +36,7 @@ const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
|
||||
* prefixes, since `isKeyMatch` treats `service.name` and `resource.service.name` as
|
||||
* the same filter but expression rewrites match keys literally.
|
||||
*/
|
||||
function removeManagedClauses(expression: string, key: string): string {
|
||||
export function removeManagedClauses(expression: string, key: string): string {
|
||||
return removeKeysFromExpression(
|
||||
expression,
|
||||
getKeySpellings(key),
|
||||
@@ -124,49 +129,6 @@ export function deriveCheckboxState({
|
||||
return filterState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new query with this filter's clauses for the attribute key removed from
|
||||
* the active query, both from the structured filter items and the raw expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => {
|
||||
if (idx !== activeQueryIndex) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeManagedClauses(
|
||||
item.filter?.expression ?? '',
|
||||
filter.attributeKey.key,
|
||||
),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || [],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
|
||||
@@ -4,13 +4,11 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { isEqual, isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
import { applyCheckboxToggle, MANAGED_OPERATORS } from './checkboxFilterQuery';
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
@@ -94,7 +92,17 @@ function useCheckboxFilterActions({
|
||||
};
|
||||
|
||||
const onClear = (): void => {
|
||||
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
|
||||
const clearedQuery = clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey: filter.attributeKey.key,
|
||||
activeQueryIndex,
|
||||
operators: MANAGED_OPERATORS,
|
||||
});
|
||||
// Nothing to clear; no dispatch
|
||||
if (isEqual(clearedQuery, currentQuery)) {
|
||||
return;
|
||||
}
|
||||
dispatch(clearedQuery);
|
||||
};
|
||||
|
||||
return { onChange, onClear };
|
||||
|
||||
@@ -6,6 +6,44 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.sectionActions {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
width 0s linear 0.16s;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActions,
|
||||
.sectionActions.sectionActionsPinned {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
|
||||
@@ -70,6 +70,13 @@ export function setupServer(): void {
|
||||
afterAll(() => server.close());
|
||||
}
|
||||
|
||||
// Components read currentQuery for the checkbox state and stagedQuery for the
|
||||
// values fetch; in the app both are set by the same URL sync, so tests pass one
|
||||
// query as both.
|
||||
export function buildQueryBuilderOverrides(query: unknown): never {
|
||||
return { currentQuery: query, stagedQuery: query } as unknown as never;
|
||||
}
|
||||
|
||||
export interface FilterItemConfig {
|
||||
op: string;
|
||||
value: string | string[];
|
||||
@@ -101,18 +108,16 @@ export function renderWithFilter(
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
@@ -44,8 +45,14 @@ export default function CheckboxFilterV2(
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const handleToggleSearch = (): void => {
|
||||
setIsSearchOpen((prev) => !prev);
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
@@ -74,6 +81,7 @@ export default function CheckboxFilterV2(
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
source,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
@@ -153,6 +161,7 @@ export default function CheckboxFilterV2(
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
isRelatedValuesSupported: useFieldApis.existingQuery !== null,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
});
|
||||
@@ -162,12 +171,15 @@ export default function CheckboxFilterV2(
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
actionsClassName={classNames(styles.sectionActions, {
|
||||
[styles.sectionActionsPinned]: isSearchOpen,
|
||||
})}
|
||||
resetActionClassName={
|
||||
isSearchOpen ? styles.sectionActionHoverOnly : undefined
|
||||
}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
<section>
|
||||
@@ -176,23 +188,26 @@ export default function CheckboxFilterV2(
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
{isSearchOpen && (
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalCount > 0 && (
|
||||
<section className={styles.values}>
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.leftAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
// The collapse chevron must keep its size; only the title absorbs the squeeze.
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -18,16 +26,18 @@
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
// Always ellipsize a long name; on hover the actions take width and it
|
||||
// compresses further.
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rightAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.clearAll {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
|
||||
|
||||
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
actionsClassName?: string;
|
||||
resetActionClassName?: string;
|
||||
onToggleOpen: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onClear: () => void;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
actionsClassName,
|
||||
resetActionClassName,
|
||||
onToggleOpen,
|
||||
onToggleSearch,
|
||||
onClear,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
|
||||
|
||||
const measureTitle = (el: HTMLElement | null): void => {
|
||||
if (el) {
|
||||
setIsTitleTruncated(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const titleText = (
|
||||
<Typography.Text ref={measureTitle} className={styles.title}>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
role="button"
|
||||
@@ -40,23 +62,31 @@ export function CheckboxFilterV2Header({
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
|
||||
<Typography.Text
|
||||
className={styles.clearAll}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
data-testid="checkbox-filter-clear-all"
|
||||
>
|
||||
Clear
|
||||
</Typography.Text>
|
||||
{isTitleTruncated ? (
|
||||
<TooltipSimple title={title} delayDuration={400}>
|
||||
{titleText}
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
titleText
|
||||
)}
|
||||
</section>
|
||||
{isOpen && (
|
||||
<section className={classNames(styles.rightAction, actionsClassName)}>
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
className={resetActionClassName}
|
||||
tooltip="Reset"
|
||||
onClick={onClear}
|
||||
testId="checkbox-filter-clear-all"
|
||||
/>
|
||||
<SectionActionButton
|
||||
icon={<Search size={14} />}
|
||||
tooltip="Search"
|
||||
onClick={onToggleSearch}
|
||||
testId="checkbox-filter-search-toggle"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
@@ -57,18 +58,16 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -91,18 +90,16 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -124,27 +121,25 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -164,18 +159,16 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -197,26 +190,24 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -236,31 +227,29 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -280,17 +269,15 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
getFilterFromCall,
|
||||
@@ -59,6 +60,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -125,18 +127,16 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -193,6 +194,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -237,6 +239,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
@@ -344,6 +347,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
@@ -490,26 +494,24 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -518,7 +520,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter applied for attribute', async () => {
|
||||
it('shows the reset action when expanded even with no active filter', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
@@ -533,9 +535,45 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
// Reset is always available on an expanded section now (hover-gated via
|
||||
// CSS), not conditional on an active filter.
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not dispatch on clear when the key has no filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
@@ -555,26 +593,24 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -637,7 +673,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(filter?.value).toBe('valueA');
|
||||
});
|
||||
|
||||
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
|
||||
it('adds to NOT IN when unchecking a non-excluded (other) item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
@@ -646,18 +682,70 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
|
||||
// valueB is not excluded, so under NOT IN [valueA] it is still included
|
||||
// and renders checked. Unchecking it excludes it too → NOT IN [A, B].
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(rowB).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toBe('valueB');
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('adds to NOT IN when unchecking a non-excluded item without related values', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['valueA', 'valueB'],
|
||||
});
|
||||
|
||||
// Without related values the display follows the clause: valueB is not
|
||||
// excluded, so it renders checked; unchecking it excludes it too.
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['valueA'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('accumulates both values in IN when toggling checked (related) then unchecked (other)', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
@@ -14,6 +15,88 @@ import {
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - item rules', () => {
|
||||
describe('related values unsupported (existingQuery: null)', () => {
|
||||
it('renders a single flat section even when the api returns related values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-related'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-all-values'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('splits clause values and the rest into selected and all values sections', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toHaveAttribute(
|
||||
'data-state',
|
||||
'unchecked',
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('section-divider-all-values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-related'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('no existing query', () => {
|
||||
it('all values show as checked with no badge when no query exists', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
@@ -65,18 +148,16 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -112,18 +193,16 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -150,27 +229,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -201,26 +278,24 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -251,29 +326,28 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// The excluded value renders unchecked.
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
@@ -282,8 +356,9 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The non-excluded value is still included by NOT IN, so it stays checked.
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -306,27 +381,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -359,18 +432,16 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -403,27 +474,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -460,27 +529,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { CheckboxFilterV2Header } from '../CheckboxFilterV2Header';
|
||||
|
||||
@@ -7,9 +8,8 @@ describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
showClearAll: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onToggleSearch: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -31,11 +31,12 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not show clear button when collapsed', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
|
||||
);
|
||||
it('does not render the section actions when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-search-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -50,36 +51,13 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('shows clear button when expanded + showClearAll=true', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
|
||||
it('renders both search and reset actions when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-toggle'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when showClearAll=false', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter present for attribute', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
showClearAll
|
||||
isSomeFilterPresentForCurrentAttribute={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,28 +100,35 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClear on clear button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear button click does not trigger onToggleOpen', async () => {
|
||||
it('calls onToggleSearch on search click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSearch = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleSearch={onToggleSearch}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
|
||||
expect(onToggleSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClear on reset click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onClear={onClear}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -153,4 +138,51 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('title tooltip', () => {
|
||||
// jsdom has no layout, so truncation is simulated at the prototype level
|
||||
// before mount (the component measures in a layout effect).
|
||||
function mockTitleWidths(scrollWidth: number, clientWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
||||
.mockReturnValue(scrollWidth);
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(clientWidth);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows the full name on hover when the title is truncated', async () => {
|
||||
mockTitleWidths(200, 100);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CheckboxFilterV2Header {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText(defaultProps.title));
|
||||
|
||||
await expect(screen.findByRole('tooltip')).resolves.toHaveTextContent(
|
||||
defaultProps.title,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows no tooltip when the title fits', async () => {
|
||||
mockTitleWidths(100, 100);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CheckboxFilterV2Header {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText(defaultProps.title));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
@@ -23,6 +24,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -38,6 +40,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -48,12 +51,46 @@ describe('itemRules', () => {
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('NOT IN filter, value not excluded, not related → all_values, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('NOT IN filter, value not excluded but related → related wins, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, not selected, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
@@ -70,6 +107,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -86,6 +124,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
@@ -102,6 +141,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -118,6 +158,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -128,4 +169,70 @@ describe('itemRules', () => {
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveItemConfig with related values unsupported', () => {
|
||||
const baseCtx: Omit<ItemContext, 'isSelectedOnFilter' | 'isNotInOperator'> = {
|
||||
isInRelatedValues: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
isRelatedValuesSupported: false,
|
||||
};
|
||||
|
||||
it('no filter on this key → selected, checked, even with an existing query', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
hasFilterForThisKey: false,
|
||||
isSelectedOnFilter: false,
|
||||
isNotInOperator: false,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('excluded by NOT IN → selected, unchecked', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isSelectedOnFilter: true,
|
||||
isNotInOperator: true,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('selected by IN → selected, checked', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isSelectedOnFilter: true,
|
||||
isNotInOperator: false,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('NOT IN complement → all_values, checked, related values ignored', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isSelectedOnFilter: false,
|
||||
isNotInOperator: true,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('IN complement → all_values, unchecked, never related', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isInRelatedValues: true,
|
||||
isSelectedOnFilter: false,
|
||||
isNotInOperator: false,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('useSectionedValues', () => {
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
visibleItemsCount: 10,
|
||||
relatedExclusions: [] as string[],
|
||||
};
|
||||
@@ -26,6 +27,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -43,6 +45,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -71,6 +74,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true, val2: false, val3: false },
|
||||
}),
|
||||
@@ -88,6 +92,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
isNotInOperator: true,
|
||||
currentFilterState: { val1: false, val2: true, val3: true },
|
||||
@@ -110,6 +115,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['zebra', 'apple', 'mango'],
|
||||
allValues: ['zebra', 'apple', 'mango'],
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -126,6 +132,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true },
|
||||
}),
|
||||
@@ -143,6 +150,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: [],
|
||||
allValues: [],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
currentFilterState: {},
|
||||
}),
|
||||
@@ -159,6 +167,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: [],
|
||||
allValues: ['other1', 'other2', 'other3'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -178,6 +187,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['pod-a-1', 'pod-b-1', 'pod-c-1'],
|
||||
allValues: ['pod-a-2', 'pod-b-2', 'pod-c-2'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -218,6 +228,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
// stale API data kept via keepPreviousData
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
@@ -246,6 +257,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
// oldSelected was just de-selected; the rest are genuinely related
|
||||
relatedValues: ['oldSelected', 'relatedA', 'relatedB', 'relatedC'],
|
||||
allValues: ['newValue'],
|
||||
@@ -275,6 +287,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
@@ -293,6 +306,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: [],
|
||||
@@ -314,6 +328,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['related1'],
|
||||
allValues: ['all1'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { selected1: true },
|
||||
}),
|
||||
@@ -337,6 +352,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['r1', 'r2', 'r3', 'r4', 'r5'],
|
||||
allValues: ['a1', 'a2', 'a3', 'a4', 'a5'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 100,
|
||||
}),
|
||||
@@ -355,6 +371,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['r1', 'r2', 'r3'],
|
||||
allValues: ['a1', 'a2', 'a3'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 4,
|
||||
}),
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ItemContext {
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
hasFilterForThisKey: boolean;
|
||||
isRelatedValuesSupported: boolean;
|
||||
}
|
||||
|
||||
export interface DerivedItem extends ItemConfig {
|
||||
@@ -35,7 +36,7 @@ interface ItemRule {
|
||||
config: ItemConfig;
|
||||
}
|
||||
|
||||
const ITEM_RULES: ItemRule[] = [
|
||||
const RELATED_SUPPORTED_RULES: ItemRule[] = [
|
||||
// No existing query and no filter → all checked (selected section)
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
@@ -73,6 +74,16 @@ const ITEM_RULES: ItemRule[] = [
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// filterKey present in query with NOT IN and value not in the list → checked
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
ctx.hasFilterForThisKey && ctx.isNotInOperator && !ctx.isSelectedOnFilter,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// All values (has existing query but not related) → unchecked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery,
|
||||
@@ -84,6 +95,54 @@ const ITEM_RULES: ItemRule[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const RELATED_UNSUPPORTED_RULES: ItemRule[] = [
|
||||
// No filter on this key → included by default
|
||||
{
|
||||
condition: (ctx): boolean => !ctx.hasFilterForThisKey,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Explicitly excluded by NOT IN
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
// Explicitly selected by IN
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Not listed in the key's NOT IN clause → not excluded, still in results
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Not listed in the key's IN clause → filtered out of results
|
||||
{
|
||||
condition: (): boolean => true,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback when no rule matches
|
||||
const DEFAULT_CONFIG: ItemConfig = {
|
||||
section: SectionType.SELECTED,
|
||||
@@ -92,7 +151,10 @@ const DEFAULT_CONFIG: ItemConfig = {
|
||||
};
|
||||
|
||||
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
|
||||
for (const rule of ITEM_RULES) {
|
||||
const rules = ctx.isRelatedValuesSupported
|
||||
? RELATED_SUPPORTED_RULES
|
||||
: RELATED_UNSUPPORTED_RULES;
|
||||
for (const rule of rules) {
|
||||
if (rule.condition(ctx)) {
|
||||
return rule.config;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
}: UseExistingQueryParams): UseExistingQueryResult {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const existingQuery = useMemo(() => {
|
||||
if (useFieldApis.existingQuery === null) {
|
||||
@@ -28,7 +28,7 @@ export function useExistingQuery({
|
||||
return useFieldApis.existingQuery;
|
||||
}
|
||||
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const queryData = stagedQuery?.builder.queryData?.[activeQueryIndex];
|
||||
|
||||
// Prefer V5 filter.expression
|
||||
if (queryData?.filter?.expression) {
|
||||
@@ -43,7 +43,7 @@ export function useExistingQuery({
|
||||
return undefined;
|
||||
}, [
|
||||
useFieldApis.existingQuery,
|
||||
currentQuery.builder.queryData,
|
||||
stagedQuery?.builder.queryData,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
@@ -51,11 +51,11 @@ export function useExistingQuery({
|
||||
// This is separate from existingQuery because existingQuery can be explicitly
|
||||
// disabled (null) while filters still exist in the query for UI purposes
|
||||
const hasExistingQuery = useMemo(() => {
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const queryData = stagedQuery?.builder.queryData?.[activeQueryIndex];
|
||||
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
|
||||
const hasV5Expression = !!queryData?.filter?.expression;
|
||||
return hasV3Items || hasV5Expression || !!existingQuery;
|
||||
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
}, [stagedQuery?.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
|
||||
return { existingQuery, hasExistingQuery };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
@@ -10,6 +16,7 @@ interface UseFieldValuesProps {
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
source?: QuickFiltersSource;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
@@ -31,11 +38,18 @@ export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
const QUICK_FILTERS_SOURCE_TO_SOURCE: Partial<
|
||||
Record<QuickFiltersSource, TelemetrytypesSourceDTO>
|
||||
> = {
|
||||
[QuickFiltersSource.METER_EXPLORER]: TelemetrytypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
@@ -49,6 +63,7 @@ export function useFieldValues({
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SectionedValuesInput {
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
isRelatedValuesSupported: boolean;
|
||||
visibleItemsCount: number;
|
||||
relatedExclusions: string[];
|
||||
}
|
||||
@@ -65,6 +66,7 @@ export function useSectionedValues({
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
isRelatedValuesSupported,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
}: SectionedValuesInput): SectionedValuesOutput {
|
||||
@@ -95,6 +97,7 @@ export function useSectionedValues({
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
|
||||
isRelatedValuesSupported,
|
||||
});
|
||||
}, [
|
||||
relatedValues,
|
||||
@@ -103,6 +106,7 @@ export function useSectionedValues({
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
isRelatedValuesSupported,
|
||||
relatedExclusions,
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,6 +11,21 @@
|
||||
padding-right: 9px !important;
|
||||
}
|
||||
|
||||
.duration-reset {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
&:hover .duration-reset {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ant-collapse-header-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
@@ -105,11 +120,6 @@
|
||||
.section-body-header {
|
||||
display: flex;
|
||||
|
||||
> button {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
.ant-collapse {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Collapse } from 'antd';
|
||||
import { Collapse } from 'antd';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
@@ -14,12 +15,16 @@ import {
|
||||
AllTraceFilterKeys,
|
||||
AllTraceFilterKeyValue,
|
||||
HandleRunProps,
|
||||
traceFilterKeys,
|
||||
unionTagFilterItems,
|
||||
} from 'pages/TracesExplorer/Filter/filterUtils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import './Duration.styles.scss';
|
||||
|
||||
export type FilterType = Record<
|
||||
@@ -268,12 +273,19 @@ function Duration({
|
||||
handleRun();
|
||||
}, [selectedFilters]);
|
||||
|
||||
const onClearHandler = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedFilters?.durationNanoMin || selectedFilters?.durationNanoMax) {
|
||||
handleRun({ clearByType: 'durationNano' });
|
||||
const onClearHandler = (): void => {
|
||||
if (!selectedFilters?.durationNanoMin && !selectedFilters?.durationNanoMax) {
|
||||
return;
|
||||
}
|
||||
const clearedQuery = clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey: traceFilterKeys.durationNano.key,
|
||||
activeQueryIndex,
|
||||
});
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(clearedQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(clearedQuery);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,18 +306,19 @@ function Duration({
|
||||
/>
|
||||
),
|
||||
label: 'Duration',
|
||||
extra: activeKeys.includes('durationNano') ? (
|
||||
<div className="duration-reset">
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
tooltip="Reset"
|
||||
onClick={onClearHandler}
|
||||
testId="collapse-duration-clearBtn"
|
||||
/>
|
||||
</div>
|
||||
) : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{activeKeys.includes('durationNano') && (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={onClearHandler}
|
||||
data-testid="collapse-duration-clearBtn"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.iconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './SectionActionButton.module.scss';
|
||||
|
||||
interface SectionActionButtonProps {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
testId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionActionButton({
|
||||
icon,
|
||||
tooltip,
|
||||
onClick,
|
||||
testId,
|
||||
className,
|
||||
}: SectionActionButtonProps): JSX.Element {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={classNames(styles.iconBtn, className)}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getKeySpellings, isKeyMatch } from '../Checkbox/utils';
|
||||
|
||||
/**
|
||||
* Returns a new query with this filter's clauses for the attribute key removed from
|
||||
* the active query, both from the structured filter items and the raw expression.
|
||||
* `operators` limits which expression clauses are removed; omit to remove every
|
||||
* clause on the key (e.g. duration's >= / <=).
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey,
|
||||
activeQueryIndex,
|
||||
operators,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filterKey: string;
|
||||
activeQueryIndex: number;
|
||||
operators?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => {
|
||||
if (idx !== activeQueryIndex) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(
|
||||
item.filter?.expression ?? '',
|
||||
getKeySpellings(filterKey),
|
||||
false,
|
||||
operators,
|
||||
),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filterKey),
|
||||
) || [],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { QuickFilterCheckboxUseFieldApis } from '../types';
|
||||
|
||||
export function useSignalFieldApis(): QuickFilterCheckboxUseFieldApis {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
existingQuery: null,
|
||||
}),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
|
||||
@@ -11,6 +12,8 @@ import DomainList from './Domains/DomainList';
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
@@ -26,6 +29,7 @@ function Explorer(): JSX.Element {
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<DomainList />
|
||||
|
||||
@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -31,6 +32,7 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
const {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
@@ -144,6 +146,7 @@ function Explorer(): JSX.Element {
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient } from 'react-query';
|
||||
import AppProviders from 'app/AppProviders';
|
||||
import AppRoutes from 'AppRoutes';
|
||||
import { AxiosError } from 'axios';
|
||||
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
import { installTranslationResilience } from 'translation-resilience';
|
||||
@@ -47,27 +43,27 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
const searchParams = (children: ReactNode): ReactNode => (
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
);
|
||||
|
||||
const appContext = (children: ReactNode): ReactNode => (
|
||||
<AppProvider>{children}</AppProvider>
|
||||
);
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<HelmetProvider>
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
<AppProvider>
|
||||
<AppRoutes />
|
||||
</AppProvider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</HelmetProvider>,
|
||||
<AppProviders
|
||||
store={store}
|
||||
queryClient={queryClient}
|
||||
appContext={appContext}
|
||||
searchParams={searchParams}
|
||||
>
|
||||
<AppRoutes />
|
||||
</AppProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,6 +8,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
|
||||
@@ -55,6 +56,8 @@ function AllErrors(): JSX.Element {
|
||||
setShowFilters((prev) => !prev);
|
||||
};
|
||||
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
@@ -64,6 +67,7 @@ function AllErrors(): JSX.Element {
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
200
frontend/src/pages/HomePage/stories/HomePage.stories.mocks.tsx
generated
Normal file
200
frontend/src/pages/HomePage/stories/HomePage.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
countControl,
|
||||
choiceControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
buildAlertRules,
|
||||
buildServices,
|
||||
homeFeatureFlags,
|
||||
homeUserPreferences,
|
||||
HOME_CHECKLIST_STEPS,
|
||||
isSavedViewSignal,
|
||||
metricsOnboardingResponse,
|
||||
queryRangeV5ScalarResponse,
|
||||
recentDashboardsResponse,
|
||||
SAVED_VIEW_SIGNALS,
|
||||
type SavedViewSignal,
|
||||
savedViewsResponse,
|
||||
SERVICES_SOURCES,
|
||||
type ServicesSource,
|
||||
spanMetricsResponse,
|
||||
topLevelOperationsResponse,
|
||||
} from './__story_mockdata__/home';
|
||||
|
||||
const SIGNALS = 'Home · signals';
|
||||
const ONBOARDING = 'Home · onboarding';
|
||||
const LISTS = 'Home · lists';
|
||||
|
||||
const INGESTED_COUNT = 4213;
|
||||
|
||||
/** Home caps every list at five rows, so the control has to go past that. */
|
||||
const LIST_MAX = 8;
|
||||
|
||||
const CHECKLIST_VISIBILITY = ['visible', 'dismissed'] as const;
|
||||
|
||||
type ChecklistVisibility = (typeof CHECKLIST_VISIBILITY)[number];
|
||||
|
||||
interface QueryRangeV5Body {
|
||||
compositeQuery?: { queries?: { spec?: { signal?: string } }[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Home detects logs and traces with one `query_range` call each, told apart by
|
||||
* the signal on the query spec.
|
||||
*/
|
||||
const signalOf = (body: QueryRangeV5Body): string | undefined =>
|
||||
body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
export const homeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
logsIngestion: toggleControl('Logs ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
tracesIngestion: toggleControl('Traces ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
metricsIngestion: toggleControl('Metrics ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
|
||||
group: ONBOARDING,
|
||||
description:
|
||||
'Dismissing it moves the checklist behind the header button, as "I\'ll do this later" does.',
|
||||
options: CHECKLIST_VISIBILITY,
|
||||
value: 'visible',
|
||||
}),
|
||||
skippedSteps: multiChoiceControl('Skipped steps', {
|
||||
group: ONBOARDING,
|
||||
description: 'Steps the user chose to skip. Completion follows the data.',
|
||||
options: HOME_CHECKLIST_STEPS,
|
||||
value: [],
|
||||
}),
|
||||
alertRules: countControl('Alert rules', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
dashboards: countControl('Recent dashboards', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViews: countControl('Saved views per signal', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViewSignals: multiChoiceControl<SavedViewSignal>('Signals with views', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'Explorer tabs that have views; the rest fall back to their empty state.',
|
||||
options: SAVED_VIEW_SIGNALS,
|
||||
value: SAVED_VIEW_SIGNALS,
|
||||
}),
|
||||
services: countControl('Services', {
|
||||
group: LISTS,
|
||||
value: 6,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
servicesSource: choiceControl<ServicesSource>('Services source', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'`span-metrics` turns on the feature flag that swaps the services card for the span-metrics one.',
|
||||
options: SERVICES_SOURCES,
|
||||
value: 'traces',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get('http://localhost/api/v2/metrics/onboarding', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(metricsOnboardingResponse(values.metricsIngestion)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
|
||||
const signal = signalOf((await req.json()) as QueryRangeV5Body);
|
||||
|
||||
const isActive =
|
||||
signal === 'traces' ? values.tracesIngestion : values.logsIngestion;
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(queryRangeV5ScalarResponse(isActive ? INGESTED_COUNT : 0)),
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: homeUserPreferences({
|
||||
checklistDismissed: values.welcomeChecklist === 'dismissed',
|
||||
skippedSteps: values.skippedSteps,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => recentDashboardsResponse(values.dashboards)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) => {
|
||||
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
|
||||
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
|
||||
|
||||
return savedViewsResponse(
|
||||
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
|
||||
signal,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildServices(values.services),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/service/top_level_operations',
|
||||
response.json(() => topLevelOperationsResponse(values.services)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v4/query_range',
|
||||
response.json(() => spanMetricsResponse()),
|
||||
),
|
||||
],
|
||||
config: ({ servicesSource }) => ({
|
||||
appContext: { featureFlags: homeFeatureFlags(servicesSource) },
|
||||
}),
|
||||
});
|
||||
53
frontend/src/pages/HomePage/stories/HomePage.stories.tsx
Normal file
53
frontend/src/pages/HomePage/stories/HomePage.stories.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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 { homeMocks } from './HomePage.stories.mocks';
|
||||
|
||||
import HomePage from '../HomePage';
|
||||
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' }),
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<HomeArgs>;
|
||||
|
||||
/**
|
||||
* Every widget carrying data: all three signals ingesting, alert rules across
|
||||
* severities, recent dashboards, saved views on each explorer tab and a
|
||||
* services table with failing services.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Fresh workspace: nothing ingested yet, so the welcome checklist takes over. */
|
||||
export const NoIngestion: Story = {
|
||||
args: {
|
||||
logsIngestion: false,
|
||||
tracesIngestion: false,
|
||||
metricsIngestion: false,
|
||||
alertRules: 0,
|
||||
dashboards: 0,
|
||||
savedViews: 0,
|
||||
services: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Telemetry reads only: no permission to manage anything, so the create actions
|
||||
* and the legacy editor role are both gone.
|
||||
*/
|
||||
export const ViewerAccess: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** Widgets stuck in their loading state, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
316
frontend/src/pages/HomePage/stories/__story_mockdata__/home.ts
generated
Normal file
316
frontend/src/pages/HomePage/stories/__story_mockdata__/home.ts
generated
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
|
||||
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import type { ServicesList } from 'types/api/metrics/getService';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
import { baseUserPreferences } from '@/storybook/msw/__story_mockdata__/appShell';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
export { queryRangeV5ScalarResponse };
|
||||
|
||||
/** Shapes follow the fields the components read, not the full generated DTOs. */
|
||||
|
||||
export const metricsOnboardingResponse = (
|
||||
hasMetrics: boolean,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: { hasMetrics },
|
||||
});
|
||||
|
||||
export const HOME_CHECKLIST_STEPS = [
|
||||
'SEND_LOGS',
|
||||
'SEND_TRACES',
|
||||
'SEND_METRICS',
|
||||
'SETUP_ALERTS',
|
||||
'SETUP_SAVED_VIEWS',
|
||||
'SETUP_DASHBOARDS',
|
||||
] as const;
|
||||
|
||||
export type HomeChecklistStep = (typeof HOME_CHECKLIST_STEPS)[number];
|
||||
|
||||
const skippedPreference = (name: string): UserPreference => ({
|
||||
name,
|
||||
description: 'Welcome checklist step skipped',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The welcome checklist reads its dismissed and skipped flags off the user
|
||||
* preferences list, one entry per step.
|
||||
*/
|
||||
export const homeUserPreferences = ({
|
||||
checklistDismissed,
|
||||
skippedSteps,
|
||||
}: {
|
||||
checklistDismissed: boolean;
|
||||
skippedSteps: readonly HomeChecklistStep[];
|
||||
}): UserPreference[] => [
|
||||
...baseUserPreferences,
|
||||
...(checklistDismissed
|
||||
? [skippedPreference(ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER)]
|
||||
: []),
|
||||
...skippedSteps.map((step) =>
|
||||
skippedPreference(checkListStepToPreferenceKeyMap[step]),
|
||||
),
|
||||
];
|
||||
|
||||
const DASHBOARDS = [
|
||||
{
|
||||
name: 'Kubernetes cluster health',
|
||||
tags: [{ key: 'team', value: 'platform' }, { key: 'k8s' }],
|
||||
},
|
||||
{ name: 'API latency overview', tags: [{ key: 'sre' }] },
|
||||
{
|
||||
name: 'Checkout funnel',
|
||||
tags: [{ key: 'team', value: 'payments' }, { key: 'business' }],
|
||||
},
|
||||
{ name: 'Postgres slow queries', tags: [{ key: 'database' }] },
|
||||
{
|
||||
name: 'Kafka consumer lag',
|
||||
tags: [{ key: 'team', value: 'data' }, { key: 'streaming' }],
|
||||
},
|
||||
{ name: 'Ingress error budget', tags: [{ key: 'sre' }, { key: 'slo' }] },
|
||||
{ name: 'Cost per service', tags: [{ key: 'finops' }] },
|
||||
{
|
||||
name: 'Redis cache hit rate',
|
||||
tags: [{ key: 'database' }, { key: 'cache' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const recentDashboardsResponse = (
|
||||
count: number,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
dashboards: DASHBOARDS.slice(0, count).map((dashboard, index) => ({
|
||||
id: `storybook-dashboard-${index + 1}`,
|
||||
name: dashboard.name,
|
||||
spec: { display: { name: dashboard.name } },
|
||||
tags: dashboard.tags,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const ALERT_NAMES = [
|
||||
'Checkout p99 above 2s',
|
||||
'Payment failure rate',
|
||||
'Log volume spike',
|
||||
'Kafka consumer lag',
|
||||
'Pod restart storm',
|
||||
'Disk usage above 85%',
|
||||
'Frontend error rate',
|
||||
'Postgres connections saturated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cycles the jest fixtures so the list keeps their severity and firing spread.
|
||||
* `updatedAt` descends because that is the order the page sorts on.
|
||||
*/
|
||||
export const buildAlertRules = (count: number): RuletypesRuleDTO[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
...alertRulesFixture[index % alertRulesFixture.length],
|
||||
id: `storybook-rule-${index + 1}`,
|
||||
alert: ALERT_NAMES[index % ALERT_NAMES.length],
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 20 - index, 9)).toISOString(),
|
||||
}));
|
||||
|
||||
export const SAVED_VIEW_SIGNALS = ['logs', 'traces', 'metrics'] as const;
|
||||
|
||||
export type SavedViewSignal = (typeof SAVED_VIEW_SIGNALS)[number];
|
||||
|
||||
const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
|
||||
logs: [
|
||||
'Checkout errors',
|
||||
'Auth service warnings',
|
||||
'Slow SQL statements',
|
||||
'Payment webhooks',
|
||||
'Rate limited requests',
|
||||
'Cron job failures',
|
||||
],
|
||||
traces: [
|
||||
'Slowest checkout spans',
|
||||
'Failed payment traces',
|
||||
'Cart to order funnel',
|
||||
'External API calls',
|
||||
'Cold start requests',
|
||||
'Retried gRPC calls',
|
||||
],
|
||||
metrics: [
|
||||
'Pod memory by namespace',
|
||||
'Queue depth by topic',
|
||||
'HTTP throughput',
|
||||
'Container CPU throttling',
|
||||
'JVM heap usage',
|
||||
'Cache hit ratio',
|
||||
],
|
||||
};
|
||||
|
||||
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
|
||||
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
|
||||
|
||||
export const savedViewsResponse = (
|
||||
count: number,
|
||||
sourcePage: SavedViewSignal,
|
||||
): Record<string, unknown> => {
|
||||
const names = VIEW_NAMES[sourcePage];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
|
||||
...explorerView.data[0],
|
||||
id: `storybook-${sourcePage}-view-${index + 1}`,
|
||||
name: names[index],
|
||||
sourcePage,
|
||||
tags: [sourcePage],
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/** Ordered by p99 with errors at both ends, so any slice keeps the spread. */
|
||||
const SERVICES: ServicesList[] = [
|
||||
{
|
||||
serviceName: 'payments',
|
||||
p99: 2_940_100_000,
|
||||
avgDuration: 921_440_000,
|
||||
numCalls: 46_080,
|
||||
callRate: 25.6,
|
||||
numErrors: 5990,
|
||||
errorRate: 13,
|
||||
},
|
||||
{
|
||||
serviceName: 'checkout',
|
||||
p99: 1_248_900_000,
|
||||
avgDuration: 486_310_000,
|
||||
numCalls: 92_160,
|
||||
callRate: 51.2,
|
||||
numErrors: 4608,
|
||||
errorRate: 5,
|
||||
},
|
||||
{
|
||||
serviceName: 'frontend',
|
||||
p99: 812_450_000,
|
||||
avgDuration: 274_120_000,
|
||||
numCalls: 184_320,
|
||||
callRate: 102.4,
|
||||
numErrors: 1843,
|
||||
errorRate: 1,
|
||||
},
|
||||
{
|
||||
serviceName: 'shipping',
|
||||
p99: 486_200_000,
|
||||
avgDuration: 192_800_000,
|
||||
numCalls: 23_040,
|
||||
callRate: 12.8,
|
||||
numErrors: 691,
|
||||
errorRate: 3,
|
||||
},
|
||||
{
|
||||
serviceName: 'cart',
|
||||
p99: 214_800_000,
|
||||
avgDuration: 88_640_000,
|
||||
numCalls: 138_240,
|
||||
callRate: 76.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
{
|
||||
serviceName: 'catalogue',
|
||||
p99: 96_300_000,
|
||||
avgDuration: 41_220_000,
|
||||
numCalls: 276_480,
|
||||
callRate: 153.6,
|
||||
numErrors: 276,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'recommendations',
|
||||
p99: 64_100_000,
|
||||
avgDuration: 28_400_000,
|
||||
numCalls: 61_440,
|
||||
callRate: 34.1,
|
||||
numErrors: 61,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'notifications',
|
||||
p99: 38_700_000,
|
||||
avgDuration: 15_900_000,
|
||||
numCalls: 12_288,
|
||||
callRate: 6.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const buildServices = (count: number): ServicesList[] =>
|
||||
SERVICES.slice(0, count);
|
||||
|
||||
export const SERVICES_SOURCES = ['traces', 'span-metrics'] as const;
|
||||
|
||||
export type ServicesSource = (typeof SERVICES_SOURCES)[number];
|
||||
|
||||
/** `USE_SPAN_METRICS` swaps the services card for the span-metrics one. */
|
||||
export const homeFeatureFlags = (source: ServicesSource): FeatureFlagProps[] =>
|
||||
defaultFeatureFlags.map((flag) =>
|
||||
flag.name === FeatureKeys.USE_SPAN_METRICS
|
||||
? { ...flag, active: source === 'span-metrics' }
|
||||
: flag,
|
||||
);
|
||||
|
||||
/** The span-metrics card takes its row set from the top level operations. */
|
||||
export const topLevelOperationsResponse = (count: number): ServiceDataProps =>
|
||||
Object.fromEntries(
|
||||
buildServices(count).map((service) => [
|
||||
service.serviceName,
|
||||
['HTTP GET /', 'HTTP POST /checkout'],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Span-metrics latency, error rate and ops per second come off a table panel.
|
||||
* The page reads them from `newResult`, which the table branch of
|
||||
* `GetMetricQueryRange` never adds, so the columns render as zero however
|
||||
* complete this body is. That is the app's gap, not the mock's.
|
||||
*/
|
||||
export const spanMetricsResponse = (): {
|
||||
status: string;
|
||||
data: MetricRangePayloadV3['data'];
|
||||
} => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
resultType: 'table',
|
||||
result: [
|
||||
{
|
||||
queryName: '',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: {
|
||||
columns: [
|
||||
{ name: 'A', queryName: 'A', isValueColumn: true },
|
||||
{ name: 'D', queryName: 'D', isValueColumn: true },
|
||||
{ name: 'F1', queryName: 'F1', isValueColumn: true },
|
||||
],
|
||||
rows: [{ data: { A: 148_000_000, D: 12.4, F1: 1.8 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -74,6 +75,8 @@ function LogsExplorer(): JSX.Element {
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
@@ -232,6 +235,7 @@ function LogsExplorer(): JSX.Element {
|
||||
signal={SignalType.LOGS}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -128,6 +129,8 @@ function TracesExplorer(): JSX.Element {
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
@@ -267,6 +270,7 @@ function TracesExplorer(): JSX.Element {
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
|
||||
323
frontend/src/storybook/README.md
Normal file
323
frontend/src/storybook/README.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# 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 the app's provider tree in `src/app/` |
|
||||
| `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`, the global decorator |
|
||||
|
||||
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 the app's own provider tree.
|
||||
That tree is not a copy: `src/index.tsx` and `src/AppRoutes/index.tsx` mount the
|
||||
same three components, and each leaves the pieces a runner has to choose as
|
||||
props.
|
||||
|
||||
| Component | What it holds | What the runner chooses |
|
||||
| ----------------------- | --------------------------------------------------------------------- | ------------------------------------- |
|
||||
| `app/AppProviders` | Helmet, nuqs, theme, timezone, react-query, redux, `AppContext` | store, query client, nuqs adapter, `AppContext` |
|
||||
| `app/AppShell` | antd config, router, cmd-K, notifications, the error modal | router, the overlays beside the routes |
|
||||
| `app/AppPageProviders` | resource attributes, query builder, hotkeys, the layout, preferences | the layout, a fixed query-builder context |
|
||||
|
||||
What stays with the app and never reaches a story: Sentry, posthog, `AppProvider`
|
||||
(Storybook has no user, license or flags to fetch), `PrivateRoute` and the route
|
||||
switch.
|
||||
|
||||
`tests/test-utils` mounts its own, smaller tree for jest and does not go through
|
||||
them: 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. Ask for it once
|
||||
on the meta so every story of that page inherits it:
|
||||
|
||||
```tsx
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' }),
|
||||
} satisfies Meta<typeof HomePage>;
|
||||
```
|
||||
|
||||
`layout: 'app'` mounts the story where the router mounts a page, inside
|
||||
`AppPageProviders`, rather than in a decorator below the providers.
|
||||
|
||||
## 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/stories/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/stories/HomePage.stories.tsx
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' }),
|
||||
} 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, revoked)` 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.
|
||||
- **Revoked**: taken back off the preset and the grants above, from the same
|
||||
catalogue. This is how a story is a complete role missing one permission,
|
||||
which is what a page's `stories/authz/` file turns: `revoked:
|
||||
['read:subscription']` reads as the page's `Default` minus that one check,
|
||||
and stays that as the catalogue grows.
|
||||
- **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. Keep every story file under `src/pages/<Page>/stories/`: the story, the
|
||||
page's mocks in `<Page>.stories.mocks.ts`, and its payload builders under
|
||||
`stories/__story_mockdata__/`. Spread `storyMocks(<page>Mocks, { route })`
|
||||
into the meta.
|
||||
3. Pass `layout: 'app'` in the same config, so the page renders inside
|
||||
`AppLayout` where a route puts it.
|
||||
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.
|
||||
161
frontend/src/storybook/access/access.ts
Normal file
161
frontend/src/storybook/access/access.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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, less what is
|
||||
* revoked on top, 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.
|
||||
* Revoking is what reaches "everything but this one", which is the shape of a
|
||||
* page's per-permission stories: listing a whole preset back out by hand drifts
|
||||
* from it the moment the catalogue grows.
|
||||
*/
|
||||
export const accessFor = (
|
||||
preset: AccessPreset,
|
||||
extraPermissions: readonly string[] = [],
|
||||
revokedPermissions: readonly string[] = [],
|
||||
): AccessGrant => {
|
||||
const permissions = new Set([
|
||||
...PRESET_PERMISSIONS[preset],
|
||||
...extraPermissions,
|
||||
]);
|
||||
|
||||
revokedPermissions.forEach((permission) => permissions.delete(permission));
|
||||
|
||||
return {
|
||||
permissions,
|
||||
allows: (transaction): boolean =>
|
||||
permissions.has(
|
||||
formatPermission(gettableTransactionToPermission(transaction)),
|
||||
) ||
|
||||
permissions.has(
|
||||
`${transaction.relation}:${transaction.object.resource.kind}`,
|
||||
),
|
||||
legacyRole: deriveLegacyRole(permissions),
|
||||
};
|
||||
};
|
||||
34
frontend/src/storybook/controls/composeStoryMocks.ts
Normal file
34
frontend/src/storybook/controls/composeStoryMocks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AnyStoryMocks, ControlDescriptor, StoryMockArgs } from './types';
|
||||
|
||||
type UnionToIntersection<TUnion> = (
|
||||
TUnion extends unknown ? (arg: TUnion) => void : never
|
||||
) extends (arg: infer TIntersection) => void
|
||||
? TIntersection
|
||||
: never;
|
||||
|
||||
/** The args of every mock module in a list, as one object type. */
|
||||
export type ComposedMockArgs<TMocks extends readonly AnyStoryMocks[]> =
|
||||
UnionToIntersection<StoryMockArgs<TMocks[number]>>;
|
||||
|
||||
export interface ComposedStoryMocks<TMocks extends readonly AnyStoryMocks[]> {
|
||||
/** In resolution order: the first module to answer a question wins. */
|
||||
members: TMocks;
|
||||
args: ComposedMockArgs<TMocks>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One registration point for a set of mock modules: the panel rows they publish
|
||||
* and the args type a story is checked against are both derived from the list,
|
||||
* so adding a module is a single edit.
|
||||
*/
|
||||
export const composeStoryMocks = <TMocks extends readonly AnyStoryMocks[]>(
|
||||
...members: TMocks
|
||||
): ComposedStoryMocks<TMocks> => ({
|
||||
members,
|
||||
args: Object.assign(
|
||||
{},
|
||||
...members.map((mocks) => mocks.args),
|
||||
) as ComposedMockArgs<TMocks>,
|
||||
argTypes: Object.assign({}, ...members.map((mocks) => mocks.argTypes)),
|
||||
});
|
||||
73
frontend/src/storybook/controls/controls.ts
Normal file
73
frontend/src/storybook/controls/controls.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ControlDescriptor, MockControl } from './types';
|
||||
|
||||
interface ControlOptions {
|
||||
/** Controls-panel group, so a page's knobs stay together. */
|
||||
group: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const describe = (
|
||||
name: string,
|
||||
{ group, description }: ControlOptions,
|
||||
value: unknown,
|
||||
): ControlDescriptor => ({
|
||||
name,
|
||||
description,
|
||||
table: {
|
||||
category: group,
|
||||
defaultValue: { summary: JSON.stringify(value) },
|
||||
},
|
||||
});
|
||||
|
||||
export const toggleControl = (
|
||||
name: string,
|
||||
options: ControlOptions & { value: boolean },
|
||||
): MockControl<boolean> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Item count for a list. `max` should go past what the page renders so a story
|
||||
* can show the cap being hit.
|
||||
*/
|
||||
export const countControl = (
|
||||
name: string,
|
||||
options: ControlOptions & { value: number; max: number },
|
||||
): MockControl<number> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'range', min: 0, max: options.max, step: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
export const choiceControl = <TOption extends string>(
|
||||
name: string,
|
||||
options: ControlOptions & { value: TOption; options: readonly TOption[] },
|
||||
): MockControl<TOption> => ({
|
||||
defaultValue: options.value,
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'select' },
|
||||
options: [...options.options],
|
||||
},
|
||||
});
|
||||
|
||||
export const multiChoiceControl = <TOption extends string>(
|
||||
name: string,
|
||||
options: ControlOptions & {
|
||||
value: readonly TOption[];
|
||||
options: readonly TOption[];
|
||||
},
|
||||
): MockControl<TOption[]> => ({
|
||||
defaultValue: [...options.value],
|
||||
argType: {
|
||||
...describe(name, options, options.value),
|
||||
control: { type: 'check' },
|
||||
options: [...options.options],
|
||||
},
|
||||
});
|
||||
59
frontend/src/storybook/controls/defineStoryMocks.ts
Normal file
59
frontend/src/storybook/controls/defineStoryMocks.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ControlDescriptor,
|
||||
MockControlMap,
|
||||
MockControlValues,
|
||||
StoryMocks,
|
||||
StoryMocksDefinition,
|
||||
} from './types';
|
||||
import type { SignozStoryConfig, StoryOwnedConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Turns a page's control declarations into the `args` / `argTypes` its meta
|
||||
* exposes and the reader the runtime uses to fold the panel's current values
|
||||
* back into mock responses.
|
||||
*/
|
||||
export const defineStoryMocks = <TControls extends MockControlMap>(
|
||||
definition: StoryMocksDefinition<TControls>,
|
||||
): StoryMocks<TControls> => {
|
||||
const entries = Object.entries(definition.controls);
|
||||
|
||||
const args = Object.fromEntries(
|
||||
entries.map(([name, control]) => [name, control.defaultValue]),
|
||||
) as MockControlValues<TControls>;
|
||||
|
||||
const argTypes: Record<string, ControlDescriptor> = Object.fromEntries(
|
||||
entries.map(([name, control]) => [name, control.argType]),
|
||||
);
|
||||
|
||||
return {
|
||||
...definition,
|
||||
args,
|
||||
argTypes,
|
||||
read: (storyArgs): MockControlValues<TControls> =>
|
||||
Object.fromEntries(
|
||||
entries.map(([name, control]) => [
|
||||
name,
|
||||
storyArgs[name] ?? control.defaultValue,
|
||||
]),
|
||||
) as MockControlValues<TControls>,
|
||||
};
|
||||
};
|
||||
|
||||
interface StoryMocksMeta<TControls extends MockControlMap> {
|
||||
args: MockControlValues<TControls>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
parameters: { signoz: SignozStoryConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta fragment a page spreads to publish its controls:
|
||||
* `...storyMocks(homeMocks, { route: ROUTES.HOME })`.
|
||||
*/
|
||||
export const storyMocks = <TControls extends MockControlMap>(
|
||||
mocks: StoryMocks<TControls>,
|
||||
config?: StoryOwnedConfig,
|
||||
): StoryMocksMeta<TControls> => ({
|
||||
args: mocks.args,
|
||||
argTypes: mocks.argTypes,
|
||||
parameters: { signoz: { ...config, mocks } },
|
||||
});
|
||||
63
frontend/src/storybook/controls/types.ts
Normal file
63
frontend/src/storybook/controls/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ArgTypes } from '@storybook/react-vite';
|
||||
import type { RequestHandler } from 'msw';
|
||||
|
||||
import type { MockResponse } from '../msw/types';
|
||||
import type { ResponseState } from '../runtime/responseState';
|
||||
import type { StoryOwnedConfig, StoryRole } from '../types';
|
||||
|
||||
export type ControlDescriptor = ArgTypes[string];
|
||||
|
||||
/** One row of the Storybook controls panel plus the value it starts at. */
|
||||
export interface MockControl<TValue> {
|
||||
defaultValue: TValue;
|
||||
argType: ControlDescriptor;
|
||||
}
|
||||
|
||||
export type MockControlMap = Record<string, MockControl<unknown>>;
|
||||
|
||||
export type MockControlValues<TControls extends MockControlMap> = {
|
||||
[TName in keyof TControls]: TControls[TName] extends MockControl<infer TValue>
|
||||
? TValue
|
||||
: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a mock module contributes to the story it runs in. Only `controls` is
|
||||
* required; the rest are the ways a control can reach the page.
|
||||
*/
|
||||
export interface StoryMocksDefinition<TControls extends MockControlMap> {
|
||||
controls: TControls;
|
||||
handlers?(
|
||||
values: MockControlValues<TControls>,
|
||||
response: MockResponse,
|
||||
): RequestHandler[];
|
||||
/** Provider-level knobs no endpoint covers. */
|
||||
config?(values: MockControlValues<TControls>): Partial<StoryOwnedConfig>;
|
||||
/** Seeds module-level app state no provider exposes, e.g. no-auth mode. */
|
||||
effect?(values: MockControlValues<TControls>): void;
|
||||
/**
|
||||
* How the endpoints declared through `response` answer. The first module that
|
||||
* answers decides, page modules ahead of the global ones.
|
||||
*/
|
||||
responseState?(values: MockControlValues<TControls>): ResponseState;
|
||||
/**
|
||||
* The legacy role the story runs as. Derived, never set by a story. See
|
||||
* `authzMocks`, which derives it from the permissions it grants.
|
||||
*/
|
||||
role?(values: MockControlValues<TControls>): StoryRole;
|
||||
}
|
||||
|
||||
export interface StoryMocks<
|
||||
TControls extends MockControlMap,
|
||||
> extends StoryMocksDefinition<TControls> {
|
||||
args: MockControlValues<TControls>;
|
||||
argTypes: Record<string, ControlDescriptor>;
|
||||
read(args: Record<string, unknown>): MockControlValues<TControls>;
|
||||
}
|
||||
|
||||
export type AnyStoryMocks = StoryMocks<MockControlMap>;
|
||||
|
||||
export type StoryMockArgs<TMocks extends AnyStoryMocks> =
|
||||
TMocks extends StoryMocks<infer TControls>
|
||||
? MockControlValues<TControls>
|
||||
: never;
|
||||
23
frontend/src/storybook/decorators/withProviders.tsx
Normal file
23
frontend/src/storybook/decorators/withProviders.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Decorator } from '@storybook/react-vite';
|
||||
|
||||
import StorybookProviders from '../providers/StorybookProviders';
|
||||
import {
|
||||
resolveStory,
|
||||
type StoryRuntimeContext,
|
||||
} from '../runtime/resolveStory';
|
||||
|
||||
/**
|
||||
* Global decorator: every story renders inside the mocked provider tree the
|
||||
* story runtime resolved. Everything the tree needs in place first (handlers,
|
||||
* module-level state, theme) is applied by the preview loader, which runs
|
||||
* ahead of this.
|
||||
*/
|
||||
export const withProviders: Decorator = (Story, context) => {
|
||||
const world = resolveStory(context as unknown as StoryRuntimeContext);
|
||||
|
||||
return (
|
||||
<StorybookProviders key={world.key} {...world.config}>
|
||||
<Story />
|
||||
</StorybookProviders>
|
||||
);
|
||||
};
|
||||
267
frontend/src/storybook/globals/appShellMocks.ts
Normal file
267
frontend/src/storybook/globals/appShellMocks.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import APIError from 'types/api/error';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
type LicenseResModel,
|
||||
LicenseState,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { setNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import { choiceControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
import type { StoryMockArgs } from '../controls/types';
|
||||
import { RESPONSE_STATES, type ResponseState } from '../runtime/responseState';
|
||||
|
||||
const APP_SHELL = 'App shell';
|
||||
const DATA = 'Data';
|
||||
const LICENSE = 'License';
|
||||
|
||||
const DAY_IN_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const LICENSES = [
|
||||
'cloud',
|
||||
'enterprise',
|
||||
'community-enterprise',
|
||||
'community',
|
||||
] as const;
|
||||
|
||||
const BANNERS = [
|
||||
'none',
|
||||
'trial-expiry',
|
||||
'payment-failed',
|
||||
'license-expired',
|
||||
'license-terminated',
|
||||
'no-auth',
|
||||
] as const;
|
||||
|
||||
type License = (typeof LICENSES)[number];
|
||||
|
||||
type Banner = (typeof BANNERS)[number];
|
||||
|
||||
const SIDENAV_STATES = ['pinned', 'collapsed'] as const;
|
||||
|
||||
type SidenavState = (typeof SIDENAV_STATES)[number];
|
||||
|
||||
const {
|
||||
activeLicense: baseLicense,
|
||||
trialInfo: baseTrialInfo,
|
||||
featureFlags: baseFeatureFlags,
|
||||
versionData: baseVersionData,
|
||||
} = createAppContextMock(USER_ROLES.ADMIN);
|
||||
|
||||
/**
|
||||
* The status code `/licenses/active` failed with is itself the signal
|
||||
* `useGetTenantLicense` reads: 404 is the enterprise build running unlicensed,
|
||||
* 501 the community build, where the endpoint does not exist at all.
|
||||
*/
|
||||
const licenseFetchError = (httpStatusCode: StatusCodes): APIError =>
|
||||
new APIError({
|
||||
httpStatusCode,
|
||||
error: {
|
||||
code: 'license_unavailable',
|
||||
message: 'storybook: no active license',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const feature = (name: FeatureKeys, active: boolean): FeatureFlagProps => ({
|
||||
name,
|
||||
active,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* What the backend serves an unlicensed enterprise build — the same keys as the
|
||||
* enterprise plan, all inactive. Mirrors `BasicPlan` in
|
||||
* `pkg/types/licensetypes/plan.go`; the community build serves none at all.
|
||||
*/
|
||||
const BASIC_PLAN: FeatureFlagProps[] = [
|
||||
FeatureKeys.SSO,
|
||||
FeatureKeys.GATEWAY,
|
||||
FeatureKeys.PREMIUM_SUPPORT,
|
||||
FeatureKeys.ANOMALY_DETECTION,
|
||||
].map((name) => feature(name, false));
|
||||
|
||||
/**
|
||||
* Which of the four deployments `useGetTenantLicense` distinguishes the story
|
||||
* runs on. The license drives the plan the app believes it is on, so the feature
|
||||
* flags and the enterprise/community build marker follow it.
|
||||
*/
|
||||
const licenseContext = (license: License): Partial<IAppContext> => {
|
||||
switch (license) {
|
||||
case 'enterprise':
|
||||
return {
|
||||
activeLicense: baseLicense && {
|
||||
...baseLicense,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
},
|
||||
activeLicenseFetchError: null,
|
||||
};
|
||||
|
||||
case 'community-enterprise':
|
||||
return {
|
||||
activeLicense: null,
|
||||
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_FOUND),
|
||||
featureFlags: BASIC_PLAN,
|
||||
};
|
||||
|
||||
case 'community':
|
||||
return {
|
||||
activeLicense: null,
|
||||
activeLicenseFetchError: licenseFetchError(StatusCodes.NOT_IMPLEMENTED),
|
||||
featureFlags: [],
|
||||
versionData: baseVersionData && { ...baseVersionData, ee: 'N' },
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
activeLicense: baseLicense,
|
||||
activeLicenseFetchError: null,
|
||||
featureFlags: baseFeatureFlags,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A banner that reads the license needs one to read, so the community
|
||||
* deployments fall back to the licensed fixture rather than showing nothing.
|
||||
*/
|
||||
const licensedBanner = (
|
||||
activeLicense: LicenseResModel | null,
|
||||
extend: (license: LicenseResModel) => LicenseResModel,
|
||||
): Partial<IAppContext> => {
|
||||
const license = activeLicense ?? baseLicense;
|
||||
|
||||
return {
|
||||
activeLicense: license && extend(license),
|
||||
activeLicenseFetchError: null,
|
||||
};
|
||||
};
|
||||
|
||||
const bannerContext = (
|
||||
banner: Banner,
|
||||
activeLicense: LicenseResModel | null,
|
||||
): Partial<IAppContext> => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
switch (banner) {
|
||||
case 'trial-expiry':
|
||||
return {
|
||||
trialInfo: {
|
||||
...baseTrialInfo,
|
||||
onTrial: true,
|
||||
trialStart: nowInSeconds - 27 * DAY_IN_SECONDS,
|
||||
trialEnd: nowInSeconds + 3 * DAY_IN_SECONDS,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
};
|
||||
|
||||
case 'payment-failed':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
eventQueue: {
|
||||
...license.eventQueue,
|
||||
event: LicenseEvent.DEFAULT,
|
||||
scheduledAt: 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>;
|
||||
91
frontend/src/storybook/globals/authzMocks.ts
Normal file
91
frontend/src/storybook/globals/authzMocks.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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: [],
|
||||
}),
|
||||
revoked: multiChoiceControl('Revoked', {
|
||||
group: ACCESS,
|
||||
description:
|
||||
"Taken back off the preset and the grants above, so a story can be an otherwise complete role missing one permission. This is what a page's per-permission stories turn.",
|
||||
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, revoked, authzState }) => {
|
||||
const granted = accessFor(access, permissions, revoked);
|
||||
|
||||
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, revoked }) =>
|
||||
accessFor(access, permissions, revoked).legacyRole,
|
||||
// Overrides persist in localStorage, so a leftover one from a real dev session
|
||||
// would silently answer for the controls panel.
|
||||
effect: ({ access }) => {
|
||||
if (access !== 'dev-tools') {
|
||||
clearAllAuthZDevOverrides();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export type AuthzArgs = StoryMockArgs<typeof authzMocks>;
|
||||
12
frontend/src/storybook/globals/index.ts
Normal file
12
frontend/src/storybook/globals/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { composeStoryMocks } from '../controls/composeStoryMocks';
|
||||
import { appShellMocks } from './appShellMocks';
|
||||
import { authzMocks } from './authzMocks';
|
||||
|
||||
/**
|
||||
* The mock modules every story carries, page or component, declared at project
|
||||
* level in `.storybook/preview.tsx`. Adding one here publishes its controls and
|
||||
* widens `PageStoryArgs` in the same edit.
|
||||
*/
|
||||
export const globalMocks = composeStoryMocks(authzMocks, appShellMocks);
|
||||
|
||||
export type GlobalMockArgs = typeof globalMocks.args;
|
||||
8
frontend/src/storybook/mocks/createStoryAppContext.ts
Normal file
8
frontend/src/storybook/mocks/createStoryAppContext.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { fn } from 'storybook/test';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
|
||||
export const createStoryAppContext = (
|
||||
role: string,
|
||||
overrides?: Partial<IAppContext>,
|
||||
): IAppContext => createAppContextMock(role, overrides, () => fn());
|
||||
20
frontend/src/storybook/mocks/env.mock.ts
Normal file
20
frontend/src/storybook/mocks/env.mock.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Replaces `constants/env` in Storybook (aliased in `.storybook/main.ts`).
|
||||
*
|
||||
* The base URL must stay `http://localhost` so the msw handlers shared with
|
||||
* jest (`src/mocks-server/handlers.ts`), which are declared against that
|
||||
* origin, match requests issued from the Storybook iframe. msw intercepts
|
||||
* before the request leaves the page, so the cross-origin URL never hits the
|
||||
* network and CORS never applies.
|
||||
*
|
||||
* The annotation checks the module's shape against the real one, so a value
|
||||
* added to `constants/env` fails to compile here rather than at render.
|
||||
*/
|
||||
const libEnv: typeof import('constants/env') = {
|
||||
ENVIRONMENT: {
|
||||
baseURL: 'http://localhost',
|
||||
wsURL: 'ws://localhost',
|
||||
},
|
||||
};
|
||||
|
||||
export const { ENVIRONMENT } = libEnv;
|
||||
20
frontend/src/storybook/mocks/logEvent.mock.ts
Normal file
20
frontend/src/storybook/mocks/logEvent.mock.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
/**
|
||||
* Replaces `api/common/logEvent` in Storybook (aliased in `.storybook/main.ts`)
|
||||
* so analytics never leave the iframe. Stories can assert on the calls:
|
||||
* `import logEvent from 'api/common/logEvent'` then `expect(logEvent)...`.
|
||||
*
|
||||
* The annotation checks the module's shape against the real one, so a change to
|
||||
* `logEvent`'s signature fails to compile here rather than at render.
|
||||
*/
|
||||
const libLogEvent: typeof import('api/common/logEvent') = {
|
||||
default: fn(async () => ({
|
||||
statusCode: 200 as const,
|
||||
error: null,
|
||||
message: 'success',
|
||||
payload: { status: 'success', data: '' },
|
||||
})).mockName('logEvent'),
|
||||
};
|
||||
|
||||
export default libLogEvent.default;
|
||||
110
frontend/src/storybook/msw/__story_mockdata__/appShell.ts
generated
Normal file
110
frontend/src/storybook/msw/__story_mockdata__/appShell.ts
generated
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
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.0.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
};
|
||||
|
||||
export const latestGithubReleaseResponse = {
|
||||
tag_name: 'v0.0.0',
|
||||
name: 'v0.0.0',
|
||||
html_url: 'https://github.com/SigNoz/signoz/releases/tag/v0.0.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.0.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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
73
frontend/src/storybook/msw/__story_mockdata__/queryRange.ts
generated
Normal file
73
frontend/src/storybook/msw/__story_mockdata__/queryRange.ts
generated
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
/** Typed builders for the query_range v5 response shapes. */
|
||||
|
||||
export const queryRangeV5ScalarResponse = (
|
||||
value: number,
|
||||
queryName = 'A',
|
||||
): MetricRangePayloadV5 => ({
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: '__result_0',
|
||||
queryName,
|
||||
aggregationIndex: 0,
|
||||
columnType: 'aggregation',
|
||||
},
|
||||
],
|
||||
data: [[value]],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
},
|
||||
});
|
||||
|
||||
export const queryRangeV5EmptyResponse = (
|
||||
queryName = 'A',
|
||||
): MetricRangePayloadV5 => ({
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [{ queryName, nextCursor: '', rows: [] }],
|
||||
},
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
},
|
||||
});
|
||||
|
||||
export const queryRangeV5RawResponse = <T>(
|
||||
rows: Array<{ timestamp: string; data: T }>,
|
||||
options: { queryName?: string; hasMore?: boolean } = {},
|
||||
): MetricRangePayloadV5 => {
|
||||
const { queryName = 'A', hasMore = false } = options;
|
||||
|
||||
return {
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName,
|
||||
nextCursor: hasMore ? 'next-cursor-token' : '',
|
||||
rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rowsScanned: rows.length,
|
||||
bytesScanned: 0,
|
||||
durationMs: 0,
|
||||
stepIntervals: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
48
frontend/src/storybook/msw/appShellHandlers.ts
Normal file
48
frontend/src/storybook/msw/appShellHandlers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
changelogResponse,
|
||||
globalConfigResponse,
|
||||
latestGithubReleaseResponse,
|
||||
userPreferencesResponse,
|
||||
versionResponse,
|
||||
zeusHostsResponse,
|
||||
} from './__story_mockdata__/appShell';
|
||||
|
||||
/**
|
||||
* Endpoints the app shell hits on every route that the jest handlers in
|
||||
* `src/mocks-server/handlers.ts` either do not cover or answer with fixtures
|
||||
* too thin to show the shell doing its job. Resolved ahead of the shared set,
|
||||
* and a page's own control-driven handlers are resolved ahead of these.
|
||||
*/
|
||||
export const appShellHandlers = [
|
||||
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(userPreferencesResponse())),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/user/preferences/:name', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/zeus/hosts', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(zeusHostsResponse)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/global/config', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(globalConfigResponse)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/version', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(versionResponse)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'https://api.github.com/repos/signoz/signoz/releases/latest',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(latestGithubReleaseResponse)),
|
||||
),
|
||||
|
||||
rest.get('https://cms.signoz.cloud/api/release-changelogs', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(changelogResponse)),
|
||||
),
|
||||
];
|
||||
36
frontend/src/storybook/msw/handlers.ts
Normal file
36
frontend/src/storybook/msw/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { rest } from 'msw';
|
||||
import { handlers as sharedHandlers } from 'mocks-server/handlers';
|
||||
|
||||
import { appShellHandlers } from './appShellHandlers';
|
||||
|
||||
/**
|
||||
* Last resort: every axios instance is built on `ENVIRONMENT.baseURL`, which
|
||||
* `mocks/env.mock.ts` pins to `http://localhost`, so an endpoint nobody mocked
|
||||
* lands here instead of leaving the browser. Failing loudly beats a request
|
||||
* that hangs until the connection is refused.
|
||||
*/
|
||||
const unmockedApiGuard = [
|
||||
rest.all('http://localhost/api/*', (req, res, ctx) => {
|
||||
console.error(
|
||||
`[storybook] no msw handler for ${req.method} ${req.url.pathname}. Add one to the page's mocks or to src/storybook/msw/appShellHandlers.ts`,
|
||||
);
|
||||
|
||||
return res(
|
||||
ctx.status(501),
|
||||
ctx.json({ status: 'error', error: 'not mocked in Storybook' }),
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* Default handler set for every story, resolved first match wins: the
|
||||
* Storybook-only shell handlers override the jest ones where the shell needs
|
||||
* richer data, and both a page's control-driven handlers and a story's own
|
||||
* `parameters.msw.handlers` are layered on top at render time. An endpoint both
|
||||
* runners need belongs in `src/mocks-server/handlers.ts` instead.
|
||||
*/
|
||||
export const storybookHandlers = [
|
||||
...appShellHandlers,
|
||||
...sharedHandlers,
|
||||
...unmockedApiGuard,
|
||||
];
|
||||
35
frontend/src/storybook/msw/storyHandlers.ts
Normal file
35
frontend/src/storybook/msw/storyHandlers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { RequestHandler } from 'msw';
|
||||
|
||||
/**
|
||||
* Handlers may be grouped under names, so unrelated overrides in the same story
|
||||
* stay readable.
|
||||
*/
|
||||
export type StoryMswParameter =
|
||||
| RequestHandler[]
|
||||
| {
|
||||
handlers?: RequestHandler[] | Record<string, RequestHandler[] | undefined>;
|
||||
};
|
||||
|
||||
export const collectStoryHandlers = (
|
||||
msw: StoryMswParameter | undefined,
|
||||
): RequestHandler[] => {
|
||||
if (!msw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(msw)) {
|
||||
return msw;
|
||||
}
|
||||
|
||||
const { handlers } = msw;
|
||||
|
||||
if (!handlers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(handlers)
|
||||
? handlers
|
||||
: Object.values(handlers)
|
||||
.filter((group): group is RequestHandler[] => Boolean(group))
|
||||
.flat();
|
||||
};
|
||||
28
frontend/src/storybook/msw/types.ts
Normal file
28
frontend/src/storybook/msw/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
DefaultBodyType,
|
||||
PathParams,
|
||||
ResponseResolver,
|
||||
RestContext,
|
||||
RestRequest,
|
||||
} from 'msw';
|
||||
|
||||
export type MockRequest = RestRequest<DefaultBodyType, PathParams>;
|
||||
|
||||
export type MockResolver = ResponseResolver<
|
||||
MockRequest,
|
||||
RestContext,
|
||||
DefaultBodyType
|
||||
>;
|
||||
|
||||
/**
|
||||
* Resolver factory handed to a mock module's `handlers`. Endpoints declared
|
||||
* through it follow the response state, so one declaration covers the loaded,
|
||||
* loading and failed states. Endpoints that have to answer for the page to
|
||||
* render at all (ingestion detection, preferences) take a plain resolver
|
||||
* instead.
|
||||
*/
|
||||
export interface MockResponse {
|
||||
json: <TBody>(
|
||||
build: (req: MockRequest) => TBody | Promise<TBody>,
|
||||
) => MockResolver;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
right: var(--spacing-8);
|
||||
bottom: var(--spacing-8);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
max-width: 420px;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
border: 1px solid var(--accent-amber);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--base-black) 45%, transparent);
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
max-height: 160px;
|
||||
padding-left: var(--spacing-8);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
margin-bottom: var(--spacing-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { useBlockedNavigationStore } from './blockedNavigationStore';
|
||||
|
||||
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 = useBlockedNavigationStore(
|
||||
(state) => state.blockedNavigations,
|
||||
);
|
||||
const clear = useBlockedNavigationStore((state) => state.clear);
|
||||
|
||||
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={clear}
|
||||
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;
|
||||
35
frontend/src/storybook/navigation/blockedNavigationStore.ts
Normal file
35
frontend/src/storybook/navigation/blockedNavigationStore.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface BlockedNavigationStore {
|
||||
blockedNavigations: BlockedNavigation[];
|
||||
record: (via: string, to: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useBlockedNavigationStore = create<BlockedNavigationStore>()(
|
||||
(set) => ({
|
||||
blockedNavigations: [],
|
||||
record: (via, to): void =>
|
||||
set(({ blockedNavigations }) => ({
|
||||
blockedNavigations: [
|
||||
...blockedNavigations,
|
||||
{ id: blockedNavigations.length + 1, via, to },
|
||||
],
|
||||
})),
|
||||
clear: (): void => set({ blockedNavigations: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
export const recordBlockedNavigation = (via: string, to: string): void =>
|
||||
useBlockedNavigationStore.getState().record(via, to);
|
||||
|
||||
export const clearBlockedNavigations = (): void =>
|
||||
useBlockedNavigationStore.getState().clear();
|
||||
65
frontend/src/storybook/navigation/containment.ts
Normal file
65
frontend/src/storybook/navigation/containment.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
History,
|
||||
LocationDescriptor,
|
||||
LocationDescriptorObject,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
import { fn, type Mock } from 'storybook/test';
|
||||
|
||||
import { recordBlockedNavigation } from './blockedNavigationStore';
|
||||
import { navigateWithinPage, storyHistory, toHref } from './pageScope';
|
||||
|
||||
const guardedNavigate = (
|
||||
via: 'push' | 'replace',
|
||||
): Mock<(to: LocationDescriptor, state?: unknown) => void> =>
|
||||
fn((to: LocationDescriptor, state?: unknown): void => {
|
||||
const target: LocationDescriptorObject =
|
||||
typeof to === 'string' ? { ...parsePath(to), state } : { state, ...to };
|
||||
|
||||
if (navigateWithinPage(target, { replace: via === 'replace' })) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordBlockedNavigation(via, toHref(to));
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const blockedRelativeNavigate = (via: string): Mock<(delta?: number) => void> =>
|
||||
fn((delta?: number): void => {
|
||||
recordBlockedNavigation(via, delta === undefined ? via : `${via}(${delta})`);
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const overriddenMethods = {
|
||||
push: guardedNavigate('push'),
|
||||
replace: guardedNavigate('replace'),
|
||||
go: blockedRelativeNavigate('go'),
|
||||
goBack: blockedRelativeNavigate('goBack'),
|
||||
goForward: blockedRelativeNavigate('goForward'),
|
||||
} as const;
|
||||
|
||||
type OverriddenMethod = keyof typeof overriddenMethods;
|
||||
|
||||
const isOverriddenMethod = (prop: string | symbol): prop is OverriddenMethod =>
|
||||
typeof prop === 'string' && prop in overriddenMethods;
|
||||
|
||||
/**
|
||||
* What the app sees in place of `lib/history`. Reads (`location`, `action`,
|
||||
* `listen`) are proxied to the story's memory history so react-router renders
|
||||
* normally; navigation goes through `pageScope`, and whatever would leave the
|
||||
* page is swallowed and reported to `blockedNavigationStore`.
|
||||
* `react-router-dom-v5-compat` drives its `useNavigate` through this same
|
||||
* object, so `useSafeNavigate` is covered too.
|
||||
*/
|
||||
export const containedHistory: History = new Proxy(storyHistory, {
|
||||
get(target, prop, receiver) {
|
||||
if (isOverriddenMethod(prop)) {
|
||||
return overriddenMethods[prop];
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
export const hasInAppHistory = (): boolean => false;
|
||||
|
||||
export const resetStoryHistory = (): void => {
|
||||
Object.values(overriddenMethods).forEach((method) => method.mockClear());
|
||||
};
|
||||
20
frontend/src/storybook/navigation/history.alias.ts
Normal file
20
frontend/src/storybook/navigation/history.alias.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
containedHistory,
|
||||
hasInAppHistory as containedHasInAppHistory,
|
||||
} from './containment';
|
||||
|
||||
/**
|
||||
* Replaces `lib/history` in Storybook (aliased in `.storybook/main.ts`). The
|
||||
* containment rule lives in `containment.ts`; this file only has to keep the
|
||||
* module's shape, and the annotation is what checks it against the real one.
|
||||
* An export added to `lib/history` fails to compile here instead of failing at
|
||||
* render in whichever component imports it.
|
||||
*/
|
||||
const libHistory: typeof import('lib/history') = {
|
||||
default: containedHistory,
|
||||
hasInAppHistory: containedHasInAppHistory,
|
||||
};
|
||||
|
||||
export default libHistory.default;
|
||||
|
||||
export const { hasInAppHistory } = libHistory;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { recordBlockedNavigation } from './blockedNavigationStore';
|
||||
import {
|
||||
isBlockableHref,
|
||||
navigateWithinPage,
|
||||
toStoryLocation,
|
||||
} from './pageScope';
|
||||
|
||||
/**
|
||||
* Takes over the navigations that never reach the story's history: plain anchors
|
||||
* and `window.open` (used by `useSafeNavigate` for `newTab`). An anchor staying
|
||||
* on the story's page is applied, because letting the browser follow it would
|
||||
* navigate the iframe away and unmount the story. Anything else is reported as
|
||||
* blocked. Returns the teardown.
|
||||
*/
|
||||
export const interceptExternalNavigation = (): (() => void) => {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.('a[href]');
|
||||
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
|
||||
if (!isBlockableHref(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ahead of react-router's own `Link` handler, which skips a click that is
|
||||
// already handled, so an in-page link is never pushed twice.
|
||||
event.preventDefault();
|
||||
|
||||
const to = toStoryLocation(href, window.location.href);
|
||||
|
||||
if (to && navigateWithinPage(to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordBlockedNavigation('link', href);
|
||||
};
|
||||
|
||||
document.addEventListener('click', onClick, true);
|
||||
|
||||
const originalOpen = window.open;
|
||||
window.open = (url?: string | URL): null => {
|
||||
recordBlockedNavigation('window.open', String(url ?? ''));
|
||||
return null;
|
||||
};
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener('click', onClick, true);
|
||||
window.open = originalOpen;
|
||||
};
|
||||
};
|
||||
85
frontend/src/storybook/navigation/pageScope.ts
Normal file
85
frontend/src/storybook/navigation/pageScope.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
createMemoryHistory,
|
||||
LocationDescriptor,
|
||||
MemoryHistory,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
|
||||
/**
|
||||
* The story's own history. A story renders one page, so this never leaves it:
|
||||
* `pageScope` decides what counts as staying, and `containment.ts` is what the
|
||||
* app sees in place of `lib/history`.
|
||||
*/
|
||||
export const storyHistory: MemoryHistory = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
export const toHref = (to: LocationDescriptor): string =>
|
||||
typeof to === 'string' ? to : storyHistory.createHref(to);
|
||||
|
||||
/** `/home/` and `/home` are the same page as far as a story is concerned. */
|
||||
const normalizePathname = (pathname: string): string =>
|
||||
pathname.length > 1 && pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
|
||||
export const isSamePagePathname = (pathname: string | undefined): boolean =>
|
||||
!pathname ||
|
||||
normalizePathname(pathname) ===
|
||||
normalizePathname(storyHistory.location.pathname);
|
||||
|
||||
/**
|
||||
* Applies a navigation that stays on the story's page: a query-param or hash
|
||||
* change, which is how tabs, filters and pagination are driven. Returns false
|
||||
* when the target is another page, leaving the caller to report it as blocked.
|
||||
*/
|
||||
export const navigateWithinPage = (
|
||||
to: LocationDescriptor,
|
||||
{ replace = false }: { replace?: boolean } = {},
|
||||
): boolean => {
|
||||
const target = typeof to === 'string' ? parsePath(to) : to;
|
||||
|
||||
if (!isSamePagePathname(target.pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storyHistory[replace ? 'replace' : 'push']({
|
||||
...target,
|
||||
pathname: storyHistory.location.pathname,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Places the story at a route without going through the block. */
|
||||
export const setStoryLocation = (to: LocationDescriptor): void => {
|
||||
storyHistory.replace(to);
|
||||
};
|
||||
|
||||
/**
|
||||
* An in-page anchor or a `javascript:` href is the browser's business, not the
|
||||
* story's: it is left alone rather than applied or reported.
|
||||
*/
|
||||
export const isBlockableHref = (href: string): boolean =>
|
||||
href.length > 0 && !href.startsWith('#') && !href.startsWith('javascript:');
|
||||
|
||||
/**
|
||||
* An anchor href as a location the story's history understands, or `undefined`
|
||||
* when it leads off the page. Relative hrefs (`?tab=logs`) carry no pathname and
|
||||
* stay on the page; app links do, and are resolved against the iframe so an
|
||||
* off-site href fails the host check before its path is compared.
|
||||
*/
|
||||
export const toStoryLocation = (
|
||||
href: string,
|
||||
base: string,
|
||||
): string | undefined => {
|
||||
if (href.startsWith('?')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const url = new URL(href, base);
|
||||
|
||||
return url.host === new URL(base).host
|
||||
? `${url.pathname}${url.search}${url.hash}`
|
||||
: undefined;
|
||||
};
|
||||
108
frontend/src/storybook/providers/StorybookProviders.tsx
Normal file
108
frontend/src/storybook/providers/StorybookProviders.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ReactNode, useEffect, useMemo } from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import AppPageProviders from '@/app/AppPageProviders';
|
||||
import AppProviders from '@/app/AppProviders';
|
||||
import AppShell from '@/app/AppShell';
|
||||
import type { AppLayer } from '@/app/types';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
import history from 'lib/history';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext, 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;
|
||||
}
|
||||
|
||||
const storyRouter: AppLayer = (children) => (
|
||||
<Router history={history}>
|
||||
<CompatRouter>{children}</CompatRouter>
|
||||
</Router>
|
||||
);
|
||||
|
||||
const appLayout: AppLayer = (children) => <AppLayout>{children}</AppLayout>;
|
||||
|
||||
const bareLayout: AppLayer = (children) => (
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
);
|
||||
|
||||
function StorybookProviders({
|
||||
children,
|
||||
role,
|
||||
appContext,
|
||||
queryBuilder,
|
||||
route = '/',
|
||||
layout = 'none',
|
||||
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 (
|
||||
<AppProviders
|
||||
store={store}
|
||||
queryClient={queryClient}
|
||||
appContext={(scoped): ReactNode => (
|
||||
<AppContext.Provider value={appContextValue}>{scoped}</AppContext.Provider>
|
||||
)}
|
||||
searchParams={(scoped): ReactNode => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
{scoped}
|
||||
</NuqsTestingAdapter>
|
||||
)}
|
||||
>
|
||||
<AppShell
|
||||
router={storyRouter}
|
||||
overlays={
|
||||
<>
|
||||
<StoryContextProbe />
|
||||
<CmdKPalette userRole={role} />
|
||||
<NavigationBlockedOverlay />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AppPageProviders
|
||||
layout={layout === 'app' ? appLayout : bareLayout}
|
||||
queryBuilder={queryBuilder}
|
||||
>
|
||||
{children}
|
||||
</AppPageProviders>
|
||||
</AppShell>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
export default StorybookProviders;
|
||||
12
frontend/src/storybook/providers/applyThemeBodyClass.ts
Normal file
12
frontend/src/storybook/providers/applyThemeBodyClass.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
|
||||
import type { StoryTheme } from '../types';
|
||||
|
||||
export const applyThemeBodyClass = (theme: StoryTheme): void => {
|
||||
const isDarkMode = theme === THEME_MODE.DARK;
|
||||
|
||||
document.body.dataset.theme = 'default';
|
||||
document.body.classList.toggle('darkMode', isDarkMode);
|
||||
document.body.classList.toggle('dark', isDarkMode);
|
||||
document.body.classList.toggle('lightMode', !isDarkMode);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
|
||||
/**
|
||||
* One client per story: retries off so a deliberately failing handler renders
|
||||
* its error state immediately, and no cache carried over between stories.
|
||||
*/
|
||||
export const createStorybookQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
19
frontend/src/storybook/providers/createStorybookStore.ts
Normal file
19
frontend/src/storybook/providers/createStorybookStore.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
applyMiddleware,
|
||||
legacy_createStore as createStore,
|
||||
Store,
|
||||
} from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import reducers, { AppState } from 'store/reducers';
|
||||
|
||||
/**
|
||||
* A fresh store per story, seeded with the real reducers so dispatches keep
|
||||
* working, unlike the mock store used in jest. Nothing leaks between stories.
|
||||
*/
|
||||
export const createStorybookStore = (reduxState?: Partial<AppState>): Store =>
|
||||
createStore(
|
||||
reducers,
|
||||
reduxState as ReturnType<typeof reducers> | undefined,
|
||||
applyMiddleware(thunk),
|
||||
);
|
||||
14
frontend/src/storybook/providers/useStoryRoute.ts
Normal file
14
frontend/src/storybook/providers/useStoryRoute.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { setStoryLocation } from '../navigation/pageScope';
|
||||
|
||||
/**
|
||||
* Places the story's history at its route before the router mounts and hands
|
||||
* back the search params for the nuqs testing adapter.
|
||||
*/
|
||||
export const useStoryRoute = (route: string): URLSearchParams =>
|
||||
useMemo(() => {
|
||||
setStoryLocation(route);
|
||||
const [, search = ''] = route.split('?');
|
||||
return new URLSearchParams(search);
|
||||
}, [route]);
|
||||
173
frontend/src/storybook/runtime/resolveStory.ts
Normal file
173
frontend/src/storybook/runtime/resolveStory.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { RequestHandler, SetupWorker } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import type { AnyStoryMocks, StoryMockArgs } from '../controls/types';
|
||||
import { globalMocks, type GlobalMockArgs } from '../globals';
|
||||
import { storybookHandlers } from '../msw/handlers';
|
||||
import { collectStoryHandlers } from '../msw/storyHandlers';
|
||||
import type { MockResolver, MockResponse } from '../msw/types';
|
||||
import { applyThemeBodyClass } from '../providers/applyThemeBodyClass';
|
||||
import type {
|
||||
ResolvedStoryConfig,
|
||||
SignozStoryConfig,
|
||||
SignozStoryParameters,
|
||||
StoryOwnedConfig,
|
||||
StoryRole,
|
||||
StoryTheme,
|
||||
} from '../types';
|
||||
import { respondWith, type ResponseState } from './responseState';
|
||||
|
||||
/** Args of a page story: its own controls plus the ones every story carries. */
|
||||
export type PageStoryArgs<TMocks extends AnyStoryMocks> = GlobalMockArgs &
|
||||
StoryMockArgs<TMocks>;
|
||||
|
||||
/**
|
||||
* What Storybook hands both the loader and the decorator. Declared structurally,
|
||||
* so the runtime does not depend on which lifecycle hook is calling it.
|
||||
*/
|
||||
export interface StoryRuntimeContext {
|
||||
id: string;
|
||||
parameters: SignozStoryParameters;
|
||||
args: Record<string, unknown>;
|
||||
globals?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StoryWorld {
|
||||
config: ResolvedStoryConfig;
|
||||
theme: StoryTheme;
|
||||
/**
|
||||
* Changes with every control a mock reads. The provider tree is keyed on it so
|
||||
* the story remounts with a fresh query cache instead of showing what the
|
||||
* previous control values resolved to.
|
||||
*/
|
||||
key: string;
|
||||
/** In resolution order: msw answers with the first handler that matches. */
|
||||
handlers: RequestHandler[];
|
||||
install(worker: SetupWorker): void;
|
||||
/**
|
||||
* Everything that has to be in place before the provider tree mounts:
|
||||
* module-level app state, the theme `ThemeProvider` reads at boot, and the
|
||||
* `<body>` markers that say what the controls resolved to.
|
||||
*/
|
||||
apply(): void;
|
||||
}
|
||||
|
||||
const withoutMocks = (config: SignozStoryConfig): StoryOwnedConfig => {
|
||||
const owned = { ...config };
|
||||
delete (owned as SignozStoryConfig).mocks;
|
||||
return owned;
|
||||
};
|
||||
|
||||
const mergeConfigs = (configs: StoryOwnedConfig[]): StoryOwnedConfig =>
|
||||
configs.reduce(
|
||||
(merged, config) => ({
|
||||
...merged,
|
||||
...config,
|
||||
appContext: { ...merged.appContext, ...config.appContext },
|
||||
}),
|
||||
{} as StoryOwnedConfig,
|
||||
);
|
||||
|
||||
const createMockResponse = (state: ResponseState): MockResponse => ({
|
||||
json: (build): MockResolver => respondWith(state, build),
|
||||
});
|
||||
|
||||
const firstAnswer = <TAnswer>(
|
||||
answers: (TAnswer | undefined)[],
|
||||
fallback: TAnswer,
|
||||
): TAnswer =>
|
||||
answers.find((answer): answer is TAnswer => answer !== undefined) ?? fallback;
|
||||
|
||||
const resolveWorld = (context: StoryRuntimeContext): StoryWorld => {
|
||||
const { parameters, args } = context;
|
||||
const storyConfig = parameters.signoz ?? {};
|
||||
|
||||
// A page's own mocks resolve ahead of the global ones, so the page wins every
|
||||
// question both answer.
|
||||
const members: AnyStoryMocks[] = storyConfig.mocks
|
||||
? [storyConfig.mocks, ...globalMocks.members]
|
||||
: [...globalMocks.members];
|
||||
|
||||
const values = members.map((mocks) => mocks.read(args));
|
||||
|
||||
const response = createMockResponse(
|
||||
firstAnswer(
|
||||
members.map((mocks, index) => mocks.responseState?.(values[index])),
|
||||
'loaded',
|
||||
),
|
||||
);
|
||||
|
||||
const role = firstAnswer(
|
||||
members.map((mocks, index) => mocks.role?.(values[index])),
|
||||
USER_ROLES.ADMIN as StoryRole,
|
||||
);
|
||||
|
||||
const config = mergeConfigs([
|
||||
// Reversed, so a page's own config wins over the global one, and the
|
||||
// story's own `parameters.signoz` is the last word over both.
|
||||
...members
|
||||
.map((mocks, index) => mocks.config?.(values[index]) ?? {})
|
||||
.reverse(),
|
||||
withoutMocks(storyConfig),
|
||||
]);
|
||||
|
||||
const theme = config.theme ?? (context.globals?.theme as StoryTheme) ?? 'dark';
|
||||
|
||||
const valuesKey = JSON.stringify(values);
|
||||
|
||||
const handlers = [
|
||||
// A story that declares its own handler always wins.
|
||||
...collectStoryHandlers(parameters.msw),
|
||||
...members.flatMap(
|
||||
(mocks, index) => mocks.handlers?.(values[index], response) ?? [],
|
||||
),
|
||||
// Shell endpoints, the jest handlers, then the catch-all that logs.
|
||||
...storybookHandlers,
|
||||
];
|
||||
|
||||
return {
|
||||
config: { ...config, role },
|
||||
theme,
|
||||
key: `${theme}|${valuesKey}`,
|
||||
handlers,
|
||||
install: (worker): void => {
|
||||
worker.resetHandlers(...handlers);
|
||||
},
|
||||
apply: (): void => {
|
||||
members.forEach((mocks, index) => mocks.effect?.(values[index]));
|
||||
|
||||
// `ThemeProvider` seeds its state from localStorage, so the value has to
|
||||
// be in place before it mounts; `key` forces the remount on a change.
|
||||
set(LOCALSTORAGE.THEME, theme);
|
||||
applyThemeBodyClass(theme);
|
||||
|
||||
// Readable from the Elements panel, so what the controls resolved to can
|
||||
// be checked without reaching into the story store.
|
||||
document.body.dataset.signozStoryRole = role;
|
||||
document.body.dataset.signozStoryMocks = valuesKey;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
let memo: { signature: string; world: StoryWorld } | undefined;
|
||||
|
||||
/**
|
||||
* The single owner of "story context → the world the story renders in". Both the
|
||||
* preview loader and the provider decorator ask for the same story render, so
|
||||
* the result is memoised on what the story is and what its controls hold.
|
||||
*/
|
||||
export const resolveStory = (context: StoryRuntimeContext): StoryWorld => {
|
||||
const signature = JSON.stringify([
|
||||
context.id,
|
||||
context.args,
|
||||
context.globals?.theme,
|
||||
]);
|
||||
|
||||
if (memo?.signature !== signature) {
|
||||
memo = { signature, world: resolveWorld(context) };
|
||||
}
|
||||
|
||||
return memo.world;
|
||||
};
|
||||
33
frontend/src/storybook/runtime/responseState.ts
Normal file
33
frontend/src/storybook/runtime/responseState.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { DefaultBodyType } from 'msw';
|
||||
|
||||
import type { MockRequest, MockResolver } from '../msw/types';
|
||||
|
||||
export const RESPONSE_STATES = ['loaded', 'loading', 'error'] as const;
|
||||
|
||||
export type ResponseState = (typeof RESPONSE_STATES)[number];
|
||||
|
||||
/**
|
||||
* The three answers every mocked endpoint can give, in one place: the payload
|
||||
* the caller built, a request that never resolves, or a failure. A story reaches
|
||||
* all three by turning one control, and a fourth state added here reaches every
|
||||
* endpoint declared through it.
|
||||
*/
|
||||
export const respondWith =
|
||||
<TBody>(
|
||||
state: ResponseState,
|
||||
build: (req: MockRequest) => TBody | Promise<TBody>,
|
||||
): MockResolver =>
|
||||
async (req, res, ctx) => {
|
||||
if (state === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.json({ status: 'error', error: 'storybook: forced failure' }),
|
||||
);
|
||||
}
|
||||
|
||||
return res(ctx.status(200), ctx.json((await build(req)) as DefaultBodyType));
|
||||
};
|
||||
30
frontend/src/storybook/storybook-root.scss
Normal file
30
frontend/src/storybook/storybook-root.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
/* `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;
|
||||
}
|
||||
|
||||
/* The story's last frame, without waiting out every fade: a zero-length single
|
||||
iteration that keeps its end state, which is also where Chromatic parks an
|
||||
animation. An infinite spinner would otherwise be caught at whatever angle
|
||||
the frame landed on, and a text caret blinks on or off at random. Applied
|
||||
after `play` by `settleForCapture`, off under Motion: Live. */
|
||||
html.sb-still {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
animation-fill-mode: forwards !important;
|
||||
animation-play-state: paused !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
|
||||
* {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user