mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-09 04:50:40 +01:00
Compare commits
21 Commits
issue_5979
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b68cc74a6 | ||
|
|
38e4652dfb | ||
|
|
c5c577b731 | ||
|
|
f9d8ae6e3f | ||
|
|
533402fc79 | ||
|
|
3cf6abd554 | ||
|
|
274df85659 | ||
|
|
c3cd27ceb7 | ||
|
|
3526f7b6ae | ||
|
|
bc9481ab99 | ||
|
|
5f7dba50f0 | ||
|
|
038b14fe88 | ||
|
|
3600d92f18 | ||
|
|
f3a9f7a545 | ||
|
|
6384be1985 | ||
|
|
0d1b34986d | ||
|
|
e0bccb9358 | ||
|
|
e7c48e964f | ||
|
|
fe8c9d8cf4 | ||
|
|
2ad3057fa6 | ||
|
|
9fc73fc0b1 |
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
|
||||
|
||||
@@ -397,7 +397,6 @@ identn:
|
||||
# headers to use for tokenizer identN resolver
|
||||
headers:
|
||||
- Authorization
|
||||
- Sec-WebSocket-Protocol
|
||||
apikey:
|
||||
# toggle apikey identN
|
||||
enabled: true
|
||||
|
||||
@@ -184,7 +184,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterWebSocketPaths(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
@@ -197,7 +196,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
const getCustomFilters = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
const { signal } = props;
|
||||
try {
|
||||
const response = await axios.get(`/orgs/me/filters/${signal}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getCustomFilters;
|
||||
@@ -1,13 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
|
||||
|
||||
const updateCustomFiltersAPI = async (
|
||||
props: UpdateCustomFiltersProps,
|
||||
): Promise<SuccessResponse<void> | AxiosError> =>
|
||||
axios.put(`/orgs/me/filters`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
export default updateCustomFiltersAPI;
|
||||
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;
|
||||
@@ -22,7 +22,10 @@ interface UseFieldValuesReturn {
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
DataSource,
|
||||
TelemetrytypesSignalDTO
|
||||
> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
function SortableFilter({
|
||||
filter,
|
||||
@@ -25,13 +25,13 @@ function SortableFilter({
|
||||
allowDrag,
|
||||
allowRemove,
|
||||
}: {
|
||||
filter: FilterType;
|
||||
onRemove: (filter: FilterType) => void;
|
||||
filter: TelemetryFieldKey;
|
||||
onRemove: (filter: TelemetryFieldKey) => void;
|
||||
allowDrag: boolean;
|
||||
allowRemove: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: filter.key });
|
||||
useSortable({ id: filter.key as string });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -46,14 +46,14 @@ function SortableFilter({
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
{allowDrag && <GripVertical size={16} />}
|
||||
{filter.key}
|
||||
{filter.name}
|
||||
</div>
|
||||
{allowRemove && (
|
||||
<Button
|
||||
className="remove-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => {
|
||||
onRemove(filter as FilterType);
|
||||
onRemove(filter);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -69,8 +69,8 @@ function AddedFilters({
|
||||
setAddedFilters,
|
||||
}: {
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
@@ -90,12 +90,12 @@ function AddedFilters({
|
||||
const filteredAddedFilters = useMemo(
|
||||
() =>
|
||||
addedFilters.filter((filter) =>
|
||||
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
),
|
||||
[addedFilters, inputValue],
|
||||
);
|
||||
|
||||
const handleRemoveFilter = (filter: FilterType): void => {
|
||||
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ function AddedFilters({
|
||||
<div className="no-values-found">No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={addedFilters.map((f) => f.key)}
|
||||
items={addedFilters.map((f) => f.key as string)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
function OtherFiltersSkeleton(): JSX.Element {
|
||||
return (
|
||||
@@ -37,106 +37,48 @@ function OtherFilters({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isLogDataSource = useMemo(
|
||||
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
|
||||
[signal],
|
||||
);
|
||||
const isMeterDataSource = useMemo(
|
||||
() => signal && signal === SignalType.METER_EXPLORER,
|
||||
[signal],
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
},
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
|
||||
useGetAttributeSuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
filters: {} as TagFilter,
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isLogDataSource,
|
||||
},
|
||||
);
|
||||
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
|
||||
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
|
||||
// add, render) can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
name: attr.name,
|
||||
signal: attr.signal as TelemetryFieldKey['signal'],
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
}));
|
||||
|
||||
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
|
||||
useGetAggregateKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
|
||||
},
|
||||
const addedKeys = new Set(
|
||||
addedFilters.map((filter) =>
|
||||
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
|
||||
),
|
||||
);
|
||||
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
|
||||
}, [data, addedFilters]);
|
||||
|
||||
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
|
||||
useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: 'meter',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const otherFilters = useMemo(() => {
|
||||
let filterAttributes;
|
||||
if (isLogDataSource) {
|
||||
filterAttributes = suggestionsData?.payload?.attributes || [];
|
||||
} else if (isMeterDataSource) {
|
||||
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
|
||||
fieldKeysData?.data?.data?.keys || {},
|
||||
)?.flat();
|
||||
filterAttributes = fieldKeys.map(
|
||||
(attr) =>
|
||||
({
|
||||
key: attr.name,
|
||||
dataType: attr.fieldDataType,
|
||||
type: attr.fieldContext,
|
||||
signal: attr.signal,
|
||||
}) as BaseAutocompleteData,
|
||||
);
|
||||
} else {
|
||||
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
|
||||
}
|
||||
return filterAttributes?.filter(
|
||||
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
|
||||
);
|
||||
}, [
|
||||
suggestionsData,
|
||||
aggregateKeysData,
|
||||
addedFilters,
|
||||
isLogDataSource,
|
||||
fieldKeysData,
|
||||
isMeterDataSource,
|
||||
]);
|
||||
|
||||
const handleAddFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: filter.key,
|
||||
dataType: filter.dataType,
|
||||
type: filter.type,
|
||||
},
|
||||
]);
|
||||
const handleAddFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => [...prev, filter]);
|
||||
};
|
||||
|
||||
const renderFilters = (): React.ReactNode => {
|
||||
const isLoading =
|
||||
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
|
||||
if (isLoading) {
|
||||
if (isFetching) {
|
||||
return <OtherFiltersSkeleton />;
|
||||
}
|
||||
if (!otherFilters?.length) {
|
||||
@@ -145,11 +87,11 @@ function OtherFilters({
|
||||
|
||||
return otherFilters.map((filter) => (
|
||||
<div key={filter.key} className="qf-filter-item other-filters-item">
|
||||
<div className="qf-filter-key">{filter.key}</div>
|
||||
<div className="qf-filter-key">{filter.name}</div>
|
||||
<Button
|
||||
className="add-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => handleAddFilter(filter as FilterType)}
|
||||
onClick={(): void => handleAddFilter(filter)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SignalType } from '../types';
|
||||
import AddedFilters from './AddedFilters';
|
||||
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
handleInputChange,
|
||||
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
|
||||
signal,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
// check if both arrays have the same length and same order of elements
|
||||
!(
|
||||
addedFilters.length === customFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === customFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, customFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="qf-header">
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
interface UseQuickFilterSettingsReturn {
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
handleSettingsClose: () => void;
|
||||
handleDiscardChanges: () => void;
|
||||
handleSaveChanges: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
isUpdatingCustomFilters: boolean;
|
||||
inputValue: string;
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
|
||||
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
|
||||
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() =>
|
||||
customFilters.map((filter) => ({
|
||||
...filter,
|
||||
key: buildCompositeKey(
|
||||
filter.name,
|
||||
filter.fieldContext,
|
||||
filter.fieldDataType,
|
||||
),
|
||||
})),
|
||||
[customFilters],
|
||||
);
|
||||
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
|
||||
normalizedCustomFilters,
|
||||
);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
|
||||
useMutation(updateCustomFiltersAPI, {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
notifications.error({
|
||||
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
useUpdateQuickFilters({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
void logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message: error.message || SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
@@ -78,19 +98,32 @@ const useQuickFilterSettings = ({
|
||||
}, [setIsSettingsOpen]);
|
||||
|
||||
const handleDiscardChanges = useCallback((): void => {
|
||||
setAddedFilters(customFilters);
|
||||
}, [customFilters, setAddedFilters]);
|
||||
setAddedFilters(normalizedCustomFilters);
|
||||
}, [normalizedCustomFilters, setAddedFilters]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
addedFilters.length === normalizedCustomFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === normalizedCustomFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, normalizedCustomFilters],
|
||||
);
|
||||
|
||||
const handleSaveChanges = useCallback((): void => {
|
||||
if (signal) {
|
||||
updateCustomFilters({
|
||||
pathParams: { source: signal },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
key: filter.key,
|
||||
datatype: filter.dataType,
|
||||
type: filter.type,
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
signal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
|
||||
}
|
||||
interface UseFilterConfigReturn {
|
||||
filterConfig: IQuickFiltersConfig[];
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
isCustomFiltersLoading: boolean;
|
||||
isDynamicFilters: boolean;
|
||||
refetchCustomFilters: () => void;
|
||||
@@ -25,17 +23,16 @@ const useFilterConfig = ({
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const {
|
||||
isFetching: isCustomFiltersLoading,
|
||||
data: customFilters = [],
|
||||
data,
|
||||
refetch,
|
||||
} = useQuery<FilterType[], Error>(
|
||||
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
|
||||
async () => {
|
||||
const res = await getCustomFilters({ signal: signal || '' });
|
||||
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
|
||||
},
|
||||
{
|
||||
enabled: !!signal,
|
||||
},
|
||||
} = useGetQuickFilters(
|
||||
{ source: signal ?? '' },
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const customFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
|
||||
[data],
|
||||
);
|
||||
|
||||
const isDynamicFilters = useMemo(
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -338,6 +338,63 @@ describe('Quick Filters with custom filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps same-name fields with different context as distinct entries', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(
|
||||
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
level: [
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||
// Both `level` variants are shown despite sharing a name.
|
||||
await waitFor(() =>
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
|
||||
);
|
||||
|
||||
// Adding one variant removes only that one; the other stays.
|
||||
const firstLevel = within(otherSection).getAllByText('level')[0];
|
||||
const addButton = firstLevel.parentElement?.querySelector('button');
|
||||
await user.click(addButton as HTMLButtonElement);
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
await waitFor(() => {
|
||||
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -456,12 +513,10 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
const requestBody = putHandler.mock.calls[0][0];
|
||||
expect(requestBody.filters).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
|
||||
]),
|
||||
expect(requestBody.filters).not.toContainEqual(
|
||||
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
|
||||
);
|
||||
expect(requestBody.signal).toBe(SIGNAL);
|
||||
expect(requestBody.filters).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should render duration slider for duration_nono filter', async () => {
|
||||
@@ -612,9 +667,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
filters: [
|
||||
...(quickFiltersListResponse.data.filters ?? []),
|
||||
{
|
||||
key: 'new.custom.filter',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'new.custom.filter',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
} as const,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
|
||||
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
|
||||
|
||||
@@ -12,6 +14,19 @@ const FILTER_TYPE_MAP: Record<string, FiltersType> = {
|
||||
duration_nano: FiltersType.DURATION,
|
||||
};
|
||||
|
||||
// The map below exists only for the old v3 attribute-values fetch
|
||||
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
|
||||
// Once the values fetch moves to fields/values, remove this and reduce
|
||||
// attributeKey to { id, key }.
|
||||
|
||||
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
};
|
||||
|
||||
const mapFieldContext = (fieldContext?: string): string =>
|
||||
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
|
||||
|
||||
const getFilterName = (str: string): string => {
|
||||
if (FILTER_TITLE_MAP[str]) {
|
||||
return FILTER_TITLE_MAP[str];
|
||||
@@ -26,16 +41,16 @@ const getFilterName = (str: string): string => {
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getFilterType = (att: FilterType): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.key]) {
|
||||
return FILTER_TYPE_MAP[att.key];
|
||||
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.name]) {
|
||||
return FILTER_TYPE_MAP[att.name];
|
||||
}
|
||||
return FiltersType.CHECKBOX;
|
||||
};
|
||||
|
||||
export const getFilterConfig = (
|
||||
signal?: SignalType,
|
||||
customFilters?: FilterType[],
|
||||
customFilters?: TelemetryFieldKey[],
|
||||
config?: IQuickFiltersConfig[],
|
||||
): IQuickFiltersConfig[] => {
|
||||
if (!customFilters?.length || !signal) {
|
||||
@@ -46,13 +61,13 @@ export const getFilterConfig = (
|
||||
(att, index) =>
|
||||
({
|
||||
type: getFilterType(att),
|
||||
title: getFilterName(att.key),
|
||||
title: getFilterName(att.name),
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
|
||||
attributeKey: {
|
||||
id: att.key,
|
||||
key: att.key,
|
||||
dataType: att.dataType,
|
||||
type: att.type,
|
||||
id: att.name,
|
||||
key: att.name,
|
||||
dataType: fieldDataTypeToDataType(att.fieldDataType),
|
||||
type: mapFieldContext(att.fieldContext),
|
||||
},
|
||||
defaultOpen: index < 2,
|
||||
}) as IQuickFiltersConfig,
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-restricted-banner,
|
||||
|
||||
@@ -16,7 +16,6 @@ import * as Sentry from '@sentry/react';
|
||||
import { Toaster } from '@signozhq/ui/sonner';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Flex } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
||||
@@ -820,14 +819,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
{' '}
|
||||
Please{' '}
|
||||
<AuthZTooltip checks={SubscriptionManagePermissions}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="none"
|
||||
className="upgrade-link"
|
||||
onClick={handleFailedPayment}
|
||||
>
|
||||
<a className="upgrade-link" onClick={handleFailedPayment}>
|
||||
pay the bill
|
||||
</Button>
|
||||
</a>
|
||||
</AuthZTooltip>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
margin: var(--spacing-12) var(--spacing-4);
|
||||
}
|
||||
|
||||
.usageDenied {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.billingDetails {
|
||||
margin: var(--spacing-4) 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
@@ -37,6 +37,7 @@ import { isEmpty, pick } from 'lodash-es';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import {
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionManagePermissions,
|
||||
@@ -509,7 +510,15 @@ export default function BillingContainer(): JSX.Element {
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<AuthZGuardContent checks={[SubscriptionReadPermission]}>
|
||||
<AuthZGuardContent
|
||||
checks={[SubscriptionReadPermission]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
className={styles.usageDenied}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
|
||||
@@ -108,7 +108,6 @@ function LogsExplorerViewsContainer({
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [queryId, setQueryId] = useState<string>(v4());
|
||||
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
@@ -180,12 +179,7 @@ function LogsExplorerViewsContainer({
|
||||
},
|
||||
undefined,
|
||||
listQueryKeyRef,
|
||||
{
|
||||
...(!isEmpty(queryId) &&
|
||||
selectedPanelType !== PANEL_TYPES.LIST && {
|
||||
'X-SIGNOZ-QUERY-ID': queryId,
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
|
||||
'custom',
|
||||
);
|
||||
@@ -250,10 +244,6 @@ function LogsExplorerViewsContainer({
|
||||
setRequestData(newRequestData);
|
||||
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryId(v4());
|
||||
}, [data]);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,114 +4,85 @@ export const quickFiltersListResponse = {
|
||||
signal: 'logs',
|
||||
filters: [
|
||||
{
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'os.description',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'duration_nano',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
name: 'duration_nano',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
name: 'quantity',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
key: 'body',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.namespace',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'k8s.namespace.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.instance.id',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'k8s.pod.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'process.owner',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'process.owner',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
|
||||
[name]: [
|
||||
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
|
||||
],
|
||||
});
|
||||
|
||||
export const otherFiltersResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.deployment.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.uid',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
keys: {
|
||||
...otherFilterName('service.name'),
|
||||
...otherFilterName('k8s.deployment.name'),
|
||||
...otherFilterName('deployment.environment'),
|
||||
...otherFilterName('service.namespace'),
|
||||
...otherFilterName('k8s.namespace.name'),
|
||||
...otherFilterName('service.instance.id'),
|
||||
...otherFilterName('k8s.pod.name'),
|
||||
...otherFilterName('k8s.pod.uid'),
|
||||
...otherFilterName('os.description'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -504,7 +504,7 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({
|
||||
let capturedPayload: QueryRangePayloadV5;
|
||||
|
||||
describe('TracesExplorer -', () => {
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/traces`;
|
||||
|
||||
const setupServer = (): void => {
|
||||
server.use(
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
58
frontend/src/storybook/types.ts
Normal file
58
frontend/src/storybook/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { AnyStoryMocks } from './controls/types';
|
||||
import type { StoryMswParameter } from './msw/storyHandlers';
|
||||
|
||||
export type StoryTheme = 'dark' | 'light';
|
||||
|
||||
export type StoryRole = ROLES;
|
||||
|
||||
export type StoryLayout = 'app' | 'none';
|
||||
|
||||
/** What a story or a mock module may set about the tree it renders in. */
|
||||
export interface StoryOwnedConfig {
|
||||
/** Deep-merged over the default mocked `AppContext` value. */
|
||||
appContext?: Partial<IAppContext>;
|
||||
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
|
||||
queryBuilder?: Partial<QueryBuilderContextType>;
|
||||
/** Initial route, search included, e.g. `/home?relativeTime=1h`. */
|
||||
route?: string;
|
||||
/** Overrides the theme toolbar for this story. */
|
||||
theme?: StoryTheme;
|
||||
/**
|
||||
* `'app'` mounts the story inside `AppLayout`, where a route renders: the nav
|
||||
* rail, the banners and the shell's own providers. Page stories want it;
|
||||
* `'none'`, the default, leaves the story bare.
|
||||
*/
|
||||
layout?: StoryLayout;
|
||||
/**
|
||||
* When set, the real redux store is seeded with this state instead of the
|
||||
* reducers' own initial state.
|
||||
*/
|
||||
reduxState?: Partial<AppState>;
|
||||
}
|
||||
|
||||
export interface SignozStoryConfig extends StoryOwnedConfig {
|
||||
/**
|
||||
* The page's control-driven mocks, declared with `defineStoryMocks` and
|
||||
* attached by spreading `storyMocks(...)` into the meta.
|
||||
*/
|
||||
mocks?: AnyStoryMocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the story runtime hands the provider tree. `role` is derived from the
|
||||
* Access controls rather than set by a story, so the mocked `AppContext` and the
|
||||
* mocked `authz/check` endpoint always answer from the same grant.
|
||||
*/
|
||||
export interface ResolvedStoryConfig extends StoryOwnedConfig {
|
||||
role: StoryRole;
|
||||
}
|
||||
|
||||
export interface SignozStoryParameters {
|
||||
signoz?: SignozStoryConfig;
|
||||
msw?: StoryMswParameter;
|
||||
}
|
||||
46
frontend/src/storybook/visual/settleForCapture.ts
Normal file
46
frontend/src/storybook/visual/settleForCapture.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const STILL_CLASS = 'sb-still';
|
||||
|
||||
interface CaptureHooks {
|
||||
__signozSnapPinnedScrollers?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list that keeps itself pinned to the bottom (the Noz thread, every virtuoso
|
||||
* list) settles a few pixels short of it, and where it stops depends on the
|
||||
* order its items were measured in. Anything already within a row of its end is
|
||||
* snapped onto it; a list parked anywhere else is left where the story put it.
|
||||
*/
|
||||
const snapPinnedScrollers = (): void => {
|
||||
document.querySelectorAll('*').forEach((element) => {
|
||||
const slack = element.scrollHeight - element.clientHeight - element.scrollTop;
|
||||
if (element.scrollTop > 0 && slack > 0 && slack <= 64) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs after the story's `play`, which is where both capture stacks take the
|
||||
* picture: Chromatic snapshots there, and `scripts/story-shots.mjs` waits for
|
||||
* the same render phase. Only what Chromatic cannot do for itself lives here -
|
||||
* it already pauses animations, videos and GIFs, and waits for the network to
|
||||
* go quiet.
|
||||
*/
|
||||
export const settleForCapture = ({
|
||||
globals,
|
||||
}: {
|
||||
globals: Record<string, unknown>;
|
||||
}): void => {
|
||||
document.documentElement.classList.toggle(
|
||||
STILL_CLASS,
|
||||
globals.motion !== 'live',
|
||||
);
|
||||
|
||||
snapPinnedScrollers();
|
||||
|
||||
// The local harness runs this again once the page has gone quiet: a virtuoso
|
||||
// list is often still measuring when `afterEach` fires, and there is no event
|
||||
// that says it stopped.
|
||||
(window as unknown as CaptureHooks).__signozSnapPinnedScrollers =
|
||||
snapPinnedScrollers;
|
||||
};
|
||||
170
frontend/src/tests/fixtures/appContextMock.ts
vendored
Normal file
170
frontend/src/tests/fixtures/appContextMock.ts
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
/**
|
||||
* Factory for the spies assigned to the callable members of `IAppContext`.
|
||||
* Jest passes `jest.fn`, Storybook passes `fn` from `storybook/test`; the
|
||||
* fixture itself stays free of any test-runner import so both can consume it.
|
||||
*/
|
||||
export type SpyFactory = () => (...args: unknown[]) => void;
|
||||
|
||||
const noopSpyFactory: SpyFactory = () => (): void => {};
|
||||
|
||||
export const defaultFeatureFlags = [
|
||||
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function createAppContextMock(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
createSpy: SpyFactory = noopSpyFactory,
|
||||
): IAppContext {
|
||||
return {
|
||||
activeLicense: {
|
||||
id: 'test-license-id',
|
||||
eventQueue: {
|
||||
createdAt: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduledAt: '0',
|
||||
status: '',
|
||||
updatedAt: '0',
|
||||
},
|
||||
state: LicenseState.ACTIVATED,
|
||||
status: LicenseStatus.VALID,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
createdAt: '0',
|
||||
plan: {
|
||||
id: '0',
|
||||
createdAt: '0',
|
||||
description: '',
|
||||
isActive: true,
|
||||
name: '',
|
||||
updatedAt: '0',
|
||||
},
|
||||
freeUntil: '0',
|
||||
updatedAt: '0',
|
||||
validFrom: 0,
|
||||
validUntil: 0,
|
||||
},
|
||||
trialInfo: {
|
||||
trialStart: -1,
|
||||
trialEnd: -1,
|
||||
onTrial: false,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicenseFetchError: null,
|
||||
changelog: null,
|
||||
user: {
|
||||
accessJwt: 'some-token',
|
||||
refreshJwt: 'some-refresh-token',
|
||||
id: 'some-user-id',
|
||||
email: 'does-not-matter@signoz.io',
|
||||
displayName: 'John Doe',
|
||||
createdAt: 1732544623,
|
||||
organization: 'Nightswatch',
|
||||
orgId: 'does-not-matter-id',
|
||||
role: role as ROLES,
|
||||
},
|
||||
org: [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: 'does-not-matter-id',
|
||||
displayName: 'Pentagon',
|
||||
},
|
||||
],
|
||||
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
|
||||
isFetchingUser: false,
|
||||
userFetchError: null,
|
||||
featureFlags: defaultFeatureFlags,
|
||||
isFetchingFeatureFlags: false,
|
||||
featureFlagsFetchError: null,
|
||||
hostsData: null,
|
||||
isFetchingHosts: false,
|
||||
hostsFetchError: null,
|
||||
orgPreferences: [
|
||||
{
|
||||
name: ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
description: 'Organisation Onboarding',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: createSpy(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: createSpy(),
|
||||
updateOrg: createSpy(),
|
||||
updateOrgPreferences: createSpy(),
|
||||
activeLicenseRefetch: createSpy(),
|
||||
updateChangelog: createSpy(),
|
||||
toggleChangelogModal: createSpy(),
|
||||
versionData: {
|
||||
version: '1.0.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
...appContextOverrides,
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
@@ -19,16 +17,10 @@ import {
|
||||
} from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
// import { MemoryRouter as V5MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
|
||||
// Mock ResizeObserver
|
||||
@@ -106,154 +98,13 @@ jest.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const defaultFeatureFlags = [
|
||||
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
|
||||
{
|
||||
name: FeatureKeys.USE_SPAN_METRICS,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.PREMIUM_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.CHAT_SUPPORT,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
export { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
|
||||
export function getAppContextMock(
|
||||
role: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
): IAppContext {
|
||||
return {
|
||||
activeLicense: {
|
||||
id: 'test-license-id',
|
||||
eventQueue: {
|
||||
createdAt: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduledAt: '0',
|
||||
status: '',
|
||||
updatedAt: '0',
|
||||
},
|
||||
state: LicenseState.ACTIVATED,
|
||||
status: LicenseStatus.VALID,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
createdAt: '0',
|
||||
plan: {
|
||||
id: '0',
|
||||
createdAt: '0',
|
||||
description: '',
|
||||
isActive: true,
|
||||
name: '',
|
||||
updatedAt: '0',
|
||||
},
|
||||
freeUntil: '0',
|
||||
updatedAt: '0',
|
||||
validFrom: 0,
|
||||
validUntil: 0,
|
||||
},
|
||||
trialInfo: {
|
||||
trialStart: -1,
|
||||
trialEnd: -1,
|
||||
onTrial: false,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicenseFetchError: null,
|
||||
changelog: null,
|
||||
user: {
|
||||
accessJwt: 'some-token',
|
||||
refreshJwt: 'some-refresh-token',
|
||||
id: 'some-user-id',
|
||||
email: 'does-not-matter@signoz.io',
|
||||
displayName: 'John Doe',
|
||||
createdAt: 1732544623,
|
||||
organization: 'Nightswatch',
|
||||
orgId: 'does-not-matter-id',
|
||||
role: role as ROLES,
|
||||
},
|
||||
org: [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: 'does-not-matter-id',
|
||||
displayName: 'Pentagon',
|
||||
},
|
||||
],
|
||||
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
|
||||
isFetchingUser: false,
|
||||
userFetchError: null,
|
||||
featureFlags: defaultFeatureFlags,
|
||||
isFetchingFeatureFlags: false,
|
||||
featureFlagsFetchError: null,
|
||||
hostsData: null,
|
||||
isFetchingHosts: false,
|
||||
hostsFetchError: null,
|
||||
orgPreferences: [
|
||||
{
|
||||
name: ORG_PREFERENCES.ORG_ONBOARDING,
|
||||
description: 'Organisation Onboarding',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: jest.fn(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: jest.fn(),
|
||||
updateOrg: jest.fn(),
|
||||
updateOrgPreferences: jest.fn(),
|
||||
activeLicenseRefetch: jest.fn(),
|
||||
updateChangelog: jest.fn(),
|
||||
toggleChangelogModal: jest.fn(),
|
||||
versionData: {
|
||||
version: '1.0.0',
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
...appContextOverrides,
|
||||
};
|
||||
return createAppContextMock(role, appContextOverrides, () => jest.fn());
|
||||
}
|
||||
|
||||
export function AllTheProviders({
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface Filter {
|
||||
key: string;
|
||||
dataType: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export type PayloadProps = {
|
||||
filters: Filter[];
|
||||
signal: string;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
|
||||
interface FilterType {
|
||||
key: string;
|
||||
datatype: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomFiltersProps {
|
||||
data: {
|
||||
filters: FilterType[];
|
||||
signal: SignalType;
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"./__mocks__/*"
|
||||
],
|
||||
"api": ["./src/api"],
|
||||
"app/*": ["./src/app/*"],
|
||||
"AppRoutes": ["./src/AppRoutes"],
|
||||
"ReactI18": ["./src/ReactI18"],
|
||||
"store": ["./src/store"],
|
||||
@@ -76,6 +77,7 @@
|
||||
],
|
||||
"include": [
|
||||
"./src",
|
||||
"./.storybook",
|
||||
"./__mocks__",
|
||||
"./babel.config.cjs",
|
||||
"./jest.config.ts",
|
||||
|
||||
2
go.mod
2
go.mod
@@ -31,7 +31,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/huandu/go-sqlbuilder v1.39.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
|
||||
@@ -134,6 +133,7 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
@@ -45,7 +45,7 @@ func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Tokenizer: TokenizerConfig{
|
||||
Enabled: true,
|
||||
Headers: []string{"Authorization", "Sec-WebSocket-Protocol"},
|
||||
Headers: []string{"Authorization"},
|
||||
},
|
||||
APIKeyConfig: APIKeyConfig{
|
||||
Enabled: true,
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
// tracks progress and manages subscriptions for all queries
|
||||
type inMemoryQueryProgressTracker struct {
|
||||
logger *slog.Logger
|
||||
queries map[string]*queryTracker
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) ReportQueryStarted(
|
||||
queryId string,
|
||||
) (postQueryCleanup func(), apiErr *model.ApiError) {
|
||||
tracker.lock.Lock()
|
||||
defer tracker.lock.Unlock()
|
||||
|
||||
_, exists := tracker.queries[queryId]
|
||||
if exists {
|
||||
return nil, model.BadRequest(fmt.Errorf(
|
||||
"query %s already started", queryId,
|
||||
))
|
||||
}
|
||||
|
||||
tracker.queries[queryId] = newQueryTracker(tracker.logger, queryId)
|
||||
|
||||
return func() {
|
||||
tracker.onQueryFinished(queryId)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) ReportQueryProgress(
|
||||
queryId string, chProgress *clickhouse.Progress,
|
||||
) *model.ApiError {
|
||||
queryTracker, err := tracker.getQueryTracker(queryId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
queryTracker.handleProgressUpdate(chProgress)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) SubscribeToQueryProgress(
|
||||
queryId string,
|
||||
) (<-chan model.QueryProgress, func(), *model.ApiError) {
|
||||
queryTracker, err := tracker.getQueryTracker(queryId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return queryTracker.subscribe()
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) onQueryFinished(
|
||||
queryId string,
|
||||
) {
|
||||
tracker.lock.Lock()
|
||||
queryTracker := tracker.queries[queryId]
|
||||
if queryTracker != nil {
|
||||
delete(tracker.queries, queryId)
|
||||
}
|
||||
tracker.lock.Unlock()
|
||||
|
||||
if queryTracker != nil {
|
||||
queryTracker.onFinished()
|
||||
}
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) getQueryTracker(
|
||||
queryId string,
|
||||
) (*queryTracker, *model.ApiError) {
|
||||
tracker.lock.RLock()
|
||||
defer tracker.lock.RUnlock()
|
||||
|
||||
queryTracker := tracker.queries[queryId]
|
||||
if queryTracker == nil {
|
||||
return nil, model.NotFoundError(fmt.Errorf(
|
||||
"query %s doesn't exist", queryId,
|
||||
))
|
||||
}
|
||||
|
||||
return queryTracker, nil
|
||||
}
|
||||
|
||||
// Tracks progress and manages subscriptions for a single query
|
||||
type queryTracker struct {
|
||||
logger *slog.Logger
|
||||
queryId string
|
||||
isFinished bool
|
||||
|
||||
progress *model.QueryProgress
|
||||
subscriptions map[string]*queryProgressSubscription
|
||||
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func newQueryTracker(logger *slog.Logger, queryId string) *queryTracker {
|
||||
return &queryTracker{
|
||||
logger: logger,
|
||||
queryId: queryId,
|
||||
subscriptions: map[string]*queryProgressSubscription{},
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) handleProgressUpdate(p *clickhouse.Progress) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Warn("received clickhouse progress update for finished query", "queryId", qt.queryId, "progress", p)
|
||||
return
|
||||
}
|
||||
|
||||
if qt.progress == nil {
|
||||
// This is the first update
|
||||
qt.progress = &model.QueryProgress{}
|
||||
}
|
||||
updateQueryProgress(qt.progress, p)
|
||||
|
||||
// broadcast latest state to all subscribers.
|
||||
for _, sub := range maps.Values(qt.subscriptions) {
|
||||
sub.send(*qt.progress)
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) subscribe() (
|
||||
<-chan model.QueryProgress, func(), *model.ApiError,
|
||||
) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
return nil, nil, model.NotFoundError(fmt.Errorf(
|
||||
"query %s already finished", qt.queryId,
|
||||
))
|
||||
}
|
||||
|
||||
subscriberId := uuid.NewString()
|
||||
subscription := newQueryProgressSubscription(qt.logger)
|
||||
qt.subscriptions[subscriberId] = subscription
|
||||
|
||||
if qt.progress != nil {
|
||||
subscription.send(*qt.progress)
|
||||
}
|
||||
|
||||
return subscription.ch, func() {
|
||||
qt.unsubscribe(subscriberId)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (qt *queryTracker) unsubscribe(subscriberId string) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Debug("received unsubscribe request after query finished", "subscriber", subscriberId, "queryId", qt.queryId)
|
||||
return
|
||||
}
|
||||
|
||||
subscription := qt.subscriptions[subscriberId]
|
||||
if subscription != nil {
|
||||
subscription.close()
|
||||
delete(qt.subscriptions, subscriberId)
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) onFinished() {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Warn("receiver query finish report after query finished", "queryId", qt.queryId)
|
||||
return
|
||||
}
|
||||
|
||||
for subId, sub := range qt.subscriptions {
|
||||
sub.close()
|
||||
delete(qt.subscriptions, subId)
|
||||
}
|
||||
|
||||
qt.isFinished = true
|
||||
}
|
||||
|
||||
type queryProgressSubscription struct {
|
||||
logger *slog.Logger
|
||||
ch chan model.QueryProgress
|
||||
isClosed bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func newQueryProgressSubscription(logger *slog.Logger) *queryProgressSubscription {
|
||||
ch := make(chan model.QueryProgress, 1000)
|
||||
return &queryProgressSubscription{
|
||||
logger: logger,
|
||||
ch: ch,
|
||||
}
|
||||
}
|
||||
|
||||
// Must not block or panic in any scenario
|
||||
func (ch *queryProgressSubscription) send(progress model.QueryProgress) {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
if ch.isClosed {
|
||||
ch.logger.Error("can't send query progress: channel already closed.", "progress", progress)
|
||||
return
|
||||
}
|
||||
|
||||
// subscription channels are expected to have big enough buffers to ensure
|
||||
// blocking while sending doesn't happen in the happy path
|
||||
select {
|
||||
case ch.ch <- progress:
|
||||
ch.logger.Debug("published query progress", "progress", progress)
|
||||
default:
|
||||
ch.logger.Error("couldn't publish query progress. dropping update.", "progress", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func (ch *queryProgressSubscription) close() {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
if !ch.isClosed {
|
||||
close(ch.ch)
|
||||
ch.isClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
func updateQueryProgress(qp *model.QueryProgress, chProgress *clickhouse.Progress) {
|
||||
qp.ReadRows += chProgress.Rows
|
||||
qp.ReadBytes += chProgress.Bytes
|
||||
qp.ElapsedMs += uint64(chProgress.Elapsed.Milliseconds())
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
)
|
||||
|
||||
type QueryProgressTracker interface {
|
||||
// Tells the tracker that query with id `queryId` has started.
|
||||
// Progress can only be reported for and tracked for a query that is in progress.
|
||||
// Returns a cleanup function that must be called after the query finishes.
|
||||
ReportQueryStarted(queryId string) (postQueryCleanup func(), apiErr *model.ApiError)
|
||||
|
||||
// Report progress stats received from clickhouse for `queryId`
|
||||
ReportQueryProgress(queryId string, chProgress *clickhouse.Progress) *model.ApiError
|
||||
|
||||
// Subscribe to progress updates for `queryId`
|
||||
// The returned channel will produce `QueryProgress` instances representing
|
||||
// the latest state of query progress stats. Also returns a function that
|
||||
// can be called to unsubscribe before the query finishes, if needed.
|
||||
SubscribeToQueryProgress(queryId string) (ch <-chan model.QueryProgress, unsubscribe func(), apiErr *model.ApiError)
|
||||
}
|
||||
|
||||
func NewQueryProgressTracker(logger *slog.Logger) QueryProgressTracker {
|
||||
// InMemory tracker is useful only for single replica query service setups.
|
||||
// Multi replica setups must use a centralized store for tracking and subscribing to query progress
|
||||
return &inMemoryQueryProgressTracker{
|
||||
logger: logger,
|
||||
queries: map[string]*queryTracker{},
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQueryProgressTracking(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
tracker := NewQueryProgressTracker(slog.Default())
|
||||
|
||||
testQueryId := "test-query"
|
||||
|
||||
testProgress := &clickhouse.Progress{}
|
||||
err := tracker.ReportQueryProgress(testQueryId, testProgress)
|
||||
require.NotNil(err, "shouldn't be able to report query progress before query has been started")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
|
||||
ch, unsubscribe, err := tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.NotNil(err, "shouldn't be able to subscribe for progress updates before query has been started")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
require.Nil(ch)
|
||||
require.Nil(unsubscribe)
|
||||
|
||||
reportQueryFinished, err := tracker.ReportQueryStarted(testQueryId)
|
||||
require.Nil(err, "should be able to report start of a query to be tracked")
|
||||
|
||||
testProgress1 := &clickhouse.Progress{
|
||||
Rows: 10,
|
||||
Bytes: 20,
|
||||
TotalRows: 100,
|
||||
Elapsed: 20 * time.Millisecond,
|
||||
}
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress1)
|
||||
require.Nil(err, "should be able to report progress after query has started")
|
||||
|
||||
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.Nil(err, "should be able to subscribe to query progress updates after query started")
|
||||
require.NotNil(ch)
|
||||
require.NotNil(unsubscribe)
|
||||
|
||||
expectedProgress := model.QueryProgress{}
|
||||
updateQueryProgress(&expectedProgress, testProgress1)
|
||||
require.Equal(expectedProgress.ReadRows, testProgress1.Rows)
|
||||
select {
|
||||
case qp := <-ch:
|
||||
require.Equal(qp, expectedProgress)
|
||||
default:
|
||||
require.Fail("should receive latest query progress state immediately after subscription")
|
||||
}
|
||||
select {
|
||||
case _ = <-ch:
|
||||
require.Fail("should have had only one pending update at this point")
|
||||
default:
|
||||
}
|
||||
|
||||
testProgress2 := &clickhouse.Progress{
|
||||
Rows: 20,
|
||||
Bytes: 40,
|
||||
TotalRows: 100,
|
||||
Elapsed: 40 * time.Millisecond,
|
||||
}
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress2)
|
||||
require.Nil(err, "should be able to report progress multiple times while query is in progress")
|
||||
|
||||
updateQueryProgress(&expectedProgress, testProgress2)
|
||||
select {
|
||||
case qp := <-ch:
|
||||
require.Equal(qp, expectedProgress)
|
||||
default:
|
||||
require.Fail("should receive updates whenever new progress updates get reported to tracker")
|
||||
}
|
||||
select {
|
||||
case _ = <-ch:
|
||||
require.Fail("should have had only one pending update at this point")
|
||||
default:
|
||||
}
|
||||
|
||||
reportQueryFinished()
|
||||
select {
|
||||
case _, isSubscriptionChannelOpen := <-ch:
|
||||
require.False(isSubscriptionChannelOpen, "subscription channels should get closed after query finishes")
|
||||
default:
|
||||
require.Fail("subscription channels should get closed after query finishes")
|
||||
}
|
||||
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress)
|
||||
require.NotNil(err, "shouldn't be able to report query progress after query has finished")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
|
||||
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.NotNil(err, "shouldn't be able to subscribe for progress updates after query has finished")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
require.Nil(ch)
|
||||
require.Nil(unsubscribe)
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import (
|
||||
|
||||
"log/slog"
|
||||
|
||||
queryprogress "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader/query_progress"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/resource"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/services"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/traces/smart"
|
||||
@@ -145,7 +144,6 @@ type ClickHouseReader struct {
|
||||
logsResourceKeys string
|
||||
logsTagAttributeTableV2 string
|
||||
logger *slog.Logger
|
||||
queryProgressTracker queryprogress.QueryProgressTracker
|
||||
|
||||
logsTableV2 string
|
||||
logsLocalTableV2 string
|
||||
@@ -214,7 +212,6 @@ func NewReader(
|
||||
logsTagAttributeTableV2: options.primary.LogsTagAttributeTableV2,
|
||||
liveTailRefreshSeconds: options.primary.LiveTailRefreshSeconds,
|
||||
cluster: cluster,
|
||||
queryProgressTracker: queryprogress.NewQueryProgressTracker(logger),
|
||||
logsTableV2: options.primary.LogsTableV2,
|
||||
logsLocalTableV2: options.primary.LogsLocalTableV2,
|
||||
logsResourceTableV2: options.primary.LogsResourceTableV2,
|
||||
@@ -4024,27 +4021,6 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTimeSeriesResultV3",
|
||||
})
|
||||
// Hook up query progress reporting if requested.
|
||||
queryId := ctx.Value("queryId")
|
||||
if queryId != nil {
|
||||
qid, ok := queryId.(string)
|
||||
if !ok {
|
||||
r.logger.Error("GetTimeSeriesResultV3: queryId in ctx not a string as expected", "queryId", queryId)
|
||||
|
||||
} else {
|
||||
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(
|
||||
func(p *clickhouse.Progress) {
|
||||
go func() {
|
||||
err := r.queryProgressTracker.ReportQueryProgress(qid, p)
|
||||
if err != nil {
|
||||
r.logger.Error("Couldn't report query progress", "queryId", qid, errorsV2.Attr(err))
|
||||
}
|
||||
}()
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
|
||||
if err != nil {
|
||||
@@ -5005,18 +4981,6 @@ func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context,
|
||||
return minTime.UnixNano(), maxTime.UnixNano(), nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) ReportQueryStartForProgressTracking(
|
||||
queryId string,
|
||||
) (func(), *model.ApiError) {
|
||||
return r.queryProgressTracker.ReportQueryStarted(queryId)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) SubscribeToQueryProgress(
|
||||
queryId string,
|
||||
) (<-chan model.QueryProgress, func(), *model.ApiError) {
|
||||
return r.queryProgressTracker.SubscribeToQueryProgress(queryId)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
@@ -115,9 +114,6 @@ type APIHandler struct {
|
||||
// is registers.
|
||||
SetupCompleted bool
|
||||
|
||||
// Websocket connection upgrader
|
||||
Upgrader *websocket.Upgrader
|
||||
|
||||
QueryParserAPI *queryparser.API
|
||||
|
||||
Signoz *signoz.SigNoz
|
||||
@@ -207,12 +203,6 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
|
||||
aH.SetupCompleted = true
|
||||
}
|
||||
|
||||
aH.Upgrader = &websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
return aH, nil
|
||||
}
|
||||
|
||||
@@ -354,18 +344,10 @@ func (aH *APIHandler) RegisterQueryRangeV3Routes(router *mux.Router, am *middlew
|
||||
|
||||
subRouter.HandleFunc("/filter_suggestions", am.ViewAccess(aH.getQueryBuilderSuggestions)).Methods(http.MethodGet)
|
||||
|
||||
// TODO(Raj): Remove this handler after /ws based path has been completely rolled out.
|
||||
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
|
||||
|
||||
// live logs
|
||||
subRouter.HandleFunc("/logs/livetail", am.ViewAccess(aH.Signoz.Handlers.QuerierHandler.QueryRawStream)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterWebSocketPaths(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/ws").Subrouter()
|
||||
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterQueryRangeV4Routes(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/api/v4").Subrouter()
|
||||
subRouter.HandleFunc("/query_range", am.ViewAccess(aH.QueryRangeV4)).Methods(http.MethodPost)
|
||||
@@ -3545,27 +3527,6 @@ func (aH *APIHandler) queryRangeV3(ctx context.Context, queryRangeParams *v3.Que
|
||||
}
|
||||
}
|
||||
|
||||
// Hook up query progress tracking if requested
|
||||
queryIdHeader := r.Header.Get("X-SIGNOZ-QUERY-ID")
|
||||
if len(queryIdHeader) > 0 {
|
||||
onQueryFinished, apiErr := aH.reader.ReportQueryStartForProgressTracking(queryIdHeader)
|
||||
|
||||
if apiErr != nil {
|
||||
aH.logger.ErrorContext(ctx, "failed to report query start for progress tracking",
|
||||
"query_id", queryIdHeader, errors.Attr(apiErr),
|
||||
)
|
||||
|
||||
} else {
|
||||
// Adding queryId to the context signals clickhouse queries to report progress
|
||||
//lint:ignore SA1029 ignore for now
|
||||
ctx = context.WithValue(ctx, "queryId", queryIdHeader)
|
||||
|
||||
defer func() {
|
||||
go onQueryFinished()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRange",
|
||||
@@ -3747,73 +3708,6 @@ func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {
|
||||
aH.queryRangeV3(r.Context(), queryRangeParams, w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) GetQueryProgressUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade connection to websocket, sending back the requested protocol
|
||||
// value for sec-websocket-protocol
|
||||
//
|
||||
// Since js websocket API doesn't allow setting headers, this header is often
|
||||
// used for passing auth tokens. As per websocket spec the connection will only
|
||||
// succeed if the requested `Sec-Websocket-Protocol` is sent back as a header
|
||||
// in the upgrade response (signifying that the protocol is supported by the server).
|
||||
upgradeResponseHeaders := http.Header{}
|
||||
requestedProtocol := r.Header.Get("Sec-WebSocket-Protocol")
|
||||
if len(requestedProtocol) > 0 {
|
||||
upgradeResponseHeaders.Add("Sec-WebSocket-Protocol", requestedProtocol)
|
||||
}
|
||||
|
||||
c, err := aH.Upgrader.Upgrade(w, r, upgradeResponseHeaders)
|
||||
if err != nil {
|
||||
RespondError(w, model.InternalError(fmt.Errorf(
|
||||
"couldn't upgrade connection: %w", err,
|
||||
)), nil)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Websocket upgrade complete. Subscribe to query progress and send updates to client
|
||||
//
|
||||
// Note: we handle any subscription problems (queryId query param missing or query already complete etc)
|
||||
// after the websocket connection upgrade by closing the channel.
|
||||
// The other option would be to handle the errors before websocket upgrade by sending an
|
||||
// error response instead of the upgrade response, but that leads to a generic websocket
|
||||
// connection failure on the client.
|
||||
|
||||
queryId := r.URL.Query().Get("q")
|
||||
|
||||
progressCh, unsubscribe, apiErr := aH.reader.SubscribeToQueryProgress(queryId)
|
||||
if apiErr != nil {
|
||||
// Shouldn't happen unless query progress requested after query finished
|
||||
aH.logger.WarnContext(r.Context(), "failed to subscribe to query progress",
|
||||
"query_id", queryId, errors.Attr(apiErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer func() { go unsubscribe() }()
|
||||
|
||||
for queryProgress := range progressCh {
|
||||
msg, err := json.Marshal(queryProgress)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "failed to serialize progress message",
|
||||
"query_id", queryId, "progress", queryProgress, errors.Attr(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
err = c.WriteMessage(websocket.TextMessage, msg)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "failed to write progress message to websocket",
|
||||
"query_id", queryId, "msg", string(msg), errors.Attr(err),
|
||||
)
|
||||
break
|
||||
|
||||
} else {
|
||||
aH.logger.DebugContext(r.Context(), "wrote progress message to websocket",
|
||||
"query_id", queryId, "msg", string(msg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getMetricMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -164,7 +164,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
api.RegisterLogsRoutes(r, am)
|
||||
api.RegisterIntegrationRoutes(r, am)
|
||||
api.RegisterQueryRangeV3Routes(r, am)
|
||||
api.RegisterWebSocketPaths(r, am)
|
||||
api.RegisterQueryRangeV4Routes(r, am)
|
||||
api.RegisterMessagingQueuesRoutes(r, am)
|
||||
api.RegisterThirdPartyApiRoutes(r, am)
|
||||
@@ -178,7 +177,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
@@ -91,10 +91,6 @@ type Reader interface {
|
||||
|
||||
GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error)
|
||||
|
||||
// Query Progress tracking helpers.
|
||||
ReportQueryStartForProgressTracking(queryId string) (reportQueryFinished func(), apiErr *model.ApiError)
|
||||
SubscribeToQueryProgress(queryId string) (<-chan model.QueryProgress, func(), *model.ApiError)
|
||||
|
||||
//trace
|
||||
GetTraceFields(ctx context.Context) (*model.GetFieldsResponse, *model.ApiError)
|
||||
UpdateTraceField(ctx context.Context, field *model.UpdateField) *model.ApiError
|
||||
|
||||
@@ -7,14 +7,6 @@ import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
)
|
||||
|
||||
type QueryProgress struct {
|
||||
ReadRows uint64 `json:"read_rows"`
|
||||
|
||||
ReadBytes uint64 `json:"read_bytes"`
|
||||
|
||||
ElapsedMs uint64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func GetLogFieldsV3(ctx context.Context, queryRangeParams *v3.QueryRangeParamsV3, fields *GetFieldsResponse) map[string]v3.AttributeKey {
|
||||
data := map[string]v3.AttributeKey{}
|
||||
for _, query := range queryRangeParams.CompositeQuery.BuilderQueries {
|
||||
|
||||
@@ -70,9 +70,9 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
@@ -86,8 +86,8 @@ require (
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.83.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user