mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-27 14:50:46 +01:00
Compare commits
10 Commits
issue_5947
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bfe2dadff | ||
|
|
7b6b41a20e | ||
|
|
f8310797b6 | ||
|
|
a317af08ba | ||
|
|
8e5f0b3e75 | ||
|
|
6dfa8fd985 | ||
|
|
ec28feabaf | ||
|
|
eb0b3eb540 | ||
|
|
97b50d67e1 | ||
|
|
899a0dc366 |
@@ -20,16 +20,6 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
|
||||
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
|
||||
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
|
||||
```ts
|
||||
// Alert list tests - need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// Alert history tests - need evaluated history rows
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
|
||||
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
|
||||
|
||||
@@ -49,7 +49,6 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
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**: `src/pages/<Page>/<Page>.stories.tsx`,
|
||||
`src/pages/<Page>/<Page>.stories.mocks.tsx`, payload builders in
|
||||
`src/pages/<Page>/__story_mockdata__/<page>.ts`. Nothing page-specific in
|
||||
`src/storybook/controls/`.
|
||||
- **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
|
||||
167
.claude/skills/signoz-page-story/references/controls.md
Normal file
167
.claude/skills/signoz-page-story/references/controls.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Turning the inventory into controls
|
||||
|
||||
Every row of the inventory becomes a control, a global control that already
|
||||
exists, or a documented reason it cannot be one.
|
||||
|
||||
## Imports
|
||||
|
||||
Paths written as `src/storybook/...` in prose are repo paths, not import
|
||||
specifiers. Stories import through the `@/` alias (`@/*` → `./src/*`); modules
|
||||
inside `src/storybook/` import each other relatively.
|
||||
|
||||
| Import | From |
|
||||
| --- | --- |
|
||||
| `toggleControl`, `countControl`, `choiceControl`, `multiChoiceControl` | `../controls/controls` |
|
||||
| `defineStoryMocks`, `storyMocks` | `../controls/defineStoryMocks` |
|
||||
| `PageStoryArgs` | `../controls/resolveStoryMocks` |
|
||||
| `MockRequest`, `MockResponse` | `../controls/types` |
|
||||
| `withAppLayout` | `@/storybook/decorators/withAppLayout` |
|
||||
| the page's mocks, from the story | `./<Page>.stories.mocks` |
|
||||
| `queryRangeV5ScalarResponse`, `queryRangeV5RawResponse`, etc. | `@/storybook/msw/__story_mockdata__/queryRange` |
|
||||
|
||||
## Which builder
|
||||
|
||||
`src/storybook/controls/controls.ts`:
|
||||
|
||||
| The state is | Builder |
|
||||
| --- | --- |
|
||||
| on or off (a signal ingesting, a feature present) | `toggleControl` |
|
||||
| how many rows a list has | `countControl` |
|
||||
| one of several modes (tab, visibility, plan, severity filter) | `choiceControl` |
|
||||
| a subset (steps skipped, columns shown, signals selected) | `multiChoiceControl` |
|
||||
|
||||
Rules that come with them:
|
||||
|
||||
- `countControl` `max` goes past what the page renders, so a story can show the
|
||||
cap being hit. `0` is the empty state, which is why an empty list rarely needs
|
||||
its own story. When the cap is in the *request* (`?limit=5`) rather than the
|
||||
renderer, stop `max` at the limit: a longer response is a body the backend
|
||||
cannot send.
|
||||
- `choiceControl` options come from a `const` array typed with
|
||||
`(typeof X)[number]`, not from string literals scattered in the handlers.
|
||||
- Defaults describe the fully-populated page. The panel starts where `Default`
|
||||
starts.
|
||||
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
|
||||
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
|
||||
control.
|
||||
- `description` only when the name does not carry the effect (what dismissing
|
||||
does, what the cap is, which widget it feeds).
|
||||
|
||||
## Which hook
|
||||
|
||||
`defineStoryMocks` takes three, all optional:
|
||||
|
||||
- `handlers(values, response)`: the page's endpoints. Everything the page owns
|
||||
goes through `response.json`, so the global Data control turns the whole page
|
||||
into loading or failed without a second declaration. An endpoint the page
|
||||
cannot render at all without (ingestion detection, preferences, license
|
||||
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
|
||||
visible while the rest hangs or fails.
|
||||
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
|
||||
`appContext`, `reduxState`, `queryBuilder`, `theme`.
|
||||
- `effect(values)`: module-level state no provider exposes.
|
||||
|
||||
One endpoint feeding several widgets stays one handler that reads the request.
|
||||
`response.json` hands the request to the builder and awaits it, so reading a
|
||||
query param, or a POST body, does not cost the Data control:
|
||||
|
||||
```ts
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) =>
|
||||
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
```ts
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const signal = body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
return countResponse(values[`${signal}Ingestion`] ? 4213 : 0);
|
||||
}),
|
||||
),
|
||||
```
|
||||
|
||||
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
|
||||
endpoint has to keep answering while the Data control is on `loading` or
|
||||
`error`: detection calls the page cannot render without.
|
||||
|
||||
## Mutations
|
||||
|
||||
A control drives the response, so a write the page makes against state a control
|
||||
owns does not stick: the refetch answers with the control's value and the button
|
||||
appears to do nothing. Two honest options: leave it declarative and say so in
|
||||
the PR, or move the state into `effect` so the handler can read what the page
|
||||
wrote. Never fake the write by mutating a builder's module state without saying
|
||||
where the state lives.
|
||||
|
||||
## Wiring it up
|
||||
|
||||
```ts
|
||||
// src/pages/Services/Services.stories.mocks.tsx
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
export const servicesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
services: countControl('Services', { group: LISTS, value: 8, max: 12 }),
|
||||
apdex: choiceControl<ApdexState>('Apdex', {
|
||||
group: HEALTH,
|
||||
options: APDEX_STATES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => buildServices(values.services, values.apdex)),
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/Services/Services.stories.tsx
|
||||
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Services',
|
||||
component: Services,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION }),
|
||||
} satisfies Meta<ServicesArgs>;
|
||||
```
|
||||
|
||||
`PageStoryArgs` folds in the global controls, so a story's `args` can set
|
||||
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
|
||||
|
||||
## Not a control
|
||||
|
||||
- Anything the global controls already cover: banner, side nav, data state,
|
||||
access preset, permissions, check state.
|
||||
- A knob whose effect nobody can see on the page. Delete it or find the widget it
|
||||
was supposed to drive.
|
||||
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
|
||||
`viewer`), and the builder turns intent into the payload.
|
||||
- Anything that needs a module mock or a component prop to work. If the state
|
||||
cannot be produced from a response, config or module state, say so in the PR
|
||||
instead of faking it.
|
||||
|
||||
## Control or story
|
||||
|
||||
Default to a control. Write a story when the state is worth a link:
|
||||
|
||||
- the fresh workspace, because that is what a new user sees
|
||||
- the restricted user, when permissions visibly change the page
|
||||
- a page-defining mode (a tab, a category) that has its own layout
|
||||
|
||||
Combinations of controls do not need stories, which is what the panel is for.
|
||||
|
||||
Each story gets one prose doc comment: what it shows, in the page's own terms.
|
||||
Everywhere else the comment rule in SKILL.md applies: write one only for what
|
||||
the code cannot show.
|
||||
64
.claude/skills/signoz-page-story/references/discovery.md
Normal file
64
.claude/skills/signoz-page-story/references/discovery.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Mapping a page
|
||||
|
||||
Two passes: read the code, then let the running story correct you. Write the
|
||||
inventory down: it is what the controls are derived from, and the only
|
||||
protection against a story that renders one state and calls it a page.
|
||||
|
||||
## Pass 1: read the page
|
||||
|
||||
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts
|
||||
(`src/container/<Feature>/`), the hooks those use, the components with their own
|
||||
fetches. Stop at leaf components that take props only.
|
||||
|
||||
Grep recipes, run against the page's directories:
|
||||
|
||||
| Looking for | Grep |
|
||||
| --- | --- |
|
||||
| endpoints | `useQuery\|useMutation\|useInfiniteQuery`, then the `api/` module it calls |
|
||||
| endpoint URLs | the api module's `axios.get\|post` |
|
||||
| endpoint URLs behind a generated hook | the hook lives in `src/api/generated/services/<name>/index.ts` and the URL only appears in the fetcher body: `rg 'url: \`' src/api/generated/services/<name>/` |
|
||||
| url state | `useUrlQuery\|useUrlQueryData\|useUrlSearchState\|useQueryState\|QueryParams\.` |
|
||||
| navigation | `useSafeNavigate\|history.push\|<Link` |
|
||||
| permissions | `useAuthZ\|AuthZGuard\|AuthZButton\|hasEditPermission\|routePermission` |
|
||||
| flags and prefs | `useFeatureFlag\|FeatureKeys\.\|USER_PREFERENCES\.\|userPreferences` |
|
||||
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
|
||||
| render caps | `slice(0,\|PAGE_SIZE\|pageSize\|limit` |
|
||||
|
||||
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names,
|
||||
`src/lib/authz/README.md` how a permission check resolves.
|
||||
|
||||
## The inventory
|
||||
|
||||
One table, in the story's PR or scratch notes:
|
||||
|
||||
| Endpoint | Feeds | States it can be in |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/x` | the header count | populated, zero, error |
|
||||
|
||||
Plus four short lists:
|
||||
|
||||
- **Query params** the page reads, and what each one switches.
|
||||
- **Permission checks** the page makes, and what disappears when each is denied.
|
||||
- **Preferences and flags** that change layout (dismissed banners, onboarding
|
||||
checklists, opt-in views).
|
||||
- **Caps**: how many rows each list renders before it truncates or paginates.
|
||||
|
||||
A state that appears in this inventory and not in the controls panel is a bug in
|
||||
the story.
|
||||
|
||||
## Pass 2: let it run
|
||||
|
||||
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the
|
||||
route, add `withAppLayout`, then open it (see verify.md). The console is the
|
||||
oracle:
|
||||
|
||||
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1
|
||||
missed. Add it to the inventory.
|
||||
- an msw unhandled-request warning: a request going to an origin the handlers do
|
||||
not answer on. handlers are declared against `http://localhost`.
|
||||
- a spinner that never resolves with the Data control on `loaded`: a handler
|
||||
whose URL does not match what the page calls.
|
||||
- the navigation overlay on mount: the page redirects, usually because `route`
|
||||
is wrong or a guard is failing on a permission the controls have not granted.
|
||||
|
||||
Repeat until the console is silent. Only then start declaring controls.
|
||||
110
.claude/skills/signoz-page-story/references/verify.md
Normal file
110
.claude/skills/signoz-page-story/references/verify.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Verifying a page story
|
||||
|
||||
A story is not done because it compiles. It is done when each control has been
|
||||
seen changing the page and the console is silent.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm storybook --ci --quiet # :6006, background it
|
||||
```
|
||||
|
||||
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
|
||||
already-running server; an empty first poll is not a broken `stories` glob.
|
||||
|
||||
Story ids come from the meta title: `Pages/Services` → `pages-services`, plus the
|
||||
story export in kebab-case. Render one story on its own:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
|
||||
```
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
Args are settable in the iframe URL, so a whole sweep runs headless without
|
||||
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
|
||||
indexed, several separated by `;`, and the theme through `globals`:
|
||||
|
||||
```
|
||||
&args=services:0;apdex:poor;access:viewer;dataState:loading
|
||||
&args=signals[0]:logs;signals[1]:traces
|
||||
&globals=theme:light
|
||||
```
|
||||
|
||||
That is the cheap way to check a control does something: load with and without
|
||||
it, diff the page text.
|
||||
|
||||
## Drive it
|
||||
|
||||
Playwright lives in the repo's e2e workspace, so a scratch script can use it
|
||||
directly:
|
||||
|
||||
```js
|
||||
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
|
||||
const { chromium } = pw;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error' || m.type() === 'warning') problems.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => problems.push(e.message));
|
||||
|
||||
await page.goto(`${story}&args=services:0`, { waitUntil: 'networkidle' });
|
||||
await page.locator('body').waitFor();
|
||||
console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
Screenshots are worth taking for `Default` in both themes
|
||||
(`&globals=theme:light`): text extraction does not catch an unstyled page.
|
||||
|
||||
## Gates
|
||||
|
||||
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
|
||||
msw unhandled-request warning, a React key or state warning: assume the story
|
||||
is wrong first. A warning that survives is sometimes the app's. Prove it by
|
||||
turning off the control that renders the widget and watching the warning go
|
||||
with it, and by finding the same component elsewhere doing it right. Then
|
||||
report the app bug in the PR. Never invent a field the API does not return to
|
||||
silence a warning.
|
||||
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
|
||||
means the page is trying to leave: wrong `route`, or a guard denying on a
|
||||
permission the controls did not grant.
|
||||
- **Every control moves something.** Sweep them one at a time from the URL and
|
||||
diff the page text. A control with no diff is either wired to nothing or aimed
|
||||
at a widget that is not rendering. Some only show their effect after an
|
||||
interaction, such as a tab that has to be clicked or a select that has to be
|
||||
opened. Drive that interaction rather than calling the control unobservable.
|
||||
- **Both themes render styled.** An unstyled page means the story is not inside
|
||||
the provider decorator, or `<body data-theme>` was lost.
|
||||
- **Roles agree.** `<body data-signoz-story-role>` and
|
||||
`<body data-signoz-context-role>` disagreeing means the page reads a different
|
||||
`AppContext` than the story config fills.
|
||||
- **The page's own navigation works.** Tabs, filters and pagination that write
|
||||
query params should re-render the page in place; only leaving the page belongs
|
||||
in the overlay.
|
||||
|
||||
## Then the usual
|
||||
|
||||
```bash
|
||||
pnpm tsgo --noEmit
|
||||
pnpm exec oxlint <changed files>
|
||||
pnpm exec oxfmt --check <changed files>
|
||||
```
|
||||
|
||||
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
|
||||
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
|
||||
not one.
|
||||
|
||||
## Common failures
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| endless spinner with Data on `loaded` | handler URL does not match the call; handlers answer on `http://localhost` |
|
||||
| page renders but empty | response shape wrong; compare against the api module's type, not a guess |
|
||||
| 501 in the console | endpoint nobody mocked; the catch-all is answering |
|
||||
| new control missing from the panel | project-level control added; the tab needs a reload |
|
||||
| control flips but nothing changes | the widget is gated by something else: a permission, a flag, a preference |
|
||||
| shell disappears in `loading` | an endpoint the shell needs went through `response.json`; give it a plain resolver |
|
||||
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
|
||||
|
||||
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -50,7 +50,6 @@ jobs:
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- quickfilter
|
||||
- querierlogs
|
||||
- queriertraces
|
||||
- queriermetrics
|
||||
@@ -63,7 +62,6 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
|
||||
@@ -7382,26 +7382,6 @@ components:
|
||||
- custom
|
||||
- text
|
||||
type: string
|
||||
QuickfiltertypesSignalFilters:
|
||||
properties:
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
type: string
|
||||
type: object
|
||||
QuickfiltertypesUpdatableQuickFilters:
|
||||
properties:
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
type: string
|
||||
type: object
|
||||
RenderErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
@@ -8030,7 +8010,6 @@ components:
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
- ai_observability
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
@@ -18197,165 +18176,6 @@ paths:
|
||||
summary: Update my organization
|
||||
tags:
|
||||
- orgs
|
||||
/api/v2/orgs/me/filters:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for every signal, each filter as
|
||||
a telemetry field key.
|
||||
operationId: ListQuickFilters
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:list
|
||||
- tokenizer:
|
||||
- quick-filter:list
|
||||
summary: List quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces the org's quick filters for the signal named in the body.
|
||||
operationId: UpdateQuickFilters
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/QuickfiltertypesUpdatableQuickFilters'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:update
|
||||
- tokenizer:
|
||||
- quick-filter:update
|
||||
summary: Update quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/orgs/me/filters/{signal_name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for one signal, each filter as
|
||||
a telemetry field key.
|
||||
operationId: GetSignalQuickFilters
|
||||
parameters:
|
||||
- in: path
|
||||
name: signal_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:read
|
||||
- tokenizer:
|
||||
- quick-filter:read
|
||||
summary: Get a signal's quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -112,41 +112,6 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
**Fixture scopes:**
|
||||
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
|
||||
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
|
||||
|
||||
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
|
||||
|
||||
```
|
||||
fixtures/alerts/
|
||||
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
|
||||
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
|
||||
```
|
||||
|
||||
Specs import from the fixture they need:
|
||||
|
||||
```ts
|
||||
// List tests — just need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// History tests — need history rows from ruler evaluation
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
|
||||
**When creating new fixtures:**
|
||||
|
||||
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
|
||||
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
|
||||
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
|
||||
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
|
||||
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
|
||||
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
@@ -267,14 +232,11 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -308,14 +270,7 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
6
frontend/.gitignore
vendored
6
frontend/.gitignore
vendored
@@ -28,4 +28,8 @@ e2e/test-plan/saved-views/
|
||||
e2e/test-plan/service-map/
|
||||
e2e/test-plan/services/
|
||||
e2e/test-plan/traces/
|
||||
e2e/test-plan/user-preferences/
|
||||
e2e/test-plan/user-preferences/
|
||||
|
||||
# Storybook
|
||||
/storybook-static/
|
||||
debug-storybook.log
|
||||
|
||||
89
frontend/.storybook/main.ts
Normal file
89
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import type { Plugin, PluginOption } from 'vite';
|
||||
|
||||
const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
||||
|
||||
/**
|
||||
* Modules replaced for every story. Same idea as `moduleNameMapper` in
|
||||
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
|
||||
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
|
||||
* not catch `lib/historyUtils`.
|
||||
*
|
||||
* Each replacement is typed as the module it stands in for, so drift is a
|
||||
* compile error rather than a story that fails at render. The `jest` note on
|
||||
* each entry is where the same import lands under the other runner. The two
|
||||
* only diverge where the runner needs them to.
|
||||
*/
|
||||
const mockAliases = [
|
||||
{
|
||||
// jest: not replaced, jsdom drives a real browser history.
|
||||
find: /^(?:src\/)?lib\/history$/,
|
||||
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
|
||||
},
|
||||
{
|
||||
// jest: src/__tests__/logEventMock.ts
|
||||
find: /^(?:src\/)?api\/common\/logEvent$/,
|
||||
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
|
||||
// resolves a relative `/api/...` against `http://localhost`.
|
||||
find: /^(?:src\/)?constants\/env$/,
|
||||
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Plugins from `vite.config.ts` that either target the app's `index.html` or
|
||||
* only pay off in a production build.
|
||||
*/
|
||||
const EXCLUDED_PLUGINS = [
|
||||
'vite-plugin-checker',
|
||||
'dev-base-path',
|
||||
'dev-boot-data',
|
||||
'vite-plugin-image-optimizer',
|
||||
'vite-plugin-compression',
|
||||
];
|
||||
|
||||
const isExcluded = (plugin: PluginOption): boolean =>
|
||||
!!plugin &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
EXCLUDED_PLUGINS.includes((plugin as Plugin).name);
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
// `../public` carries the fonts, icons and i18n bundles the app expects at
|
||||
// the root; `./public` carries the msw worker, which must not ship in a
|
||||
// production build.
|
||||
staticDirs: ['../public', './public'],
|
||||
addons: ['@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;
|
||||
24
frontend/.storybook/preview-head.html
Normal file
24
frontend/.storybook/preview-head.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
// i18n's language detector would pick the browser locale (e.g. `en-US`), which
|
||||
// /public/locales does not ship; pin it to the bundled language instead.
|
||||
window.localStorage.setItem('i18nextLng', 'en');
|
||||
|
||||
// The Go backend injects this at boot; every integration it enables is off in
|
||||
// Storybook so no third-party script loads inside the iframe.
|
||||
window.signozBootData = {
|
||||
settings: {
|
||||
posthog: { enabled: false, apiHost: '', key: '', uiHost: '' },
|
||||
appcues: { enabled: false, appId: '' },
|
||||
sentry: { enabled: false, dsn: '', tunnel: '' },
|
||||
pylon: { enabled: false, appId: '', identitySecret: '' },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
110
frontend/.storybook/preview.tsx
Normal file
110
frontend/.storybook/preview.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
import { withProviders } from '../src/storybook/decorators/withProviders';
|
||||
import { globalMocks } from '../src/storybook/globals';
|
||||
import { resetStoryHistory } from '../src/storybook/navigation/containment';
|
||||
import { clearBlockedNavigations } from '../src/storybook/navigation/blockedNavigationStore';
|
||||
import {
|
||||
resolveStory,
|
||||
type StoryRuntimeContext,
|
||||
} from '../src/storybook/runtime/resolveStory';
|
||||
|
||||
import '../src/ReactI18';
|
||||
|
||||
import '../src/styles.scss';
|
||||
|
||||
import '../src/storybook/storybook-root.scss';
|
||||
|
||||
interface StorybookWorkerHolder {
|
||||
__signozStorybookWorker?: StorybookWorker;
|
||||
}
|
||||
|
||||
const holder = window as unknown as StorybookWorkerHolder;
|
||||
|
||||
/**
|
||||
* One worker per page, even if this module is re-executed by HMR. Two live
|
||||
* workers both answer the service worker and the story gets whichever replies
|
||||
* first.
|
||||
*/
|
||||
interface StorybookWorker {
|
||||
worker: SetupWorker;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
|
||||
const { worker, ready } = (holder.__signozStorybookWorker ??=
|
||||
((): StorybookWorker => {
|
||||
const instance = setupWorker();
|
||||
|
||||
return {
|
||||
worker: instance,
|
||||
ready: instance.start({
|
||||
serviceWorker: { url: './mockServiceWorker.js' },
|
||||
// Storybook's own traffic (index.json, HMR, telemetry) goes unhandled by
|
||||
// design; only flag the app's API calls so a missing handler is obvious.
|
||||
onUnhandledRequest: (request, print): void => {
|
||||
const url = new URL(request.url.href);
|
||||
const isStaticAsset =
|
||||
/\.(?:woff2?|ttf|otf|css|js|map|png|jpe?g|svg|webp|ico)$/.test(
|
||||
url.pathname,
|
||||
);
|
||||
const isAppRequest =
|
||||
!isStaticAsset &&
|
||||
(url.pathname.startsWith('/api/') || url.host !== window.location.host);
|
||||
|
||||
if (isAppRequest) {
|
||||
print.warning();
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
})());
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { expanded: true },
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'SigNoz color scheme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
items: [
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'light', title: 'Light' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: { theme: 'dark' },
|
||||
// Controls every story carries: permissions, banners, and whether the page's
|
||||
// own endpoints answer, hang or fail.
|
||||
args: globalMocks.args,
|
||||
argTypes: globalMocks.argTypes,
|
||||
decorators: [withProviders],
|
||||
loaders: [
|
||||
// Runs on every render, args changes included, and ahead of the decorators:
|
||||
// the whole story world is put in place here, so the provider tree only has
|
||||
// to read it. Re-registering the handlers per render also means an edit to a
|
||||
// handler module takes effect on the next render instead of leaving the
|
||||
// worker on the set it was created with.
|
||||
async (context): Promise<void> => {
|
||||
const world = resolveStory(context as unknown as StoryRuntimeContext);
|
||||
|
||||
world.apply();
|
||||
world.install(worker);
|
||||
|
||||
await ready;
|
||||
},
|
||||
],
|
||||
beforeEach: () => {
|
||||
clearBlockedNavigations();
|
||||
resetStoryHistory();
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
303
frontend/.storybook/public/mockServiceWorker.js
Normal file
303
frontend/.storybook/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (1.3.2).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = Math.random().toString(16).slice(2)
|
||||
|
||||
event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn(
|
||||
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const clonedResponse = response.clone()
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body:
|
||||
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
const clonedRequest = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||
|
||||
// Remove MSW-specific request headers so the bypassed requests
|
||||
// comply with the server's CORS preflight check.
|
||||
// Operate with the headers as an object because request "Headers"
|
||||
// are immutable.
|
||||
delete headers['x-msw-bypass']
|
||||
|
||||
return fetch(clonedRequest, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.text(),
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
})
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.data
|
||||
const networkError = new Error(message)
|
||||
networkError.name = name
|
||||
|
||||
// Rejecting a "respondWith" promise emulates a network error.
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(timeMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, timeMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
await sleep(response.delay)
|
||||
return new Response(response.body, response)
|
||||
}
|
||||
@@ -88,6 +88,17 @@ pnpm test
|
||||
pnpm tsgo --noEmit
|
||||
```
|
||||
|
||||
## Storybook
|
||||
|
||||
```bash
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
Opens [http://localhost:6006](http://localhost:6006). Pages run against msw
|
||||
mocks with no backend; query-param navigation works inside a story, leaving the
|
||||
page is blocked. See [`src/storybook/README.md`](src/storybook/README.md) for the
|
||||
override surface.
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
|
||||
"dev": "vite",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build -o storybook-static",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -158,6 +160,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
@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
// anything else
|
||||
console.error('ErrorResponseHandler: unclassified error');
|
||||
console.error('any');
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetSignalQuickFilters200,
|
||||
GetSignalQuickFiltersPathParameters,
|
||||
ListQuickFilters200,
|
||||
QuickfiltertypesUpdatableQuickFiltersDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns the org's quick filters for every signal, each filter as a telemetry field key.
|
||||
* @summary List quick filters
|
||||
*/
|
||||
export const listQuickFilters = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListQuickFilters200>({
|
||||
url: `/api/v2/orgs/me/filters`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListQuickFiltersQueryKey = () => {
|
||||
return [`/api/v2/orgs/me/filters`] as const;
|
||||
};
|
||||
|
||||
export const getListQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListQuickFiltersQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listQuickFilters>>> = ({
|
||||
signal,
|
||||
}) => listQuickFilters(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>
|
||||
>;
|
||||
export type ListQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List quick filters
|
||||
*/
|
||||
|
||||
export function useListQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListQuickFiltersQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List quick filters
|
||||
*/
|
||||
export const invalidateListQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListQuickFiltersQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the org's quick filters for the signal named in the body.
|
||||
* @summary Update quick filters
|
||||
*/
|
||||
export const updateQuickFilters = (
|
||||
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/orgs/me/filters`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: quickfiltertypesUpdatableQuickFiltersDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateQuickFiltersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateQuickFilters'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateQuickFilters(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateQuickFiltersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>
|
||||
>;
|
||||
export type UpdateQuickFiltersMutationBody =
|
||||
| BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>
|
||||
| undefined;
|
||||
export type UpdateQuickFiltersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update quick filters
|
||||
*/
|
||||
export const useUpdateQuickFilters = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateQuickFiltersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the org's quick filters for one signal, each filter as a telemetry field key.
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const getSignalQuickFilters = (
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSignalQuickFilters200>({
|
||||
url: `/api/v2/orgs/me/filters/${signalName}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSignalQuickFiltersQueryKey = ({
|
||||
signalName,
|
||||
}: GetSignalQuickFiltersPathParameters) => {
|
||||
return [`/api/v2/orgs/me/filters/${signalName}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSignalQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetSignalQuickFiltersQueryKey({ signalName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>
|
||||
> = ({ signal }) => getSignalQuickFilters({ signalName }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!signalName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSignalQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>
|
||||
>;
|
||||
export type GetSignalQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
|
||||
export function useGetSignalQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSignalQuickFiltersQueryOptions(
|
||||
{ signalName },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const invalidateGetSignalQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSignalQuickFiltersQueryKey({ signalName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -8435,28 +8435,6 @@ export enum Querybuildertypesv5QueryTypeDTO {
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
promql = 'promql',
|
||||
}
|
||||
export interface QuickfiltertypesSignalFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface RenderErrorResponseDTO {
|
||||
error: ErrorsJSONDTO;
|
||||
/**
|
||||
@@ -9043,7 +9021,6 @@ export enum SavedviewtypesSourceDTO {
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
ai_observability = 'ai_observability',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
@@ -11811,28 +11788,6 @@ export type GetMyOrganization200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListQuickFilters200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: QuickfiltertypesSignalFiltersDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSignalQuickFiltersPathParameters = {
|
||||
signalName: string;
|
||||
};
|
||||
export type GetSignalQuickFilters200 = {
|
||||
data: QuickfiltertypesSignalFiltersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -8,14 +8,12 @@ export interface AlertBreadcrumbProps {
|
||||
items: BreadcrumbItemConfig[];
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
showDivider = true,
|
||||
testId,
|
||||
}: AlertBreadcrumbProps): JSX.Element {
|
||||
const breadcrumbItems = items.map((item) => ({
|
||||
title: <BreadcrumbItem {...item} />,
|
||||
@@ -26,7 +24,6 @@ function AlertBreadcrumb({
|
||||
<Breadcrumb
|
||||
className={`${styles.breadcrumb} ${className || ''}`}
|
||||
items={breadcrumbItems}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showDivider && <Divider className={styles.divider} />}
|
||||
</>
|
||||
|
||||
@@ -197,7 +197,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
key: buildCompositeKey(f.name, f.fieldContext),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
@@ -52,15 +52,13 @@ function OtherFields({
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}));
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) =>
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
),
|
||||
addedFields.map((f) => buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
const available = suggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
|
||||
@@ -14,10 +14,10 @@ jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
|
||||
}));
|
||||
|
||||
const field = (name: string, type = '', dataType = ''): IField => ({
|
||||
const field = (name: string, type = ''): IField => ({
|
||||
name,
|
||||
type,
|
||||
dataType,
|
||||
dataType: 'string',
|
||||
});
|
||||
|
||||
describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
@@ -136,24 +136,6 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
expect(byId.get('user_field')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('disambiguates same-name/same-context fields by dataType (3-part id)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [
|
||||
field('http.status_code', 'attribute', 'int64'),
|
||||
field('http.status_code', 'attribute', 'string'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'attribute:http.status_code:int64',
|
||||
'attribute:http.status_code:string',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders only the stateIndicator when fields is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
|
||||
@@ -92,7 +92,7 @@ export function useLogsTableColumns({
|
||||
};
|
||||
|
||||
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
|
||||
id: buildCompositeKey(f.name, f.type, f.dataType),
|
||||
id: buildCompositeKey(f.name, f.type),
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown =>
|
||||
getLogFieldValue(log, f.name, isBodyJsonEnabled),
|
||||
|
||||
@@ -11,7 +11,6 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -29,7 +29,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -41,7 +40,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,10 +26,7 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -41,10 +38,7 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -56,10 +50,7 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,12 +103,7 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -137,7 +123,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
<div className="count-label">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,11 +81,7 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,16 +48,11 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,10 +68,7 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
<div className="total-contribution">
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -81,10 +78,7 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,10 +31,7 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,10 +118,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -131,15 +128,12 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
<div className="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<div className="timeline-table__filter-search">
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -161,7 +155,6 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -179,17 +172,14 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<div className="timeline-table__error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
<div className="timeline-table__pagination-info">
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,14 +21,18 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<AlertState state={value} showLabel testId="timeline-row-state" />
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<AlertLabels labels={labels} testId="timeline-row-labels" />
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -36,10 +40,7 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
<div className="alert-rule__created-at">
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -52,7 +53,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost disabled>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -64,7 +65,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,17 +57,14 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
--button-font-size: var(--periscope-font-size-base, 13px);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Select } from 'antd';
|
||||
import { Button, Flex, Select } from 'antd';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS, Pagination } from 'hooks/queryPagination';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { defaultSelectStyle } from './config';
|
||||
import styles from './Controls.module.scss';
|
||||
import { Container } from './styles';
|
||||
|
||||
function Controls({
|
||||
offset = 0,
|
||||
@@ -35,24 +34,28 @@ function Controls({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Container>
|
||||
<Button
|
||||
variant="link"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={isPreviousDisabled}
|
||||
prefix={<ChevronLeft size={16} />}
|
||||
onClick={handleNavigatePrevious}
|
||||
>
|
||||
Previous
|
||||
<Flex align="center" gap="4px">
|
||||
<ChevronLeft size={16} /> Previous
|
||||
</Flex>
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={isNextDisabled}
|
||||
suffix={<ChevronRight size={16} />}
|
||||
onClick={handleNavigateNext}
|
||||
>
|
||||
Next
|
||||
<Flex align="center" gap="4px">
|
||||
Next <ChevronRight size={16} />
|
||||
</Flex>
|
||||
</Button>
|
||||
|
||||
{showSizeChanger && (
|
||||
@@ -71,7 +74,7 @@ function Controls({
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
7
frontend/src/container/Controls/styles.ts
Normal file
7
frontend/src/container/Controls/styles.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
@@ -34,7 +34,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -67,7 +66,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,6 @@ function EvaluationWindowPopover({
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
data-testid={`${sectionId}-option-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -186,7 +186,6 @@ function Footer(): JSX.Element {
|
||||
color="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="save-alert-rule-button"
|
||||
>
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
|
||||
@@ -219,7 +218,6 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="test-notification-button"
|
||||
>
|
||||
{isTestingAlertRule ? (
|
||||
<Loader data-testid="test-notification-loader-icon" size={14} />
|
||||
@@ -251,7 +249,6 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -119,7 +119,6 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -148,7 +147,6 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -163,7 +161,6 @@ function BasicInfo({
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
|
||||
@@ -105,7 +105,7 @@ function QuerySection({
|
||||
{
|
||||
label: (
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Button className="nav-btns">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,11 +122,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -166,11 +162,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -188,11 +180,7 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,6 @@ function RuleOptions({
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
@@ -117,7 +116,6 @@ function RuleOptions({
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
@@ -179,7 +177,6 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -197,7 +194,6 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -399,7 +395,6 @@ function RuleOptions({
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -844,6 +844,8 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -966,7 +968,6 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -980,7 +981,6 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,7 +989,6 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -999,7 +998,6 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,7 +174,6 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-0);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
.trace-explorer-header {
|
||||
.trace-explorer-run-query {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
margin: 8px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-outlined-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-header.single-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.traces-explorer-views {
|
||||
padding: 8px;
|
||||
padding-bottom: 60px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-view-container {
|
||||
padding: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l2-background) !important;
|
||||
height: 34px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-list-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-traces-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-table-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-time-series-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import {
|
||||
tracesAddFilterAction,
|
||||
tracesChangeViewAction,
|
||||
tracesRunQueryAction,
|
||||
tracesSaveViewAction,
|
||||
} from 'pages/TracesExplorer/aiActions';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
import TracesView from './TracesView/TracesView';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
import styles from './Explorer.module.scss';
|
||||
|
||||
// Shell for the AI Observability Explorer tab. Owns the
|
||||
// /ai-observability/explorer route and is intentionally empty for now: the
|
||||
// query builder + results surface land in a follow-up.
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
handleSetConfig,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the active view unmounts when cancelled, so no
|
||||
// child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
);
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
const aiActions = useMemo(
|
||||
() =>
|
||||
isAIAssistantEnabled
|
||||
? [
|
||||
tracesRunQueryAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesAddFilterAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesChangeViewAction({
|
||||
onChangeView: (view) => handleChangeSelectedView(view as ExplorerViews),
|
||||
}),
|
||||
tracesSaveViewAction({
|
||||
// POC stub — logs a save request; wire to real API when available
|
||||
onSaveView: async (name) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[AI Assistant] Save view requested:', name);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
isAIAssistantEnabled,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
panelTypeParam,
|
||||
options,
|
||||
);
|
||||
|
||||
logEvent('Traces Explorer: Add to dashboard successful', {
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
},
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
logEvent('Traces Explorer: Page visited', {});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
// if any of the non-disabled queries has filters applied, return true
|
||||
const result = stagedQuery?.builder?.queryData?.filter(
|
||||
(item) => !isEmpty(item.filters?.items) && !item.disabled,
|
||||
);
|
||||
return !!result?.length;
|
||||
}, [stagedQuery]);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
leftActions={
|
||||
<LeftToolbarActions
|
||||
showFilter={isOpen}
|
||||
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
|
||||
items={TOOLBAR_VIEWS}
|
||||
selectedView={selectedView}
|
||||
onChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
}
|
||||
warningElement={
|
||||
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>
|
||||
<div className="query-section-container">
|
||||
<QuerySection />
|
||||
</div>
|
||||
</ExplorerCard>
|
||||
|
||||
<div className="traces-explorer-views">
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<div className="trace-explorer-list-view">
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<div className="trace-explorer-traces-view">
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<div className="trace-explorer-time-series-view">
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<div className="trace-explorer-table-view">
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
<div className={styles.explorer} data-testid="llm-observability-explorer">
|
||||
<div className={styles.placeholder}>Explorer coming soon.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
.trace-explorer-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.order-by-select {
|
||||
width: 100px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows, panelType]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
@@ -1,61 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(QuerySection);
|
||||
@@ -1,7 +0,0 @@
|
||||
.traces-table-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Space } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TableView.styles.scss';
|
||||
|
||||
function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap.traces,
|
||||
graphType: panelType || PANEL_TYPES.TABLE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableData = useMemo(
|
||||
() =>
|
||||
data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload.data.result ||
|
||||
[],
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
<div className="traces-table-view-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
<QueryTable
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
queryTableData={queryTableData as QueryDataV3[]}
|
||||
loading={isLoading}
|
||||
sticky
|
||||
/>
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
@@ -1,8 +0,0 @@
|
||||
.trace-explorer-time-series-view-container {
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TimeSeriesView.styles.scss';
|
||||
|
||||
function TimeSeriesViewContainer({
|
||||
dataSource = DataSource.TRACES,
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap[dataSource],
|
||||
graphType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
return (
|
||||
<div className="trace-explorer-time-series-view-container">
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimeSeriesViewProps {
|
||||
dataSource?: DataSource;
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
@@ -1,15 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
(responseData ?? []).map((item) => {
|
||||
const row = item.data;
|
||||
return { ...row, id: row.trace_id };
|
||||
}) as TracesTableRow[],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
@@ -1,25 +0,0 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
@@ -1,36 +0,0 @@
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
show: true,
|
||||
key: 'list',
|
||||
},
|
||||
timeseries: {
|
||||
name: 'timeseries',
|
||||
label: 'Timeseries',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'table',
|
||||
},
|
||||
clickhouse: {
|
||||
name: 'clickhouse',
|
||||
label: 'Clickhouse',
|
||||
disabled: false,
|
||||
show: false,
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
@@ -18,12 +18,6 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
|
||||
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
|
||||
}));
|
||||
|
||||
// Same data-router gap as the dashboard above: the Explorer toolbar calls useNavigationType.
|
||||
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
min-height: 200px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.ant-card-body {
|
||||
height: 200px;
|
||||
min-height: 200px;
|
||||
padding: 0 16px 16px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
.logs-frequency-chart-loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Graph from 'components/Graph';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getChartData, { GetChartDataProps } from 'lib/getChartData';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
|
||||
import { useLogsExplorerChartConfig } from './useLogsExplorerChartConfig';
|
||||
import { getColorsForSeverityLabels } from './utils';
|
||||
|
||||
import './LogsExplorerChart.styles.scss';
|
||||
|
||||
// Axis and tooltip format separately; both need this or only one abbreviates.
|
||||
const Y_AXIS_UNIT = 'short';
|
||||
|
||||
function LogsExplorerChart({
|
||||
data,
|
||||
isLoading,
|
||||
@@ -41,6 +37,24 @@ function LogsExplorerChart({
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] =
|
||||
useCallback(
|
||||
(element, index, allLabels) => ({
|
||||
data: element,
|
||||
backgroundColor: isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(allLabels[index], index)
|
||||
: colors[index % colors.length] || themeColors.red,
|
||||
borderColor: isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(allLabels[index], index)
|
||||
: colors[index % colors.length] || themeColors.red,
|
||||
...(isLabelEnabled
|
||||
? {
|
||||
label: allLabels[index],
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[isLabelEnabled, isLogsExplorerViews],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
@@ -72,47 +86,44 @@ function LogsExplorerChart({
|
||||
[dispatch, location.pathname, safeNavigate, urlQuery, isShowingLiveLogs],
|
||||
);
|
||||
|
||||
// uPlot plots the series on a seconds-based x scale
|
||||
const { minTimeScale, maxTimeScale } = useMemo(
|
||||
const graphData = useMemo(
|
||||
() =>
|
||||
getChartData({
|
||||
queryData: [
|
||||
{
|
||||
queryData: data,
|
||||
},
|
||||
],
|
||||
createDataset: handleCreateDatasets,
|
||||
}),
|
||||
[data, handleCreateDatasets],
|
||||
);
|
||||
|
||||
// Convert nanosecond timestamps to milliseconds for Chart.js
|
||||
const { chartMinTime, chartMaxTime } = useMemo(
|
||||
() => ({
|
||||
minTimeScale: minTime ? Math.floor(minTime / 1e9) : undefined,
|
||||
maxTimeScale: maxTime ? Math.floor(maxTime / 1e9) : undefined,
|
||||
chartMinTime: minTime ? Math.floor(minTime / 1e6) : undefined,
|
||||
chartMaxTime: maxTime ? Math.floor(maxTime / 1e6) : undefined,
|
||||
}),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { config, chartData } = useLogsExplorerChartConfig({
|
||||
data,
|
||||
isLogsExplorerViews,
|
||||
isLabelEnabled,
|
||||
onDragSelect,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit: Y_AXIS_UNIT,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={graphRef} className={`${className} logs-frequency-chart-container`}>
|
||||
<div className={`${className} logs-frequency-chart-container`}>
|
||||
{isLoading ? (
|
||||
<div className="logs-frequency-chart-loading">
|
||||
<Spinner size="default" height="100%" />
|
||||
</div>
|
||||
) : (
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
stack={isLogsExplorerViews ? StackMode.Normal : StackMode.None}
|
||||
showLegend={isLabelEnabled}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
timezone={timezone}
|
||||
data-testid="logs-frequency-chart"
|
||||
yAxisUnit={Y_AXIS_UNIT}
|
||||
<Graph
|
||||
name="logsExplorerChart"
|
||||
data={graphData.data}
|
||||
isStacked={isLogsExplorerViews}
|
||||
type="bar"
|
||||
animate
|
||||
onDragSelect={onDragSelect}
|
||||
minTime={chartMinTime}
|
||||
maxTime={chartMaxTime}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { getColorsForSeverityLabels } from './utils';
|
||||
|
||||
export interface UseLogsExplorerChartConfigParams {
|
||||
data: QueryData[];
|
||||
isLogsExplorerViews?: boolean;
|
||||
isLabelEnabled?: boolean;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
yAxisUnit?: string;
|
||||
}
|
||||
|
||||
export interface UseLogsExplorerChartConfigResult {
|
||||
config: UPlotConfigBuilder;
|
||||
chartData: uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export function useLogsExplorerChartConfig({
|
||||
data,
|
||||
isLogsExplorerViews = false,
|
||||
isLabelEnabled = true,
|
||||
onDragSelect,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
}: UseLogsExplorerChartConfigParams): UseLogsExplorerChartConfigResult {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// getUPlotChartData / buildBaseConfig both consume the legacy query-range payload
|
||||
// shape, so the raw series list is wrapped instead of being plotted directly.
|
||||
const apiResponse = useMemo(
|
||||
() =>
|
||||
({
|
||||
data: { result: data, resultType: '' },
|
||||
}) as unknown as MetricRangePayloadProps,
|
||||
[data],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => getUPlotChartData(apiResponse), [apiResponse]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
const builder = buildBaseConfig({
|
||||
id: 'logs-explorer-frequency-chart',
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
data.forEach((series, index) => {
|
||||
const label = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '',
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const color = isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(label, index)
|
||||
: colors[index % colors.length] || themeColors.red;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
// No group-by yields query name "A"; use ' ' not '' so uPlot does not default the label to "Value".
|
||||
label: isLabelEnabled && label.trim() ? label : ' ',
|
||||
lineColor: color,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}, [
|
||||
data,
|
||||
isDarkMode,
|
||||
isLabelEnabled,
|
||||
isLogsExplorerViews,
|
||||
maxTimeScale,
|
||||
minTimeScale,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
]);
|
||||
|
||||
return { config, chartData };
|
||||
}
|
||||
@@ -217,6 +217,13 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,9 +298,9 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'attribute:service.name:string',
|
||||
'log:body:string',
|
||||
'resource:service.name:string',
|
||||
'attribute:service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'log:timestamp',
|
||||
]);
|
||||
|
||||
@@ -331,9 +331,9 @@ describe('useOptionsMenu', () => {
|
||||
'state-indicator',
|
||||
'log:timestamp',
|
||||
'unknown.composite',
|
||||
'log:body:string',
|
||||
'resource:service.name:string',
|
||||
'attribute:service.name:string',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'attribute:service.name',
|
||||
]);
|
||||
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
@@ -360,7 +360,7 @@ describe('useOptionsMenu', () => {
|
||||
);
|
||||
|
||||
// Removing 'resource:service.name' should drop ONLY the resource variant.
|
||||
result.current.config.addColumn?.onRemove('resource:service.name:string');
|
||||
result.current.config.addColumn?.onRemove('resource:service.name');
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const remaining = mockUpdateColumns.mock.calls[0][0];
|
||||
|
||||
@@ -56,7 +56,7 @@ export function dedupeColumnsByCompositeKey(
|
||||
const seen = new Set<string>();
|
||||
let hasDuplicate = false;
|
||||
const deduped = columns.filter((c) => {
|
||||
const key = buildCompositeKey(c.name, c.fieldContext, c.fieldDataType);
|
||||
const key = buildCompositeKey(c.name, c.fieldContext);
|
||||
if (seen.has(key)) {
|
||||
hasDuplicate = true;
|
||||
return false;
|
||||
|
||||
@@ -281,8 +281,7 @@ const useOptionsMenu = ({
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
(f) =>
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
|
||||
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
@@ -365,10 +364,7 @@ const useOptionsMenu = ({
|
||||
(orderedIds: string[]): void => {
|
||||
const current = preferences?.columns ?? [];
|
||||
const byCompositeKey = new Map(
|
||||
current.map((f) => [
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
f,
|
||||
]),
|
||||
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
|
||||
);
|
||||
const reordered = orderedIds
|
||||
.map((id) => byCompositeKey.get(id))
|
||||
|
||||
@@ -15,11 +15,8 @@ export const getOptionsFromKeys = (
|
||||
);
|
||||
};
|
||||
|
||||
export const buildCompositeKey = (
|
||||
name: string,
|
||||
context?: string,
|
||||
dataType?: string,
|
||||
): string => {
|
||||
const withContext = context ? `${context}:${name}` : name;
|
||||
return dataType ? `${withContext}:${dataType}` : withContext;
|
||||
};
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource:service.name vs attribute:service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}:${name}` : name;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import ListView from './index';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
jest.mock('react-redux', () => {
|
||||
const actual = jest.requireActual('react-redux');
|
||||
return {
|
||||
...actual,
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown => {
|
||||
const result = actual.useSelector(selector);
|
||||
if (result && typeof result === 'object' && 'loading' in result) {
|
||||
return { ...result, loading: false };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the query fires and the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
options: {
|
||||
selectColumns: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name', fieldContext: 'span' },
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const listRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
http_method: 'GET',
|
||||
response_status_code: '200',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
name: 'authenticate_check_db',
|
||||
duration_nano: 790949390,
|
||||
// empty status fields to assert the "-" cell
|
||||
http_method: '',
|
||||
response_status_code: '',
|
||||
span_id: '5704353737b6778e',
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderListView = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<ListView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('Traces ListView - Data Loaded', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders backend rows in FieldCell format', async () => {
|
||||
mockSuccess();
|
||||
renderListView();
|
||||
|
||||
// plain-text columns
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
// http_method / response_status_code render as badges
|
||||
expect(screen.getAllByTestId('http_method')[0]).toHaveTextContent('GET');
|
||||
expect(screen.getAllByTestId('response_status_code')[0]).toHaveTextContent(
|
||||
'200',
|
||||
);
|
||||
|
||||
// empty status fields render "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -10,7 +8,6 @@ import {
|
||||
DURATION_FIELD_NAMES,
|
||||
STATUS_FIELD_NAMES,
|
||||
TIMESTAMP_FIELD_NAMES,
|
||||
TRACE_ID_FIELD_NAMES,
|
||||
} from './constants';
|
||||
import { stringifyCellValue } from './utils';
|
||||
|
||||
@@ -41,18 +38,6 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
|
||||
const text = stringifyCellValue(value);
|
||||
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (STATUS_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
|
||||
@@ -19,8 +19,7 @@ import styles from './TracesTable.module.scss';
|
||||
export type TracesTableProps = {
|
||||
data: TracesTableRow[];
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
columnStorageKey?: string;
|
||||
respectColumnOrder?: boolean;
|
||||
columnStorageKey: string;
|
||||
panelType: PanelTypeKeys;
|
||||
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
|
||||
getRowHref: (row: TracesTableRow) => string;
|
||||
@@ -38,7 +37,6 @@ function TracesTable({
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
respectColumnOrder = false,
|
||||
panelType,
|
||||
getRowHref,
|
||||
isLoading,
|
||||
@@ -90,7 +88,7 @@ function TracesTable({
|
||||
columns={columns}
|
||||
className={styles.tracesTable}
|
||||
columnStorageKey={columnStorageKey}
|
||||
respectColumnOrder={respectColumnOrder}
|
||||
respectColumnOrder={false}
|
||||
isLoading={isFetching}
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
@@ -106,8 +104,6 @@ function TracesTable({
|
||||
}
|
||||
|
||||
TracesTable.defaultProps = {
|
||||
columnStorageKey: undefined,
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
|
||||
@@ -5,14 +5,8 @@ export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'http.method',
|
||||
'http.request.method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
'http.status_code',
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
|
||||
@@ -10,11 +10,11 @@ export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext, fieldDataType } = field;
|
||||
const { name, fieldContext } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext, fieldDataType),
|
||||
id: buildCompositeKey(name, fieldContext),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row[name],
|
||||
enableMove: !isTimestamp,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,25 +1,50 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
import { ListItem } from 'types/api/widgets/getQuery';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
export const columns: ColumnsType<ListItem['data']> = [
|
||||
{
|
||||
title: 'Root Service Name',
|
||||
dataIndex: 'service.name',
|
||||
key: 'serviceName',
|
||||
},
|
||||
{
|
||||
title: 'Root Operation Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Root Duration (in ms)',
|
||||
dataIndex: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
render: (duration: number): JSX.Element => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import TracesView from './index';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const groupedRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
span_count: 8,
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
// intentionally empty to assert the "-" cell
|
||||
name: '',
|
||||
duration_nano: 790949390,
|
||||
span_count: 3,
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
const mockSuccess = (rows: unknown[] = groupedRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(groupedResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockError = (): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error', error: 'boom' })),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderTracesView = (
|
||||
props: Record<string, unknown> = {},
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView (grouped root-span table)', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders backend rows in FieldCell format', async () => {
|
||||
mockSuccess();
|
||||
renderTracesView();
|
||||
|
||||
// service.name + name render as plain text
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('HTTP GET')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
// span_count renders as text
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
|
||||
// empty field renders "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// trace_id renders as a link to the trace detail
|
||||
const traceLinks = screen.getAllByTestId('trace-id');
|
||||
expect(traceLinks[0]).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('/trace/0000000000000000344ded1387b08a7e'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state and keeps the toolbar when there are no rows', async () => {
|
||||
mockSuccess([]);
|
||||
renderTracesView();
|
||||
|
||||
// toolbar (un-gated) stays visible regardless of data
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the toolbar visible on API error', async () => {
|
||||
mockError();
|
||||
renderTracesView();
|
||||
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
@@ -11,29 +12,30 @@ import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
|
||||
import styles from './TracesView.module.scss';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
@@ -117,13 +119,8 @@ function TracesView({
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
(responseData ?? []).map((item) => {
|
||||
const row = item.data;
|
||||
return { ...row, id: row.trace_id };
|
||||
}) as TracesTableRow[],
|
||||
const tableData = useMemo(
|
||||
() => responseData?.map((listItem) => listItem.data),
|
||||
[responseData],
|
||||
);
|
||||
|
||||
@@ -136,52 +133,71 @@ function TracesView({
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
<Container>
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ActionsContainer>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
)}
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
/>
|
||||
</div>
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
(tableData || []).length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
12
frontend/src/container/TracesExplorer/TracesView/styles.ts
Normal file
12
frontend/src/container/TracesExplorer/TracesView/styles.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
15
frontend/src/harness/AntdThemeBridge.tsx
Normal file
15
frontend/src/harness/AntdThemeBridge.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { useThemeConfig } from 'hooks/useDarkMode';
|
||||
|
||||
/**
|
||||
* `useThemeConfig` has to run under `ThemeProvider`, so the antd
|
||||
* `ConfigProvider` lives in its own component the way `AppRoutes` does it.
|
||||
*/
|
||||
function AntdThemeBridge({ children }: { children: ReactNode }): JSX.Element {
|
||||
const themeConfig = useThemeConfig();
|
||||
|
||||
return <ConfigProvider theme={themeConfig}>{children}</ConfigProvider>;
|
||||
}
|
||||
|
||||
export default AntdThemeBridge;
|
||||
125
frontend/src/harness/AppHarness.tsx
Normal file
125
frontend/src/harness/AppHarness.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Store } from 'redux';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import { NotificationProvider } from 'hooks/useNotifications';
|
||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import {
|
||||
QueryBuilderContext,
|
||||
QueryBuilderProvider,
|
||||
} from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import AntdThemeBridge from './AntdThemeBridge';
|
||||
|
||||
/**
|
||||
* A layer the runner supplies. A render function rather than a component, so an
|
||||
* inline one does not change identity between renders and remount the tree.
|
||||
*/
|
||||
export type HarnessWrapper = (children: ReactNode) => ReactNode;
|
||||
|
||||
export interface AppHarnessProps {
|
||||
children: ReactNode;
|
||||
/** Stands in for `AppProvider`, whose fetches no harness can make. */
|
||||
appContext: IAppContext;
|
||||
store: Store;
|
||||
queryClient: QueryClient;
|
||||
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
|
||||
queryBuilder?: Partial<QueryBuilderContextType>;
|
||||
/**
|
||||
* The router the runner drives: jest a `MemoryRouter`, Storybook a `Router` on
|
||||
* the contained history. Everything above it in the tree is router-free, so
|
||||
* the choice stays here.
|
||||
*/
|
||||
router: HarnessWrapper;
|
||||
/** The nuqs adapter: the react one under jest, the testing one in Storybook. */
|
||||
searchParams: HarnessWrapper;
|
||||
/** Rendered beside the subject: Storybook's palette, overlay and probe. */
|
||||
overlays?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's provider tree, as `src/index.tsx` and `src/AppRoutes/index.tsx`
|
||||
* mount it, minus Sentry, posthog and `AppProvider`, with the pieces a test
|
||||
* runner has to choose left as props. Storybook's `StorybookProviders` mounts
|
||||
* it, so a story gets the tree production gets. jest's `test-utils` keeps its
|
||||
* own smaller tree: ~20 files in the suite mock provider modules down to a
|
||||
* single export, so the providers those modules also carry would come back
|
||||
* `undefined`.
|
||||
*/
|
||||
function AppHarness({
|
||||
children,
|
||||
appContext,
|
||||
store,
|
||||
queryClient,
|
||||
queryBuilder,
|
||||
router,
|
||||
searchParams,
|
||||
overlays,
|
||||
}: AppHarnessProps): JSX.Element {
|
||||
const subject = queryBuilder ? (
|
||||
<QueryBuilderContext.Provider value={queryBuilder as QueryBuilderContextType}>
|
||||
{children}
|
||||
</QueryBuilderContext.Provider>
|
||||
) : (
|
||||
<QueryBuilderProvider>{children}</QueryBuilderProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<HelmetProvider>
|
||||
{searchParams(
|
||||
<ThemeProvider>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<GlobalTimeStoreAdapter />
|
||||
<AppContext.Provider value={appContext}>
|
||||
<AntdThemeBridge>
|
||||
{router(
|
||||
<TooltipProvider>
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
<ResourceProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<PreferenceContextProvider>
|
||||
{subject}
|
||||
{overlays}
|
||||
</PreferenceContextProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
</ResourceProvider>
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>
|
||||
</TooltipProvider>,
|
||||
)}
|
||||
</AntdThemeBridge>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</ThemeProvider>,
|
||||
)}
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
AppHarness.defaultProps = {
|
||||
queryBuilder: undefined,
|
||||
overlays: undefined,
|
||||
};
|
||||
|
||||
export default AppHarness;
|
||||
@@ -135,3 +135,5 @@ export const closeAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().closeModal();
|
||||
export const toggleAuthZDevModal = (): void =>
|
||||
useAuthZDevStore.getState().toggleModal();
|
||||
export const clearAllAuthZDevOverrides = (): void =>
|
||||
useAuthZDevStore.getState().clearAllOverrides();
|
||||
|
||||
@@ -94,8 +94,6 @@ function AlertDetails(): JSX.Element {
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -117,11 +117,7 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -133,7 +129,6 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,26 +47,21 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState
|
||||
state={alertRuleState ?? state ?? ''}
|
||||
testId="alert-header-state"
|
||||
/>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && (
|
||||
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
|
||||
)}
|
||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,16 +6,14 @@ import './AlertLabels.styles.scss';
|
||||
export type AlertLabelsProps = {
|
||||
labels: Record<string, any>;
|
||||
initialCount?: number;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
function AlertLabels({
|
||||
labels,
|
||||
initialCount = 2,
|
||||
testId,
|
||||
}: AlertLabelsProps): JSX.Element {
|
||||
return (
|
||||
<div className="alert-labels" data-testid={testId}>
|
||||
<div className="alert-labels">
|
||||
<SeeMore initialCount={initialCount} moreLabel="More">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<KeyValueLabel key={`label-${key}`} badgeKey={key} badgeValue={value} />
|
||||
@@ -27,7 +25,6 @@ function AlertLabels({
|
||||
|
||||
AlertLabels.defaultProps = {
|
||||
initialCount: 2,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default AlertLabels;
|
||||
|
||||
@@ -32,10 +32,8 @@ const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
testId,
|
||||
}: {
|
||||
severity: string;
|
||||
testId?: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
@@ -54,16 +52,9 @@ export default function AlertSeverity({
|
||||
};
|
||||
}, [severity]);
|
||||
return (
|
||||
<div
|
||||
className={`alert-severity ${severityDetails.className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={`alert-severity ${severityDetails.className}`}>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
<div className="alert-severity__text">{severityDetails.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertSeverity.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -8,13 +8,11 @@ import './AlertState.styles.scss';
|
||||
type AlertStateProps = {
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
showLabel?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export default function AlertState({
|
||||
state,
|
||||
showLabel,
|
||||
testId,
|
||||
}: AlertStateProps): JSX.Element {
|
||||
let icon;
|
||||
let label;
|
||||
@@ -66,7 +64,7 @@ export default function AlertState({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alert-state" data-testid={testId}>
|
||||
<div className="alert-state">
|
||||
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -74,5 +72,4 @@ export default function AlertState({
|
||||
|
||||
AlertState.defaultProps = {
|
||||
showLabel: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<div className="tab-item">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<div className="tab-item">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
|
||||
200
frontend/src/pages/HomePage/HomePage.stories.mocks.tsx
generated
Normal file
200
frontend/src/pages/HomePage/HomePage.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
countControl,
|
||||
choiceControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
buildAlertRules,
|
||||
buildServices,
|
||||
homeFeatureFlags,
|
||||
homeUserPreferences,
|
||||
HOME_CHECKLIST_STEPS,
|
||||
isSavedViewSignal,
|
||||
metricsOnboardingResponse,
|
||||
queryRangeV5ScalarResponse,
|
||||
recentDashboardsResponse,
|
||||
SAVED_VIEW_SIGNALS,
|
||||
type SavedViewSignal,
|
||||
savedViewsResponse,
|
||||
SERVICES_SOURCES,
|
||||
type ServicesSource,
|
||||
spanMetricsResponse,
|
||||
topLevelOperationsResponse,
|
||||
} from './__story_mockdata__/home';
|
||||
|
||||
const SIGNALS = 'Home · signals';
|
||||
const ONBOARDING = 'Home · onboarding';
|
||||
const LISTS = 'Home · lists';
|
||||
|
||||
const INGESTED_COUNT = 4213;
|
||||
|
||||
/** Home caps every list at five rows, so the control has to go past that. */
|
||||
const LIST_MAX = 8;
|
||||
|
||||
const CHECKLIST_VISIBILITY = ['visible', 'dismissed'] as const;
|
||||
|
||||
type ChecklistVisibility = (typeof CHECKLIST_VISIBILITY)[number];
|
||||
|
||||
interface QueryRangeV5Body {
|
||||
compositeQuery?: { queries?: { spec?: { signal?: string } }[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Home detects logs and traces with one `query_range` call each, told apart by
|
||||
* the signal on the query spec.
|
||||
*/
|
||||
const signalOf = (body: QueryRangeV5Body): string | undefined =>
|
||||
body.compositeQuery?.queries?.[0]?.spec?.signal;
|
||||
|
||||
export const homeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
logsIngestion: toggleControl('Logs ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
tracesIngestion: toggleControl('Traces ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
metricsIngestion: toggleControl('Metrics ingestion', {
|
||||
group: SIGNALS,
|
||||
value: true,
|
||||
}),
|
||||
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
|
||||
group: ONBOARDING,
|
||||
description:
|
||||
'Dismissing it moves the checklist behind the header button, as "I\'ll do this later" does.',
|
||||
options: CHECKLIST_VISIBILITY,
|
||||
value: 'visible',
|
||||
}),
|
||||
skippedSteps: multiChoiceControl('Skipped steps', {
|
||||
group: ONBOARDING,
|
||||
description: 'Steps the user chose to skip. Completion follows the data.',
|
||||
options: HOME_CHECKLIST_STEPS,
|
||||
value: [],
|
||||
}),
|
||||
alertRules: countControl('Alert rules', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
dashboards: countControl('Recent dashboards', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViews: countControl('Saved views per signal', {
|
||||
group: LISTS,
|
||||
value: 5,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
savedViewSignals: multiChoiceControl<SavedViewSignal>('Signals with views', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'Explorer tabs that have views; the rest fall back to their empty state.',
|
||||
options: SAVED_VIEW_SIGNALS,
|
||||
value: SAVED_VIEW_SIGNALS,
|
||||
}),
|
||||
services: countControl('Services', {
|
||||
group: LISTS,
|
||||
value: 6,
|
||||
max: LIST_MAX,
|
||||
}),
|
||||
servicesSource: choiceControl<ServicesSource>('Services source', {
|
||||
group: LISTS,
|
||||
description:
|
||||
'`span-metrics` turns on the feature flag that swaps the services card for the span-metrics one.',
|
||||
options: SERVICES_SOURCES,
|
||||
value: 'traces',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get('http://localhost/api/v2/metrics/onboarding', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(metricsOnboardingResponse(values.metricsIngestion)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v5/query_range', async (req, res, ctx) => {
|
||||
const signal = signalOf((await req.json()) as QueryRangeV5Body);
|
||||
|
||||
const isActive =
|
||||
signal === 'traces' ? values.tracesIngestion : values.logsIngestion;
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(queryRangeV5ScalarResponse(isActive ? INGESTED_COUNT : 0)),
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get('http://localhost/api/v1/user/preferences', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: homeUserPreferences({
|
||||
checklistDismissed: values.welcomeChecklist === 'dismissed',
|
||||
skippedSteps: values.skippedSteps,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => recentDashboardsResponse(values.dashboards)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) => {
|
||||
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
|
||||
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
|
||||
|
||||
return savedViewsResponse(
|
||||
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
|
||||
signal,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildServices(values.services),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/service/top_level_operations',
|
||||
response.json(() => topLevelOperationsResponse(values.services)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v4/query_range',
|
||||
response.json(() => spanMetricsResponse()),
|
||||
),
|
||||
],
|
||||
config: ({ servicesSource }) => ({
|
||||
appContext: { featureFlags: homeFeatureFlags(servicesSource) },
|
||||
}),
|
||||
});
|
||||
55
frontend/src/pages/HomePage/HomePage.stories.tsx
Normal file
55
frontend/src/pages/HomePage/HomePage.stories.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { withAppLayout } from '@/storybook/decorators/withAppLayout';
|
||||
import { homeMocks } from './HomePage.stories.mocks';
|
||||
|
||||
import HomePage from './HomePage';
|
||||
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME }),
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<HomeArgs>;
|
||||
|
||||
/**
|
||||
* Every widget carrying data: all three signals ingesting, alert rules across
|
||||
* severities, recent dashboards, saved views on each explorer tab and a
|
||||
* services table with failing services.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Fresh workspace: nothing ingested yet, so the welcome checklist takes over. */
|
||||
export const NoIngestion: Story = {
|
||||
args: {
|
||||
logsIngestion: false,
|
||||
tracesIngestion: false,
|
||||
metricsIngestion: false,
|
||||
alertRules: 0,
|
||||
dashboards: 0,
|
||||
savedViews: 0,
|
||||
services: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Telemetry reads only: no permission to manage anything, so the create actions
|
||||
* and the legacy editor role are both gone.
|
||||
*/
|
||||
export const ViewerAccess: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** Widgets stuck in their loading state, shell included. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
316
frontend/src/pages/HomePage/__story_mockdata__/home.ts
generated
Normal file
316
frontend/src/pages/HomePage/__story_mockdata__/home.ts
generated
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
|
||||
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import type { ServicesList } from 'types/api/metrics/getService';
|
||||
import type { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
import { baseUserPreferences } from '@/storybook/msw/__story_mockdata__/appShell';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
export { queryRangeV5ScalarResponse };
|
||||
|
||||
/** Shapes follow the fields the components read, not the full generated DTOs. */
|
||||
|
||||
export const metricsOnboardingResponse = (
|
||||
hasMetrics: boolean,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: { hasMetrics },
|
||||
});
|
||||
|
||||
export const HOME_CHECKLIST_STEPS = [
|
||||
'SEND_LOGS',
|
||||
'SEND_TRACES',
|
||||
'SEND_METRICS',
|
||||
'SETUP_ALERTS',
|
||||
'SETUP_SAVED_VIEWS',
|
||||
'SETUP_DASHBOARDS',
|
||||
] as const;
|
||||
|
||||
export type HomeChecklistStep = (typeof HOME_CHECKLIST_STEPS)[number];
|
||||
|
||||
const skippedPreference = (name: string): UserPreference => ({
|
||||
name,
|
||||
description: 'Welcome checklist step skipped',
|
||||
valueType: 'boolean',
|
||||
defaultValue: false,
|
||||
allowedValues: ['true', 'false'],
|
||||
allowedScopes: ['org'],
|
||||
value: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The welcome checklist reads its dismissed and skipped flags off the user
|
||||
* preferences list, one entry per step.
|
||||
*/
|
||||
export const homeUserPreferences = ({
|
||||
checklistDismissed,
|
||||
skippedSteps,
|
||||
}: {
|
||||
checklistDismissed: boolean;
|
||||
skippedSteps: readonly HomeChecklistStep[];
|
||||
}): UserPreference[] => [
|
||||
...baseUserPreferences,
|
||||
...(checklistDismissed
|
||||
? [skippedPreference(ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER)]
|
||||
: []),
|
||||
...skippedSteps.map((step) =>
|
||||
skippedPreference(checkListStepToPreferenceKeyMap[step]),
|
||||
),
|
||||
];
|
||||
|
||||
const DASHBOARDS = [
|
||||
{
|
||||
name: 'Kubernetes cluster health',
|
||||
tags: [{ key: 'team', value: 'platform' }, { key: 'k8s' }],
|
||||
},
|
||||
{ name: 'API latency overview', tags: [{ key: 'sre' }] },
|
||||
{
|
||||
name: 'Checkout funnel',
|
||||
tags: [{ key: 'team', value: 'payments' }, { key: 'business' }],
|
||||
},
|
||||
{ name: 'Postgres slow queries', tags: [{ key: 'database' }] },
|
||||
{
|
||||
name: 'Kafka consumer lag',
|
||||
tags: [{ key: 'team', value: 'data' }, { key: 'streaming' }],
|
||||
},
|
||||
{ name: 'Ingress error budget', tags: [{ key: 'sre' }, { key: 'slo' }] },
|
||||
{ name: 'Cost per service', tags: [{ key: 'finops' }] },
|
||||
{
|
||||
name: 'Redis cache hit rate',
|
||||
tags: [{ key: 'database' }, { key: 'cache' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const recentDashboardsResponse = (
|
||||
count: number,
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
dashboards: DASHBOARDS.slice(0, count).map((dashboard, index) => ({
|
||||
id: `storybook-dashboard-${index + 1}`,
|
||||
name: dashboard.name,
|
||||
spec: { display: { name: dashboard.name } },
|
||||
tags: dashboard.tags,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const ALERT_NAMES = [
|
||||
'Checkout p99 above 2s',
|
||||
'Payment failure rate',
|
||||
'Log volume spike',
|
||||
'Kafka consumer lag',
|
||||
'Pod restart storm',
|
||||
'Disk usage above 85%',
|
||||
'Frontend error rate',
|
||||
'Postgres connections saturated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Cycles the jest fixtures so the list keeps their severity and firing spread.
|
||||
* `updatedAt` descends because that is the order the page sorts on.
|
||||
*/
|
||||
export const buildAlertRules = (count: number): RuletypesRuleDTO[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
...alertRulesFixture[index % alertRulesFixture.length],
|
||||
id: `storybook-rule-${index + 1}`,
|
||||
alert: ALERT_NAMES[index % ALERT_NAMES.length],
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 20 - index, 9)).toISOString(),
|
||||
}));
|
||||
|
||||
export const SAVED_VIEW_SIGNALS = ['logs', 'traces', 'metrics'] as const;
|
||||
|
||||
export type SavedViewSignal = (typeof SAVED_VIEW_SIGNALS)[number];
|
||||
|
||||
const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
|
||||
logs: [
|
||||
'Checkout errors',
|
||||
'Auth service warnings',
|
||||
'Slow SQL statements',
|
||||
'Payment webhooks',
|
||||
'Rate limited requests',
|
||||
'Cron job failures',
|
||||
],
|
||||
traces: [
|
||||
'Slowest checkout spans',
|
||||
'Failed payment traces',
|
||||
'Cart to order funnel',
|
||||
'External API calls',
|
||||
'Cold start requests',
|
||||
'Retried gRPC calls',
|
||||
],
|
||||
metrics: [
|
||||
'Pod memory by namespace',
|
||||
'Queue depth by topic',
|
||||
'HTTP throughput',
|
||||
'Container CPU throttling',
|
||||
'JVM heap usage',
|
||||
'Cache hit ratio',
|
||||
],
|
||||
};
|
||||
|
||||
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
|
||||
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
|
||||
|
||||
export const savedViewsResponse = (
|
||||
count: number,
|
||||
sourcePage: SavedViewSignal,
|
||||
): Record<string, unknown> => {
|
||||
const names = VIEW_NAMES[sourcePage];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
|
||||
...explorerView.data[0],
|
||||
id: `storybook-${sourcePage}-view-${index + 1}`,
|
||||
name: names[index],
|
||||
sourcePage,
|
||||
tags: [sourcePage],
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/** Ordered by p99 with errors at both ends, so any slice keeps the spread. */
|
||||
const SERVICES: ServicesList[] = [
|
||||
{
|
||||
serviceName: 'payments',
|
||||
p99: 2_940_100_000,
|
||||
avgDuration: 921_440_000,
|
||||
numCalls: 46_080,
|
||||
callRate: 25.6,
|
||||
numErrors: 5990,
|
||||
errorRate: 13,
|
||||
},
|
||||
{
|
||||
serviceName: 'checkout',
|
||||
p99: 1_248_900_000,
|
||||
avgDuration: 486_310_000,
|
||||
numCalls: 92_160,
|
||||
callRate: 51.2,
|
||||
numErrors: 4608,
|
||||
errorRate: 5,
|
||||
},
|
||||
{
|
||||
serviceName: 'frontend',
|
||||
p99: 812_450_000,
|
||||
avgDuration: 274_120_000,
|
||||
numCalls: 184_320,
|
||||
callRate: 102.4,
|
||||
numErrors: 1843,
|
||||
errorRate: 1,
|
||||
},
|
||||
{
|
||||
serviceName: 'shipping',
|
||||
p99: 486_200_000,
|
||||
avgDuration: 192_800_000,
|
||||
numCalls: 23_040,
|
||||
callRate: 12.8,
|
||||
numErrors: 691,
|
||||
errorRate: 3,
|
||||
},
|
||||
{
|
||||
serviceName: 'cart',
|
||||
p99: 214_800_000,
|
||||
avgDuration: 88_640_000,
|
||||
numCalls: 138_240,
|
||||
callRate: 76.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
{
|
||||
serviceName: 'catalogue',
|
||||
p99: 96_300_000,
|
||||
avgDuration: 41_220_000,
|
||||
numCalls: 276_480,
|
||||
callRate: 153.6,
|
||||
numErrors: 276,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'recommendations',
|
||||
p99: 64_100_000,
|
||||
avgDuration: 28_400_000,
|
||||
numCalls: 61_440,
|
||||
callRate: 34.1,
|
||||
numErrors: 61,
|
||||
errorRate: 0.1,
|
||||
},
|
||||
{
|
||||
serviceName: 'notifications',
|
||||
p99: 38_700_000,
|
||||
avgDuration: 15_900_000,
|
||||
numCalls: 12_288,
|
||||
callRate: 6.8,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const buildServices = (count: number): ServicesList[] =>
|
||||
SERVICES.slice(0, count);
|
||||
|
||||
export const SERVICES_SOURCES = ['traces', 'span-metrics'] as const;
|
||||
|
||||
export type ServicesSource = (typeof SERVICES_SOURCES)[number];
|
||||
|
||||
/** `USE_SPAN_METRICS` swaps the services card for the span-metrics one. */
|
||||
export const homeFeatureFlags = (source: ServicesSource): FeatureFlagProps[] =>
|
||||
defaultFeatureFlags.map((flag) =>
|
||||
flag.name === FeatureKeys.USE_SPAN_METRICS
|
||||
? { ...flag, active: source === 'span-metrics' }
|
||||
: flag,
|
||||
);
|
||||
|
||||
/** The span-metrics card takes its row set from the top level operations. */
|
||||
export const topLevelOperationsResponse = (count: number): ServiceDataProps =>
|
||||
Object.fromEntries(
|
||||
buildServices(count).map((service) => [
|
||||
service.serviceName,
|
||||
['HTTP GET /', 'HTTP POST /checkout'],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Span-metrics latency, error rate and ops per second come off a table panel.
|
||||
* The page reads them from `newResult`, which the table branch of
|
||||
* `GetMetricQueryRange` never adds, so the columns render as zero however
|
||||
* complete this body is. That is the app's gap, not the mock's.
|
||||
*/
|
||||
export const spanMetricsResponse = (): {
|
||||
status: string;
|
||||
data: MetricRangePayloadV3['data'];
|
||||
} => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
resultType: 'table',
|
||||
result: [
|
||||
{
|
||||
queryName: '',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: {
|
||||
columns: [
|
||||
{ name: 'A', queryName: 'A', isValueColumn: true },
|
||||
{ name: 'D', queryName: 'D', isValueColumn: true },
|
||||
{ name: 'F1', queryName: 'F1', isValueColumn: true },
|
||||
],
|
||||
rows: [{ data: { A: 148_000_000, D: 12.4, F1: 1.8 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -13,8 +13,6 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -65,7 +63,6 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
305
frontend/src/storybook/README.md
Normal file
305
frontend/src/storybook/README.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Storybook
|
||||
|
||||
Runs SigNoz pages and components with no backend: every request is answered by
|
||||
msw, and the providers the app mounts at boot are replaced by story-controlled
|
||||
values.
|
||||
|
||||
```bash
|
||||
pnpm storybook # dev server on :6006
|
||||
pnpm storybook:build # static build into storybook-static/
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What lives there |
|
||||
| ----------------- | ---------------------------------------------------------------------- |
|
||||
| `runtime/` | `resolveStory`: story context in, the world the story renders in out |
|
||||
| `controls/` | Declaring controls (`defineStoryMocks`) and composing mock modules |
|
||||
| `globals/` | The mock modules every story carries: app shell and access |
|
||||
| `access/` | What a permission grant allows, and the legacy role it derives |
|
||||
| `providers/` | The Storybook adapter over `src/harness/AppHarness` |
|
||||
| `navigation/` | Keeping a story on its page, and reporting what it tried to leave for |
|
||||
| `msw/` | The default handler set and the shell's endpoints |
|
||||
| `mocks/` | Modules aliased in place of the app's own |
|
||||
| `decorators/` | `withProviders` (global) and `withAppLayout` (opt-in per page) |
|
||||
|
||||
A page's own mocks live with the page, not here. See [Adding a page
|
||||
story](#adding-a-page-story).
|
||||
|
||||
## What a story gets for free
|
||||
|
||||
`withProviders` (global decorator, `.storybook/preview.tsx`) wraps every story in
|
||||
`StorybookProviders`, the Storybook adapter over `src/harness/AppHarness`.
|
||||
`AppHarness` is the app's provider tree from `src/index.tsx` +
|
||||
`src/AppRoutes/index.tsx`, minus Sentry, posthog and `AppProvider`, with the
|
||||
pieces a runner has to choose left as props: the router, the nuqs adapter, the
|
||||
store, the query client and the mocked `AppContext`.
|
||||
|
||||
`tests/test-utils` mounts its own, smaller tree for jest and does not go through
|
||||
`AppHarness`: the suite has ~20 files that mock `hooks/useDarkMode`,
|
||||
`hooks/useNotifications` or `providers/cmdKProvider` down to a single export, so
|
||||
the providers those modules also carry would come back `undefined`. A provider
|
||||
added to the app therefore still needs adding in both places.
|
||||
|
||||
Storybook fills the seams with:
|
||||
|
||||
- `AppContext` from `tests/fixtures/appContextMock`, the same fixture the jest
|
||||
suite uses, so a story and a test see the same user, license and flags.
|
||||
- A fresh react-query client and redux store per story: no cache or state bleed.
|
||||
- `nuqs` on its testing adapter, so query-param state lives in memory and never
|
||||
touches the iframe URL.
|
||||
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
|
||||
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
|
||||
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it
|
||||
`--l1-background` and friends resolve to nothing and the page renders
|
||||
unstyled) and the `darkMode`/`dark`/`lightMode` classes.
|
||||
|
||||
## The story runtime
|
||||
|
||||
`runtime/resolveStory.ts` is the one place that turns a story's parameters and
|
||||
the controls panel's current values into everything the story runs on: the msw
|
||||
handlers in resolution order, the provider config, the theme, the remount key,
|
||||
and the module-level state to seed. The preview loader applies it before the
|
||||
decorators run; the decorator reads the same result, memoised on the story and
|
||||
its args.
|
||||
|
||||
Handlers resolve first-match-wins, in this order:
|
||||
|
||||
1. the story's own `parameters.msw.handlers`;
|
||||
2. the page's control-driven handlers;
|
||||
3. the global mocks' handlers (access);
|
||||
4. `msw/appShellHandlers.ts`, the endpoints the shell hits on every route, and
|
||||
the ones whose jest fixture is too thin to show it doing its job;
|
||||
5. `src/mocks-server/handlers.ts`, the jest handlers verbatim. An endpoint both
|
||||
runners need belongs here so jest gets it too;
|
||||
6. a catch-all for `http://localhost/api/*` that logs and answers 501, so an
|
||||
endpoint nobody mocked fails loudly instead of hanging on a refused
|
||||
connection.
|
||||
|
||||
The whole set is re-registered on every story render rather than handed to
|
||||
`setupWorker` once. Editing a handler module then takes effect on the next
|
||||
render; with the handlers baked in at worker creation, a long-lived dev server
|
||||
kept answering with the set it started with, and endpoints added later showed up
|
||||
as failed requests.
|
||||
|
||||
The handlers are declared against `http://localhost`, which is why
|
||||
`constants/env` is mocked to that origin. msw intercepts before the request
|
||||
leaves the page, so nothing reaches the network.
|
||||
|
||||
## Overrides
|
||||
|
||||
Per-story, through `parameters`:
|
||||
|
||||
```tsx
|
||||
export const Elsewhere: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
route: '/home?relativeTime=1h',
|
||||
appContext: { featureFlags: [] },
|
||||
reduxState: { globalTime: { ... } },
|
||||
theme: 'light',
|
||||
},
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.get('http://localhost/api/v2/rules', handleInternalServerError),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`parameters.signoz` is typed by `SignozStoryConfig` in `src/storybook/types.ts`.
|
||||
Who the story runs as is not in there. See [Access](#access).
|
||||
|
||||
Anything a page declares as a control belongs in `args`, not in `parameters`.
|
||||
|
||||
## The app shell
|
||||
|
||||
A page story always runs inside the real `AppLayout` (side nav, top nav,
|
||||
banners). A page without its shell is not the page anyone sees. Declare it once
|
||||
on the meta so every story of that page inherits it:
|
||||
|
||||
```tsx
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
} satisfies Meta<typeof HomePage>;
|
||||
```
|
||||
|
||||
## Controls
|
||||
|
||||
A page declares what about its mocks is adjustable, and the controls panel drives
|
||||
it. Every control is a knob on the response, not a prop on the component: turning
|
||||
one re-registers the msw handlers and remounts the story with an empty query
|
||||
cache, so the page fetches again and renders the new data.
|
||||
|
||||
```tsx
|
||||
// src/pages/HomePage/HomePage.stories.mocks.ts
|
||||
export const homeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
logsIngestion: toggleControl('Logs ingestion', { group: SIGNALS, value: true }),
|
||||
dashboards: countControl('Recent dashboards', { group: LISTS, value: 5, max: 8 }),
|
||||
welcomeChecklist: choiceControl<ChecklistVisibility>('Welcome checklist', {
|
||||
group: ONBOARDING,
|
||||
options: CHECKLIST_VISIBILITY,
|
||||
value: 'visible',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => recentDashboardsResponse(values.dashboards)),
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/pages/HomePage/HomePage.stories.tsx
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const meta = {
|
||||
title: 'Pages/Home',
|
||||
component: HomePage,
|
||||
decorators: [withAppLayout],
|
||||
...storyMocks(homeMocks, { route: ROUTES.HOME }),
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export const NoIngestion: StoryObj<HomeArgs> = {
|
||||
args: { logsIngestion: false, tracesIngestion: false, metricsIngestion: false },
|
||||
};
|
||||
```
|
||||
|
||||
`toggleControl`, `countControl`, `choiceControl` and `multiChoiceControl` build
|
||||
the panel row and carry the value's type, so `values` inside `handlers` is typed
|
||||
and a story's `args` are checked.
|
||||
|
||||
The hooks a mock module can answer, all optional. `handlers` answers the page's
|
||||
endpoints; `config` returns the provider-level knobs no endpoint covers;
|
||||
`responseState` says how the endpoints declared through `response` answer;
|
||||
`effect` seeds module-level state no provider exposes, such as no-auth mode; and
|
||||
`role` derives the legacy role, which only `authzMocks` does.
|
||||
|
||||
Endpoints declared through `response.json` follow the response state (`loaded`,
|
||||
`loading` or `error`) which the `Data` control drives, so one declaration covers
|
||||
all three. Endpoints the page cannot render without, such as ingestion detection
|
||||
and preferences, take a plain msw resolver so they keep answering while the rest
|
||||
of the page hangs or fails.
|
||||
|
||||
The mock modules every story carries are registered in `globals/index.ts`:
|
||||
`appShellMocks` (app-wide banners, side nav state, `Data`) and `authzMocks`
|
||||
(below). Adding one there publishes its controls and widens `PageStoryArgs` in
|
||||
the same edit.
|
||||
|
||||
## Access
|
||||
|
||||
Permissions are the knob, not roles. `POST /api/v1/authz/check` is the single
|
||||
gate the app reads: route guards, `AuthZGuard`, `AuthZButton` and `user.role` all
|
||||
resolve through it, so the controls answer that endpoint and everything
|
||||
downstream follows. `access/access.ts` is what decides:
|
||||
`accessFor(preset, extra)` returns the permission set, whether a given check is
|
||||
allowed, and the legacy role it derives.
|
||||
|
||||
- **Access**: `admin`, `editor`, `viewer`, `anonymous`, `grant-all`, `deny-all`,
|
||||
`custom`, `dev-tools`.
|
||||
- **Permissions**: granted on top of the preset, as `relation:kind`
|
||||
(`read:logs`, `create:serviceaccount`, …); `custom` starts from nothing, so
|
||||
there the list is the whole grant. Generated from
|
||||
`lib/authz/hooks/useAuthZ/permissions.type.ts`, so a resource added to the
|
||||
catalogue shows up without touching Storybook. A selector-scoped check
|
||||
(`update` on `role:some-id`) matches the entry for its kind; the legacy
|
||||
`assignee:role:signoz-*` permissions are listed individually.
|
||||
- **Check state**: `loaded`, `loading` or `error`, the same forcing the AuthZ
|
||||
dev modal offers.
|
||||
|
||||
`user.role` comes from the same grant, derived the way `AppProvider` derives it,
|
||||
so the legacy role, `hasEditPermission`, `routePermission` and
|
||||
`componentPermission` all follow the same control and no story can set them to
|
||||
something the check endpoint disagrees with. The runtime writes the result to
|
||||
`<body data-signoz-story-role>`, and the provider tree writes what
|
||||
`useAppContext()` actually yields to `<body data-signoz-context-role>`, so
|
||||
whether the page is reading it is one glance away in the Elements panel.
|
||||
|
||||
Granting no legacy role at all (`deny-all`, or `custom` without one ticked)
|
||||
derives `ANONYMOUS`, exactly as `AppProvider` does. The legacy checks are written
|
||||
as `role !== VIEWER`, so an anonymous user passes them and sees *more* than a
|
||||
viewer. That is the app's gap, faithfully reproduced: to see the viewer UI, grant
|
||||
the viewer role. The role-named presets exist because those checks still exist;
|
||||
when the roles go, delete the presets and the derivation. The permission list
|
||||
stays.
|
||||
|
||||
For anything finer than a preset, the app's own dev tools are mounted in every
|
||||
story: `⌘K` → **AuthZ DevTools** lists the permissions the page actually checked
|
||||
and overrides them one by one (granted, denied, delayed, error). Set Access to
|
||||
`dev-tools` first, because the other values reset the override store on render, so
|
||||
a leftover override from a real dev session cannot answer for the controls panel.
|
||||
Overrides only apply while `IS_DEV` is true, which means the dev server, not a
|
||||
static build.
|
||||
|
||||
Adding or renaming a project-level control needs a tab reload: Vite hot-updates
|
||||
`preview.tsx` without re-preparing the open stories, so the panel keeps the
|
||||
controls, and the arg values, it was built with.
|
||||
|
||||
## Module mocks
|
||||
|
||||
Aliased for every story in `.storybook/main.ts`, the same way `jest.config.ts`
|
||||
does it through `moduleNameMapper`. Each replacement is typed as the module it
|
||||
stands in for, so an export added to the real module is a compile error here
|
||||
rather than a story that fails at render:
|
||||
|
||||
| Module | Replacement | Why |
|
||||
| --------------------- | ---------------------------------- | ------------------------------------------ |
|
||||
| `lib/history` | `navigation/history.alias.ts` | keeps a story on its page, see below |
|
||||
| `api/common/logEvent` | `mocks/logEvent.mock.ts` | analytics never leave the iframe |
|
||||
| `constants/env` | `mocks/env.mock.ts` | pins the API origin the handlers answer on |
|
||||
|
||||
Mocks use `fn()` from `storybook/test`, so a play function can assert on them:
|
||||
|
||||
```tsx
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
play: async () => {
|
||||
await expect(logEvent).toHaveBeenCalledWith('Homepage: Visited', {});
|
||||
},
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
A story renders one page, so leaving that page would unmount it.
|
||||
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` is what the
|
||||
app sees in place of `lib/history`, and the two tell a navigation apart by
|
||||
pathname:
|
||||
|
||||
- **Same page**: a query-param or hash change, which is how tabs, filters,
|
||||
pagination and time ranges are driven. It is applied, and the page re-renders
|
||||
the way it does in the app. Anchors are covered too: an in-page `<a href="?tab=x">`
|
||||
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's history
|
||||
rather than followed, which would navigate the iframe out of the story.
|
||||
- **Another page**: a different pathname, an off-site href, `window.open` (what
|
||||
`useSafeNavigate({ newTab })` calls) or a relative `go`/`goBack`, which carries
|
||||
no target to compare against. It is swallowed and reported to
|
||||
`NavigationBlockedOverlay`, which lists what was attempted. Nothing is silently
|
||||
dropped.
|
||||
|
||||
`nuqs` is the one gap: it runs on its testing adapter and keeps its own copy of
|
||||
the query string, seeded from the story's `route`. A page that writes params
|
||||
through both `useQueryState` and `history.push({ search })` sees the two diverge
|
||||
inside a story; a page that stays on one mechanism does not.
|
||||
|
||||
## Adding a page story
|
||||
|
||||
The `signoz-page-story` skill in `.claude/skills/` carries this as a workflow:
|
||||
mapping the page, deriving its controls, and the checks a story has to pass.
|
||||
|
||||
1. Point the story at the page component under `src/pages/<Page>`.
|
||||
2. Declare the page's mocks in `<Page>.stories.mocks.ts` next to it, with its
|
||||
payload builders under `<Page>/__story_mockdata__/`, and spread
|
||||
`storyMocks(<page>Mocks, { route })` into the meta.
|
||||
3. Add `decorators: [withAppLayout]` to the meta.
|
||||
4. Give the default story every widget populated. A page story earns its keep by
|
||||
showing what the page looks like with data, not with empty states.
|
||||
5. Run it and watch the console: an msw warning or a `[storybook] no msw handler`
|
||||
line is an endpoint the page hits that no handler covers yet.
|
||||
6. Reach for a control before a story. A variant earns a story only when it is
|
||||
worth linking to; anything else is a control someone can turn.
|
||||
155
frontend/src/storybook/access/access.ts
Normal file
155
frontend/src/storybook/access/access.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { AuthtypesTransactionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IsAdminPermission,
|
||||
IsAnonymousPermission,
|
||||
IsEditorPermission,
|
||||
IsViewerPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
formatPermission,
|
||||
gettableTransactionToPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
/**
|
||||
* `relation:kind` for every verb the backend allows on a resource, from the
|
||||
* generated permission catalogue, so a resource added there shows up here
|
||||
* without anyone touching this file. Selector-scoped checks (`role:some-id`)
|
||||
* match the entry for their kind.
|
||||
*/
|
||||
export const permissionCatalogue = (): string[] => {
|
||||
const entries = new Set<string>();
|
||||
|
||||
for (const [relation, types] of Object.entries(
|
||||
permissionsType.data.relations,
|
||||
)) {
|
||||
for (const resource of permissionsType.data.resources) {
|
||||
const appliesToResource = (types as readonly string[]).includes(
|
||||
resource.type,
|
||||
);
|
||||
|
||||
const allowsVerb = (resource.allowedVerbs as readonly string[]).includes(
|
||||
relation,
|
||||
);
|
||||
|
||||
if (appliesToResource && allowsVerb) {
|
||||
entries.add(`${relation}:${resource.kind}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries].sort();
|
||||
};
|
||||
|
||||
const CATALOGUE = permissionCatalogue();
|
||||
|
||||
const TELEMETRY_READS = CATALOGUE.filter((permission) =>
|
||||
permissionsType.data.resources.some(
|
||||
(resource) =>
|
||||
resource.type === 'telemetryresource' &&
|
||||
permission === `read:${resource.kind}`,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* `user.role` is itself an authz check in the real app (`AppProvider` derives it
|
||||
* from these), so a caller that wants a role grants the matching permission
|
||||
* rather than setting the role directly.
|
||||
*/
|
||||
export const LEGACY_ROLE_PERMISSIONS = {
|
||||
[USER_ROLES.ADMIN]: formatPermission(IsAdminPermission),
|
||||
[USER_ROLES.EDITOR]: formatPermission(IsEditorPermission),
|
||||
[USER_ROLES.VIEWER]: formatPermission(IsViewerPermission),
|
||||
[USER_ROLES.ANONYMOUS]: formatPermission(IsAnonymousPermission),
|
||||
};
|
||||
|
||||
export const ACCESS_PRESETS = [
|
||||
'admin',
|
||||
'editor',
|
||||
'viewer',
|
||||
'anonymous',
|
||||
'grant-all',
|
||||
'deny-all',
|
||||
'custom',
|
||||
'dev-tools',
|
||||
] as const;
|
||||
|
||||
export type AccessPreset = (typeof ACCESS_PRESETS)[number];
|
||||
|
||||
/**
|
||||
* The legacy roles only differ in authz for the resources the backend already
|
||||
* covers: an admin manages roles, service accounts and API keys, while editor
|
||||
* and viewer are telemetry readers and differ through `user.role` alone. The
|
||||
* presets go away with the roles; the permission list does not.
|
||||
*/
|
||||
const ADMIN_PERMISSIONS = [
|
||||
LEGACY_ROLE_PERMISSIONS[USER_ROLES.ADMIN],
|
||||
// Without the `assignee` wildcard, which would hand out every legacy role at
|
||||
// once. That is what `grant-all` is for.
|
||||
...CATALOGUE.filter((permission) => permission !== 'assignee:role'),
|
||||
];
|
||||
|
||||
const PRESET_PERMISSIONS: Record<AccessPreset, readonly string[]> = {
|
||||
admin: ADMIN_PERMISSIONS,
|
||||
editor: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.EDITOR], ...TELEMETRY_READS],
|
||||
viewer: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.VIEWER], ...TELEMETRY_READS],
|
||||
anonymous: [LEGACY_ROLE_PERMISSIONS[USER_ROLES.ANONYMOUS]],
|
||||
'grant-all': [...Object.values(LEGACY_ROLE_PERMISSIONS), ...CATALOGUE],
|
||||
'deny-all': [],
|
||||
custom: [],
|
||||
'dev-tools': ADMIN_PERMISSIONS,
|
||||
};
|
||||
|
||||
/** Every permission a caller can grant on top of a preset. */
|
||||
export const PERMISSION_OPTIONS = [
|
||||
...Object.values(LEGACY_ROLE_PERMISSIONS),
|
||||
...CATALOGUE,
|
||||
];
|
||||
|
||||
export interface AccessGrant {
|
||||
permissions: ReadonlySet<string>;
|
||||
/** Answers one `authz/check` transaction the way the backend would. */
|
||||
allows(transaction: AuthtypesTransactionDTO): boolean;
|
||||
/**
|
||||
* The legacy role the granted `assignee:role:signoz-*` permissions derive,
|
||||
* the way `AppProvider` derives it. No legacy role granted lands on
|
||||
* `ANONYMOUS`.
|
||||
*/
|
||||
legacyRole: ROLES;
|
||||
}
|
||||
|
||||
const deriveLegacyRole = (granted: ReadonlySet<string>): ROLES => {
|
||||
const role = Object.entries(LEGACY_ROLE_PERMISSIONS).find(([, permission]) =>
|
||||
granted.has(permission),
|
||||
);
|
||||
|
||||
return (role?.[0] ?? USER_ROLES.ANONYMOUS) as ROLES;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission set a preset plus its extra grants resolve to, and the two
|
||||
* questions the app asks of it. `custom` and `deny-all` start from nothing, so
|
||||
* there the extra grants are the whole set.
|
||||
*/
|
||||
export const accessFor = (
|
||||
preset: AccessPreset,
|
||||
extraPermissions: readonly string[] = [],
|
||||
): AccessGrant => {
|
||||
const permissions = new Set([
|
||||
...PRESET_PERMISSIONS[preset],
|
||||
...extraPermissions,
|
||||
]);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
allows: (transaction): boolean =>
|
||||
permissions.has(
|
||||
formatPermission(gettableTransactionToPermission(transaction)),
|
||||
) ||
|
||||
permissions.has(
|
||||
`${transaction.relation}:${transaction.object.resource.kind}`,
|
||||
),
|
||||
legacyRole: deriveLegacyRole(permissions),
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user