Compare commits

..

12 Commits

Author SHA1 Message Date
Vinícius Lourenço
207129a6df refactor(app): own the provider tree in src/app 2026-09-04 10:45:28 -03:00
Vinícius Lourenço
7cb16a2fed refactor(storybook): move page story files under stories/ 2026-09-04 10:45:27 -03:00
Vinícius Lourenço
bb09584a65 chore(package): pin the version for testing lib 2026-09-03 09:58:41 -03:00
Vinícius Lourenço
b157513580 chore(app-shell): from 0.99.0 to 0.0.0 2026-09-03 09:58:41 -03:00
Vinícius Lourenço
a12b6cdebe docs(harness): correct which runners mount AppHarness 2026-09-03 09:58:41 -03:00
Vinícius Lourenço
7f66ac35a8 feat(storybook): add the a11y addon 2026-09-03 09:58:41 -03:00
Vinícius Lourenço
aeb4b0a34c chore: collapse AI-owned story mocks in diffs 2026-09-03 09:58:41 -03:00
Vinícius Lourenço
5c47f10268 docs(storybook): mark story mocks as AI-owned 2026-09-03 09:58:41 -03:00
Vinícius Lourenço
b5e7d14488 refactor(storybook): rename __mockdata__ to __story_mockdata__ 2026-09-03 09:58:40 -03:00
Vinícius Lourenço
e9aa5083a1 feat(storybook): add story for home page 2026-09-03 09:58:40 -03:00
Vinícius Lourenço
7fab79fc18 feat(storybook): add skill to create stories for pages 2026-09-03 09:58:40 -03:00
Vinícius Lourenço
e3b773bfd5 feat(storybook): add initial support for storybook 2026-09-03 09:58:39 -03:00
1252 changed files with 27316 additions and 5436 deletions

View File

@@ -0,0 +1,95 @@
---
name: signoz-page-story
description: Explore a SigNoz page, map the endpoints, states and query params it has, then write its Storybook page story with control-driven msw mocks that reach every state. Use when asked to create, extend or review a Storybook story for a page under frontend/src/pages, to add controls to an existing page story, or to write defineStoryMocks handlers and mock data for a page.
---
# SigNoz page stories
A page story renders the real page inside the real app shell against msw, and its
controls panel can reach every state the page has. The panel is the deliverable,
not the story list.
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
control builders, module mocks, navigation). Read it first; this skill is the
process on top of it.
## Workflow
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
the inventory (endpoints, states, params, permissions, caps) before writing
code. No inventory, no story.
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
names the endpoints step 1 missed.
3. **Inventory to controls**: [references/controls.md](references/controls.md).
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's
mocks module.
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
report the story as done without it.
## Rules
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
every widget carrying data. Empty, loading and failed are variants or control
values, never the default.
- **A control is a knob on a response**, resolved through `handlers`, `config` or
`effect`. Never a component prop, never a module mock added for one story.
- **Every branch in the inventory is reachable from the panel.** A state that
needs a code edit to see is a missing control.
- **Never re-declare what every story already has**: banner, side nav, data state
(loaded/loading/error), access preset, permissions, check state.
- **A variant earns a story only when it is worth linking to**: an empty
workspace, a viewer, a page mid-load. Everything else stays a control.
- **Endpoints the page owns go through `response.json`**, so the Data control
covers loaded, loading and failed in one declaration. Endpoints the page cannot
render without (ingestion detection, preferences, feature payloads) take a
plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param
navigation works inside a story; a different pathname is blocked and reported
by the overlay. A control for a param is worth it only when the param is a page
mode someone would want to flip.
- **File layout**: every story file for a page lives under
`src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`,
payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing
page-specific in `src/storybook/controls/`.
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file
under a `__story_mockdata__/` open with this banner, above the imports:
```ts
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
```
The root `.gitattributes` marks both paths `linguist-generated=true`, so the
reviewer gets them collapsed and spends the attention on the rendered page. The
story file is the human surface and never carries the banner. A file with the
banner has to stay regenerable from the page alone: no page knowledge in
`src/storybook/`, and builders typed from `src/api/generated` where the endpoint
has types, so a contract change is a compile error instead of a mock that lies.
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
writing a response shape inline, check if a builder exists; if not and the
shape will repeat, add it there. Page-specific builders stay in the page's
`__story_mockdata__/`.
- **No comment is the default.** Write one only for what the code cannot show:
a shape the backend dictates, an app bug the mock reproduces, an ordering or
cap the page depends on, a workaround and the reason for it. Never restate a
name, a type, or what a builder plainly builds; if the sentence reads as the
signature in prose, delete it. Nothing addressed to a reviewer. The one
comment a story always gets is its own doc comment: what it shows, in the
page's own terms.
## Done means
- [ ] `Default` shows the page with data, checked in dark and light
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
- [ ] every control flipped once, its effect seen on screen
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled
request, no React warning
- [ ] no navigation overlay on mount
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
`pnpm exec oxfmt --check <files>` all clean. The repo has no
prettier: `pnpm exec prettier` prints a pass while exiting 254

View File

@@ -0,0 +1,165 @@
# Turning the inventory into controls
Every row of the inventory becomes a control, a global control that already
exists, or a documented reason it cannot be one.
## Imports
Paths written as `src/storybook/...` in prose are repo paths, not import
specifiers. Stories import through the `@/` alias (`@/*``./src/*`); modules
inside `src/storybook/` import each other relatively.
| Import | From |
| --- | --- |
| `toggleControl`, `countControl`, `choiceControl`, `multiChoiceControl` | `../controls/controls` |
| `defineStoryMocks`, `storyMocks` | `../controls/defineStoryMocks` |
| `PageStoryArgs` | `../controls/resolveStoryMocks` |
| `MockRequest`, `MockResponse` | `../controls/types` |
| the page's mocks, from the story | `./<Page>.stories.mocks` |
| `queryRangeV5ScalarResponse`, `queryRangeV5RawResponse`, etc. | `@/storybook/msw/__story_mockdata__/queryRange` |
## Which builder
`src/storybook/controls/controls.ts`:
| The state is | Builder |
| --- | --- |
| on or off (a signal ingesting, a feature present) | `toggleControl` |
| how many rows a list has | `countControl` |
| one of several modes (tab, visibility, plan, severity filter) | `choiceControl` |
| a subset (steps skipped, columns shown, signals selected) | `multiChoiceControl` |
Rules that come with them:
- `countControl` `max` goes past what the page renders, so a story can show the
cap being hit. `0` is the empty state, which is why an empty list rarely needs
its own story. When the cap is in the *request* (`?limit=5`) rather than the
renderer, stop `max` at the limit: a longer response is a body the backend
cannot send.
- `choiceControl` options come from a `const` array typed with
`(typeof X)[number]`, not from string literals scattered in the handlers.
- Defaults describe the fully-populated page. The panel starts where `Default`
starts.
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
control.
- `description` only when the name does not carry the effect (what dismissing
does, what the cap is, which widget it feeds).
## Which hook
`defineStoryMocks` takes three, all optional:
- `handlers(values, response)`: the page's endpoints. Everything the page owns
goes through `response.json`, so the global Data control turns the whole page
into loading or failed without a second declaration. An endpoint the page
cannot render at all without (ingestion detection, preferences, license
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
visible while the rest hangs or fails.
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
`appContext`, `reduxState`, `queryBuilder`, `theme`.
- `effect(values)`: module-level state no provider exposes.
One endpoint feeding several widgets stays one handler that reads the request.
`response.json` hands the request to the builder and awaits it, so reading a
query param, or a POST body, does not cost the Data control:
```ts
rest.get(
'http://localhost/api/v1/explorer/views',
response.json((req) =>
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
),
),
```
```ts
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {
const body = (await req.json()) as QueryRangeRequestV5;
const signal = body.compositeQuery?.queries?.[0]?.spec?.signal;
return countResponse(values[`${signal}Ingestion`] ? 4213 : 0);
}),
),
```
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
endpoint has to keep answering while the Data control is on `loading` or
`error`: detection calls the page cannot render without.
## Mutations
A control drives the response, so a write the page makes against state a control
owns does not stick: the refetch answers with the control's value and the button
appears to do nothing. Two honest options: leave it declarative and say so in
the PR, or move the state into `effect` so the handler can read what the page
wrote. Never fake the write by mutating a builder's module state without saying
where the state lives.
## Wiring it up
```ts
// src/pages/Services/stories/Services.stories.mocks.tsx
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
export const servicesMocks = defineStoryMocks({
controls: {
services: countControl('Services', { group: LISTS, value: 8, max: 12 }),
apdex: choiceControl<ApdexState>('Apdex', {
group: HEALTH,
options: APDEX_STATES,
value: 'mixed',
}),
},
handlers: (values, response) => [
rest.post(
'http://localhost/api/v2/services',
response.json(() => buildServices(values.services, values.apdex)),
),
],
});
```
```tsx
// src/pages/Services/stories/Services.stories.tsx
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
const meta = {
title: 'Pages/Services',
component: Services,
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION, layout: 'app' }),
} satisfies Meta<ServicesArgs>;
```
`PageStoryArgs` folds in the global controls, so a story's `args` can set
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
## Not a control
- Anything the global controls already cover: banner, side nav, data state,
access preset, permissions, check state.
- A knob whose effect nobody can see on the page. Delete it or find the widget it
was supposed to drive.
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
`viewer`), and the builder turns intent into the payload.
- Anything that needs a module mock or a component prop to work. If the state
cannot be produced from a response, config or module state, say so in the PR
instead of faking it.
## Control or story
Default to a control. Write a story when the state is worth a link:
- the fresh workspace, because that is what a new user sees
- the restricted user, when permissions visibly change the page
- a page-defining mode (a tab, a category) that has its own layout
Combinations of controls do not need stories, which is what the panel is for.
Each story gets one prose doc comment: what it shows, in the page's own terms.
Everywhere else the comment rule in SKILL.md applies: write one only for what
the code cannot show.

View File

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

View File

@@ -0,0 +1,110 @@
# Verifying a page story
A story is not done because it compiles. It is done when each control has been
seen changing the page and the console is silent.
## Run it
```bash
cd frontend && pnpm storybook --ci --quiet # :6006, background it
```
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
already-running server; an empty first poll is not a broken `stories` glob.
Story ids come from the meta title: `Pages/Services``pages-services`, plus the
story export in kebab-case. Render one story on its own:
```
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
```
## Flip controls from the URL
Args are settable in the iframe URL, so a whole sweep runs headless without
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
indexed, several separated by `;`, and the theme through `globals`:
```
&args=services:0;apdex:poor;access:viewer;dataState:loading
&args=signals[0]:logs;signals[1]:traces
&globals=theme:light
```
That is the cheap way to check a control does something: load with and without
it, diff the page text.
## Drive it
Playwright lives in the repo's e2e workspace, so a scratch script can use it
directly:
```js
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
const { chromium } = pw;
const browser = await chromium.launch();
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') problems.push(m.text());
});
page.on('pageerror', (e) => problems.push(e.message));
await page.goto(`${story}&args=services:0`, { waitUntil: 'networkidle' });
await page.locator('body').waitFor();
console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
await browser.close();
```
Screenshots are worth taking for `Default` in both themes
(`&globals=theme:light`): text extraction does not catch an unstyled page.
## Gates
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
msw unhandled-request warning, a React key or state warning: assume the story
is wrong first. A warning that survives is sometimes the app's. Prove it by
turning off the control that renders the widget and watching the warning go
with it, and by finding the same component elsewhere doing it right. Then
report the app bug in the PR. Never invent a field the API does not return to
silence a warning.
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
means the page is trying to leave: wrong `route`, or a guard denying on a
permission the controls did not grant.
- **Every control moves something.** Sweep them one at a time from the URL and
diff the page text. A control with no diff is either wired to nothing or aimed
at a widget that is not rendering. Some only show their effect after an
interaction, such as a tab that has to be clicked or a select that has to be
opened. Drive that interaction rather than calling the control unobservable.
- **Both themes render styled.** An unstyled page means the story is not inside
the provider decorator, or `<body data-theme>` was lost.
- **Roles agree.** `<body data-signoz-story-role>` and
`<body data-signoz-context-role>` disagreeing means the page reads a different
`AppContext` than the story config fills.
- **The page's own navigation works.** Tabs, filters and pagination that write
query params should re-render the page in place; only leaving the page belongs
in the overlay.
## Then the usual
```bash
pnpm tsgo --noEmit
pnpm exec oxlint <changed files>
pnpm exec oxfmt --check <changed files>
```
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
not one.
## Common failures
| Symptom | Cause |
| --- | --- |
| endless spinner with Data on `loaded` | handler URL does not match the call; handlers answer on `http://localhost` |
| page renders but empty | response shape wrong; compare against the api module's type, not a guess |
| 501 in the console | endpoint nobody mocked; the catch-all is answering |
| new control missing from the panel | project-level control added; the tab needs a reload |
| control flips but nothing changes | the widget is gated by something else: a permission, a flag, a preference |
| shell disappears in `loading` | an endpoint the shell needs went through `response.json`; give it a plain resolver |

4
.gitattributes vendored
View File

@@ -1 +1,3 @@
*.css linguist-detectable=false
*.css linguist-detectable=false
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true

28
.github/CODEOWNERS vendored
View File

@@ -152,29 +152,39 @@ go.mod @therealpandey
## Dashboard Types
/frontend/src/types/api/dashboard/ @SigNoz/pulse-frontend
/frontend/src/types/api/widgets/ @SigNoz/pulse-frontend
/frontend/src/api/types/dashboard/ @SigNoz/pulse-frontend
## Widget Card
## Dashboard List
/frontend/src/container/WidgetCard/ @SigNoz/pulse-frontend
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
# Dashboard Widget Page
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Dashboard Page
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
## Public Dashboard Page
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## Dashboard Libs + Components
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
/frontend/src/lib/visualization/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend
## Dashboard Pages
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
## Dashboard V2
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
## Infrastructure Monitoring
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend

View File

@@ -44,8 +44,6 @@ import (
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/subscription/noopsubscription"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
@@ -89,9 +87,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return nooplicensing.NewFactory()
},
func(_ zeus.Zeus, _ licensing.Licensing) subscription.Subscription {
return noopsubscription.New()
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(config.Global),

View File

@@ -28,7 +28,6 @@ import (
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/ee/subscription/httpsubscription"
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/alertmanager"
@@ -61,7 +60,6 @@ import (
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
@@ -105,9 +103,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics)
},
func(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription {
return httpsubscription.New(zeus, licensing)
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(config.Global),

View File

@@ -9217,116 +9217,6 @@ components:
required:
- references
type: object
SubscriptiontypesGettableSubscription:
properties:
redirectURL:
type: string
required:
- redirectURL
type: object
SubscriptiontypesGettableSubscriptionUsage:
properties:
billingPeriodEnd:
format: int64
type: integer
billingPeriodStart:
format: int64
type: integer
details:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDetails'
discount:
format: double
type: number
subscriptionStatus:
type: string
type: object
SubscriptiontypesPostableSubscription:
properties:
url:
type: string
required:
- url
type: object
SubscriptiontypesSubscriptionUsageBreakdown:
properties:
dayWiseBreakdown:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseBreakdown'
tiers:
items:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageTier'
nullable: true
type: array
type:
type: string
unit:
type: string
type: object
SubscriptiontypesSubscriptionUsageDayWiseBreakdown:
properties:
breakdown:
items:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseData'
nullable: true
type: array
type:
type: string
type: object
SubscriptiontypesSubscriptionUsageDayWiseData:
properties:
count:
format: double
type: number
quantity:
format: double
type: number
size:
format: double
type: number
timestamp:
format: int64
type: integer
total:
format: double
type: number
unitPrice:
format: double
type: number
type: object
SubscriptiontypesSubscriptionUsageDetails:
properties:
baseFee:
format: double
type: number
billTotal:
format: double
type: number
breakdown:
items:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageBreakdown'
nullable: true
type: array
total:
format: double
type: number
type: object
SubscriptiontypesSubscriptionUsageTier:
properties:
quantity:
format: double
type: number
tierCost:
format: double
type: number
tierEnd:
format: int64
type: integer
tierStart:
format: int64
type: integer
unitPrice:
format: double
type: number
type: object
TagtypesGettableTag:
properties:
key:
@@ -14551,197 +14441,6 @@ paths:
summary: Get stats
tags:
- stats
/api/v1/subscriptions:
get:
deprecated: false
description: This endpoint gets the organization's subscription along with its
usage and billing details.
operationId: GetSubscription
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SubscriptiontypesGettableSubscriptionUsage'
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
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- subscription:read
- tokenizer:
- subscription:read
summary: Get the subscription.
tags:
- subscriptions
post:
deprecated: false
description: This endpoint creates a subscription for the organization.
operationId: CreateSubscription
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SubscriptiontypesPostableSubscription'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SubscriptiontypesGettableSubscription'
status:
type: string
required:
- status
- data
type: object
description: Created
"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
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- subscription:create
- tokenizer:
- subscription:create
summary: Create a subscription.
tags:
- subscriptions
put:
deprecated: false
description: This endpoint updates the organization's subscription.
operationId: UpdateSubscription
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SubscriptiontypesPostableSubscription'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SubscriptiontypesGettableSubscription'
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
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- subscription:list
- subscription:update
- tokenizer:
- subscription:list
- subscription:update
summary: Update the subscription.
tags:
- subscriptions
/api/v1/testChannel:
post:
deprecated: true

View File

@@ -1,95 +0,0 @@
package httpsubscription
import (
"context"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/types/subscriptiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/tidwall/gjson"
)
const upstreamTimeout = 10 * time.Second
type provider struct {
zeus zeus.Zeus
licensing licensing.Licensing
}
func New(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription {
return &provider{
zeus: zeus,
licensing: licensing,
}
}
func (provider *provider) Create(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) {
ctx, cancel := context.WithTimeout(ctx, upstreamTimeout)
defer cancel()
license, err := provider.licensing.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload")
}
response, err := provider.zeus.GetCheckoutURL(ctx, license.Key, body)
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
}
return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) Update(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) {
ctx, cancel := context.WithTimeout(ctx, upstreamTimeout)
defer cancel()
license, err := provider.licensing.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload")
}
response, err := provider.zeus.GetPortalURL(ctx, license.Key, body)
if err != nil {
return nil, err
}
return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) Get(ctx context.Context, organizationID valuer.UUID) (*subscriptiontypes.GettableSubscriptionUsage, error) {
license, err := provider.licensing.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
data, err := provider.zeus.GetMeters(ctx, license.Key)
if err != nil {
return nil, err
}
usage, err := subscriptiontypes.NewGettableSubscriptionUsage(data)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "failed to unmarshal subscription usage")
}
return usage, nil
}

6
frontend/.gitignore vendored
View File

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

View File

@@ -323,10 +323,9 @@
"name": "react",
"importNames": [
"createContext",
"useContext",
"useSyncExternalStore"
"useContext"
],
"message": "[State mgmt] React Context and hand-rolled external stores are deprecated. Migrate shared state to Zustand."
"message": "[State mgmt] React Context is deprecated. Migrate shared state to Zustand."
},
{
"name": "immer",
@@ -566,12 +565,12 @@
}
},
{
// Root dashboard pages own the fetch lifecycle; useDashboardFetchRequired wraps it.
// Root V2 pages own the dashboard fetch lifecycle; useDashboardFetchRequired wraps it.
// Everywhere else must use useDashboardFetchRequired().
"files": [
"src/pages/DashboardPage/DashboardPage.tsx",
"src/pages/DashboardPage/PanelEditorPage/PanelEditorPage.tsx",
"src/pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired.ts"
"src/pages/DashboardPageV2/DashboardPageV2.tsx",
"src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.tsx",
"src/pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired.ts"
],
"rules": {
"signoz/no-dashboard-fetch-outside-root": "off"

View File

@@ -0,0 +1,89 @@
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import type { StorybookConfig } from '@storybook/react-vite';
import type { Plugin, PluginOption } from 'vite';
const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
/**
* Modules replaced for every story. Same idea as `moduleNameMapper` in
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
* not catch `lib/historyUtils`.
*
* Each replacement is typed as the module it stands in for, so drift is a
* compile error rather than a story that fails at render. The `jest` note on
* each entry is where the same import lands under the other runner. The two
* only diverge where the runner needs them to.
*/
const mockAliases = [
{
// jest: not replaced, jsdom drives a real browser history.
find: /^(?:src\/)?lib\/history$/,
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
},
{
// jest: src/__tests__/logEventMock.ts
find: /^(?:src\/)?api\/common\/logEvent$/,
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
},
{
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
// resolves a relative `/api/...` against `http://localhost`.
find: /^(?:src\/)?constants\/env$/,
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
},
];
/**
* Plugins from `vite.config.ts` that either target the app's `index.html` or
* only pay off in a production build.
*/
const EXCLUDED_PLUGINS = [
'vite-plugin-checker',
'dev-base-path',
'dev-boot-data',
'vite-plugin-image-optimizer',
'vite-plugin-compression',
];
const isExcluded = (plugin: PluginOption): boolean =>
!!plugin &&
typeof plugin === 'object' &&
'name' in plugin &&
EXCLUDED_PLUGINS.includes((plugin as Plugin).name);
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.@(ts|tsx)'],
// `../public` carries the fonts, icons and i18n bundles the app expects at
// the root; `./public` carries the msw worker, which must not ship in a
// production build.
staticDirs: ['../public', './public'],
addons: ['@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;

View File

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

View File

@@ -0,0 +1,110 @@
import type { Preview } from '@storybook/react-vite';
import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
import { withProviders } from '../src/storybook/decorators/withProviders';
import { globalMocks } from '../src/storybook/globals';
import { resetStoryHistory } from '../src/storybook/navigation/containment';
import { clearBlockedNavigations } from '../src/storybook/navigation/blockedNavigationStore';
import {
resolveStory,
type StoryRuntimeContext,
} from '../src/storybook/runtime/resolveStory';
import '../src/ReactI18';
import '../src/styles.scss';
import '../src/storybook/storybook-root.scss';
interface StorybookWorkerHolder {
__signozStorybookWorker?: StorybookWorker;
}
const holder = window as unknown as StorybookWorkerHolder;
/**
* One worker per page, even if this module is re-executed by HMR. Two live
* workers both answer the service worker and the story gets whichever replies
* first.
*/
interface StorybookWorker {
worker: SetupWorker;
ready: Promise<unknown>;
}
const { worker, ready } = (holder.__signozStorybookWorker ??=
((): StorybookWorker => {
const instance = setupWorker();
return {
worker: instance,
ready: instance.start({
serviceWorker: { url: './mockServiceWorker.js' },
// Storybook's own traffic (index.json, HMR, telemetry) goes unhandled by
// design; only flag the app's API calls so a missing handler is obvious.
onUnhandledRequest: (request, print): void => {
const url = new URL(request.url.href);
const isStaticAsset =
/\.(?:woff2?|ttf|otf|css|js|map|png|jpe?g|svg|webp|ico)$/.test(
url.pathname,
);
const isAppRequest =
!isStaticAsset &&
(url.pathname.startsWith('/api/') || url.host !== window.location.host);
if (isAppRequest) {
print.warning();
}
},
}),
};
})());
const preview: Preview = {
parameters: {
layout: 'fullscreen',
controls: { expanded: true },
},
globalTypes: {
theme: {
description: 'SigNoz color scheme',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'dark', title: 'Dark' },
{ value: 'light', title: 'Light' },
],
dynamicTitle: true,
},
},
},
initialGlobals: { theme: 'dark' },
// Controls every story carries: permissions, banners, and whether the page's
// own endpoints answer, hang or fail.
args: globalMocks.args,
argTypes: globalMocks.argTypes,
decorators: [withProviders],
loaders: [
// Runs on every render, args changes included, and ahead of the decorators:
// the whole story world is put in place here, so the provider tree only has
// to read it. Re-registering the handlers per render also means an edit to a
// handler module takes effect on the next render instead of leaving the
// worker on the set it was created with.
async (context): Promise<void> => {
const world = resolveStory(context as unknown as StoryRuntimeContext);
world.apply();
world.install(worker);
await ready;
},
],
beforeEach: () => {
clearBlockedNavigations();
resetStoryHistory();
},
};
export default preview;

View File

@@ -0,0 +1,303 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (1.3.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
'[MSW] Successfully emulated a network error for the "%s %s" request.',
request.method,
request.url,
)
return
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`,
)
}),
)
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [channel.port2])
})
}
function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(resolve, timeMs)
})
}
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

View File

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

View File

@@ -7,6 +7,8 @@
"preinstall": "npx only-allow pnpm",
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
"dev": "vite",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",
@@ -158,6 +160,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

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
import { Suspense, useCallback, useEffect, useState } from 'react';
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Router, Switch } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { ConfigProvider } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppPageProviders from 'app/AppPageProviders';
import AppShell from 'app/AppShell';
import AppLoading from 'components/AppLoading/AppLoading';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
@@ -17,22 +18,15 @@ import ROUTES from 'constants/routes';
import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { NotificationProvider } from 'hooks/useNotifications';
import { ResourceProvider } from 'hooks/useResourceAttribute';
import { StatusCodes } from 'http-status-codes';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
@@ -44,8 +38,17 @@ import defaultRoutes, {
SUPPORT_ROUTE,
} from './routes';
const appRouter = (children: ReactNode): ReactNode => (
<Router history={history}>
<CompatRouter>{children}</CompatRouter>
</Router>
);
const appLayout = (children: ReactNode): ReactNode => (
<AppLayout>{children}</AppLayout>
);
function App(): JSX.Element {
const themeConfig = useThemeConfig();
const {
user,
isFetchingUser,
@@ -451,48 +454,36 @@ function App(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{isLoggedInState && <CmdKPalette userRole={user.role} />}
{isLoggedInState && (
<ShiftHoldOverlayController userRole={user.role} />
)}
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
<KeyboardHotkeysProvider>
<AppLayout>
<PreferenceContextProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</PreferenceContextProvider>
</AppLayout>
</KeyboardHotkeysProvider>
</QueryBuilderProvider>
</ResourceProvider>
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
</CompatRouter>
</Router>
</ConfigProvider>
<AppShell
router={appRouter}
overlays={
isLoggedInState && (
<>
<CmdKPalette userRole={user.role} />
<ShiftHoldOverlayController userRole={user.role} />
</>
)
}
>
<PrivateRoute>
<AppPageProviders layout={appLayout}>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppPageProviders>
</PrivateRoute>
</AppShell>
</Sentry.ErrorBoundary>
);
}

View File

@@ -94,18 +94,23 @@ export const OnboardingV2 = Loadable(
export const DashboardsListPage = Loadable(
() =>
import(
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPage'
/* webpackChunkName: "DashboardsListPage" */ 'pages/DashboardsListPageV2'
),
);
export const DashboardPage = Loadable(
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPage'),
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardPageV2'),
);
export const DashboardWidget = Loadable(
() =>
import(/* webpackChunkName: "DashboardWidgetPage" */ 'pages/DashboardWidget'),
);
export const DashboardPanelEditorPage = Loadable(
() =>
import(
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPage/PanelEditorPage/PanelEditorPage'
/* webpackChunkName: "DashboardPanelEditorPage" */ 'pages/DashboardPageV2/PanelEditorPage/PanelEditorPage'
),
);

View File

@@ -13,6 +13,7 @@ import {
DashboardPage,
DashboardPanelEditorPage,
DashboardsListPage,
DashboardWidget,
EditRulesPage,
ErrorDetails,
ForgotPassword,
@@ -182,6 +183,13 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_WIDGET,
exact: true,
component: DashboardWidget,
isPrivate: true,
key: 'DASHBOARD_WIDGET',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,

View File

@@ -0,0 +1,27 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
/**
* @deprecated Use the generated `useGetPublicDashboardData` hook (or `getPublicDashboardData` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
try {
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardData;

View File

@@ -0,0 +1,34 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { MetricRangePayloadV5 } from 'api/v5/v5';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
/**
* @deprecated Use the generated `useGetPublicDashboardWidgetQueryRange` hook (or `getPublicDashboardWidgetQueryRange` fetcher) from
* `api/generated/services/dashboard` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
try {
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {
params: {
startTime: props.startTime,
endTime: props.endTime,
},
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardWidgetData;

View File

@@ -10522,153 +10522,6 @@ export interface SpantypesUpdatableSpanMapperGroupDTO {
name?: string | null;
}
export interface SubscriptiontypesGettableSubscriptionDTO {
/**
* @type string
*/
redirectURL: string;
}
export interface SubscriptiontypesSubscriptionUsageDayWiseDataDTO {
/**
* @type number
* @format double
*/
count?: number;
/**
* @type number
* @format double
*/
quantity?: number;
/**
* @type number
* @format double
*/
size?: number;
/**
* @type integer
* @format int64
*/
timestamp?: number;
/**
* @type number
* @format double
*/
total?: number;
/**
* @type number
* @format double
*/
unitPrice?: number;
}
export interface SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO {
/**
* @type array,null
*/
breakdown?: SubscriptiontypesSubscriptionUsageDayWiseDataDTO[] | null;
/**
* @type string
*/
type?: string;
}
export interface SubscriptiontypesSubscriptionUsageTierDTO {
/**
* @type number
* @format double
*/
quantity?: number;
/**
* @type number
* @format double
*/
tierCost?: number;
/**
* @type integer
* @format int64
*/
tierEnd?: number;
/**
* @type integer
* @format int64
*/
tierStart?: number;
/**
* @type number
* @format double
*/
unitPrice?: number;
}
export interface SubscriptiontypesSubscriptionUsageBreakdownDTO {
dayWiseBreakdown?: SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO;
/**
* @type array,null
*/
tiers?: SubscriptiontypesSubscriptionUsageTierDTO[] | null;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
}
export interface SubscriptiontypesSubscriptionUsageDetailsDTO {
/**
* @type number
* @format double
*/
baseFee?: number;
/**
* @type number
* @format double
*/
billTotal?: number;
/**
* @type array,null
*/
breakdown?: SubscriptiontypesSubscriptionUsageBreakdownDTO[] | null;
/**
* @type number
* @format double
*/
total?: number;
}
export interface SubscriptiontypesGettableSubscriptionUsageDTO {
/**
* @type integer
* @format int64
*/
billingPeriodEnd?: number;
/**
* @type integer
* @format int64
*/
billingPeriodStart?: number;
details?: SubscriptiontypesSubscriptionUsageDetailsDTO;
/**
* @type number
* @format double
*/
discount?: number;
/**
* @type string
*/
subscriptionStatus?: string;
}
export interface SubscriptiontypesPostableSubscriptionDTO {
/**
* @type string
*/
url: string;
}
export type TelemetrytypesGettableFieldKeysDTOKeysAnyOf = {
[key: string]: TelemetrytypesTelemetryFieldKeyDTO[];
};
@@ -11887,30 +11740,6 @@ export type GetStats200 = {
status: string;
};
export type GetSubscription200 = {
data: SubscriptiontypesGettableSubscriptionUsageDTO;
/**
* @type string
*/
status: string;
};
export type CreateSubscription201 = {
data: SubscriptiontypesGettableSubscriptionDTO;
/**
* @type string
*/
status: string;
};
export type UpdateSubscription200 = {
data: SubscriptiontypesGettableSubscriptionDTO;
/**
* @type string
*/
status: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};

View File

@@ -1,280 +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 {
CreateSubscription201,
GetSubscription200,
RenderErrorResponseDTO,
SubscriptiontypesPostableSubscriptionDTO,
UpdateSubscription200,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint gets the organization's subscription along with its usage and billing details.
* @summary Get the subscription.
*/
export const getSubscription = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetSubscription200>({
url: `/api/v1/subscriptions`,
method: 'GET',
signal,
});
};
export const getGetSubscriptionQueryKey = () => {
return [`/api/v1/subscriptions`] as const;
};
export const getGetSubscriptionQueryOptions = <
TData = Awaited<ReturnType<typeof getSubscription>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSubscription>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSubscriptionQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSubscription>>> = ({
signal,
}) => getSubscription(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getSubscription>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSubscriptionQueryResult = NonNullable<
Awaited<ReturnType<typeof getSubscription>>
>;
export type GetSubscriptionQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get the subscription.
*/
export function useGetSubscription<
TData = Awaited<ReturnType<typeof getSubscription>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSubscription>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSubscriptionQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get the subscription.
*/
export const invalidateGetSubscription = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSubscriptionQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint creates a subscription for the organization.
* @summary Create a subscription.
*/
export const createSubscription = (
subscriptiontypesPostableSubscriptionDTO?: BodyType<SubscriptiontypesPostableSubscriptionDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateSubscription201>({
url: `/api/v1/subscriptions`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: subscriptiontypesPostableSubscriptionDTO,
signal,
});
};
export const getCreateSubscriptionMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
const mutationKey = ['createSubscription'];
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 createSubscription>>,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> }
> = (props) => {
const { data } = props ?? {};
return createSubscription(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateSubscriptionMutationResult = NonNullable<
Awaited<ReturnType<typeof createSubscription>>
>;
export type CreateSubscriptionMutationBody =
| BodyType<SubscriptiontypesPostableSubscriptionDTO>
| undefined;
export type CreateSubscriptionMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create a subscription.
*/
export const useCreateSubscription = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
return useMutation(getCreateSubscriptionMutationOptions(options));
};
/**
* This endpoint updates the organization's subscription.
* @summary Update the subscription.
*/
export const updateSubscription = (
subscriptiontypesPostableSubscriptionDTO?: BodyType<SubscriptiontypesPostableSubscriptionDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateSubscription200>({
url: `/api/v1/subscriptions`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: subscriptiontypesPostableSubscriptionDTO,
signal,
});
};
export const getUpdateSubscriptionMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
const mutationKey = ['updateSubscription'];
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 updateSubscription>>,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> }
> = (props) => {
const { data } = props ?? {};
return updateSubscription(data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateSubscriptionMutationResult = NonNullable<
Awaited<ReturnType<typeof updateSubscription>>
>;
export type UpdateSubscriptionMutationBody =
| BodyType<SubscriptiontypesPostableSubscriptionDTO>
| undefined;
export type UpdateSubscriptionMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update the subscription.
*/
export const useUpdateSubscription = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
return useMutation(getUpdateSubscriptionMutationOptions(options));
};

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/get';
import { Dashboard } from 'types/api/dashboard/getAll';
const get = async (props: Props): Promise<SuccessResponseV2<Dashboard>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -0,0 +1,23 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { PayloadProps, Props } from 'types/api/dashboard/update';
const update = async (props: Props): Promise<SuccessResponseV2<Dashboard>> => {
try {
const response = await axios.put<PayloadProps>(`/dashboards/${props.id}`, {
...props.data,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default update;

View File

@@ -6,7 +6,6 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty } from 'lodash-es';
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -546,22 +545,20 @@ function reduceQueriesToObject(queryArray: any[]): {
/**
* Prepares V5 query range payload from GetQueryResultsProps
*/
export const prepareQueryRangePayloadV5 = (
{
query,
globalSelectedInterval,
graphType,
selectedTime,
tableParams,
variables = {},
start: startTime,
end: endTime,
formatForWeb,
originalGraphType,
fillGaps,
}: GetQueryResultsProps,
dynamicVariables: DynamicVariableSuggestion[] = [],
): PrepareQueryRangePayloadV5Result => {
export const prepareQueryRangePayloadV5 = ({
query,
globalSelectedInterval,
graphType,
selectedTime,
tableParams,
variables = {},
start: startTime,
end: endTime,
formatForWeb,
originalGraphType,
fillGaps,
dynamicVariables,
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
let legendMap: Record<string, string> = {};
const requestType = mapPanelTypeToRequestType(graphType);
let queries: QueryEnvelope[] = [];
@@ -674,9 +671,9 @@ export const prepareQueryRangePayloadV5 = (
(acc, [key, value]) => {
acc[key] = {
value,
type: dynamicVariables.some((v) => v.name === key)
? ('dynamic' as VariableType)
: undefined,
type: dynamicVariables
?.find((v) => v.name === key)
?.type?.toLowerCase() as VariableType,
};
return acc;
},

View File

@@ -0,0 +1,61 @@
import { ReactNode } from 'react';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { ResourceProvider } from 'hooks/useResourceAttribute';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import {
QueryBuilderContext,
QueryBuilderProvider,
} from 'providers/QueryBuilder';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import { AppLayer } from './types';
export interface AppPageProvidersProps {
children: ReactNode;
layout: AppLayer;
/** When set, replaces `QueryBuilderProvider` with a fixed context value. */
queryBuilder?: Partial<QueryBuilderContextType>;
}
/**
* The layers a routed page renders in, below `PrivateRoute` and inside the app
* chrome. A new provider belongs here when only pages need it, or when it has to
* sit inside `AppLayout`.
*
* One ordering constraint: `AppLayout` calls `useKeyboardHotkeys`, so the
* hotkeys provider has to stay above the layout. The rest of the order is the
* one `AppRoutes` has, kept as-is so a story and a route render the same tree.
*/
function AppPageProviders({
children,
layout,
queryBuilder,
}: AppPageProvidersProps): JSX.Element {
const hotkeys = (
<KeyboardHotkeysProvider>
<>
{layout(<PreferenceContextProvider>{children}</PreferenceContextProvider>)}
</>
</KeyboardHotkeysProvider>
);
return (
<ResourceProvider>
{queryBuilder ? (
<QueryBuilderContext.Provider
value={queryBuilder as QueryBuilderContextType}
>
{hotkeys}
</QueryBuilderContext.Provider>
) : (
<QueryBuilderProvider>{hotkeys}</QueryBuilderProvider>
)}
</ResourceProvider>
);
}
AppPageProviders.defaultProps = {
queryBuilder: undefined,
};
export default AppPageProviders;

View File

@@ -0,0 +1,56 @@
import { ReactNode } from 'react';
import { HelmetProvider } from 'react-helmet-async';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
// eslint-disable-next-line no-restricted-imports
import { Store } from 'redux';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { ThemeProvider } from 'hooks/useDarkMode';
import TimezoneProvider from 'providers/Timezone';
import { AppLayer } from './types';
export interface AppProvidersProps {
children: ReactNode;
store: Store;
queryClient: QueryClient;
appContext: AppLayer;
searchParams: AppLayer;
}
/**
* The layers that exist before the app knows anything. Mounted for the whole
* session, including while `AppProvider` is still fetching the user and the boot
* spinner is on screen, and never remounted after that.
*
* A new provider belongs here only if it holds process-wide state that does not
* depend on the user, the license or the route. One that fetches on mount would
* fire unauthenticated from here; put it in `AppShell` or lower.
*/
function AppProviders({
children,
store,
queryClient,
appContext,
searchParams,
}: AppProvidersProps): JSX.Element {
return (
<HelmetProvider>
{searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
{appContext(children)}
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
)}
</HelmetProvider>
);
}
export default AppProviders;

View File

@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
import { ConfigProvider } from 'antd';
import { useThemeConfig } from 'hooks/useDarkMode';
import { NotificationProvider } from 'hooks/useNotifications';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { AppLayer } from './types';
export interface AppShellProps {
children: ReactNode;
router: AppLayer;
/** Mounted beside the routed content: the command palette and its siblings. */
overlays?: ReactNode;
}
/**
* The layers between a resolved session and a page. Mounted once the boot
* fetches settle, above `PrivateRoute`, so it also covers the redirects and the
* not-found route, and it survives every navigation.
*
* A new provider belongs here if it needs the router or the user and has to
* outlive the page: a global overlay, a shortcut host, anything one route opens
* and the next one keeps.
*
* `ConfigProvider` reads `useThemeConfig`, which needs `ThemeProvider` above it,
* so the antd theme is settled here instead of by the caller.
*/
function AppShell({ children, router, overlays }: AppShellProps): JSX.Element {
const themeConfig = useThemeConfig();
return (
<ConfigProvider theme={themeConfig}>
{router(
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>,
)}
</ConfigProvider>
);
}
AppShell.defaultProps = {
overlays: undefined,
};
export default AppShell;

View File

@@ -0,0 +1,3 @@
import { ReactNode } from 'react';
export type AppLayer = (children: ReactNode) => ReactNode;

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { X } from '@signozhq/icons';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';

View File

@@ -5,16 +5,16 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ViewMenuAction } from 'container/WidgetCard/config';
import GridCard from 'container/WidgetCard/Card';
import { Card } from 'container/WidgetCard/styles';
import { ViewMenuAction } from 'container/GridCardLayout/config';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card } from 'container/GridCardLayout/styles';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { isEmpty } from 'lodash-es';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { CaptureDataProps } from '../CeleryTaskDetail/CeleryTaskDetail';

View File

@@ -5,15 +5,15 @@ import { useHistory, useLocation } from 'react-router-dom';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ViewMenuAction } from 'container/WidgetCard/config';
import GridCard from 'container/WidgetCard/Card';
import { Card } from 'container/WidgetCard/styles';
import { ViewMenuAction } from 'container/GridCardLayout/config';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card } from 'container/GridCardLayout/styles';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { UpdateTimeInterval } from 'store/actions';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { CaptureDataProps } from '../CeleryTaskDetail/CeleryTaskDetail';

View File

@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
import { Card } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { CardContainer } from 'container/WidgetCard/styles';
import { CardContainer } from 'container/GridCardLayout/styles';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { AppState } from 'store/reducers';

View File

@@ -1,7 +1,7 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { getWidgetQuery } from 'pages/MessagingQueues/MQDetails/MetricPage/MetricPageUtil';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';

View File

@@ -6,9 +6,9 @@ import { Col, Row } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ViewMenuAction } from 'container/WidgetCard/config';
import GridCard from 'container/WidgetCard/Card';
import { Card } from 'container/WidgetCard/styles';
import { ViewMenuAction } from 'container/GridCardLayout/config';
import GridCard from 'container/GridCardLayout/GridCard';
import { Card } from 'container/GridCardLayout/styles';
import { Button } from 'container/MetricsApplication/Tabs/styles';
import { useGraphClickHandler } from 'container/MetricsApplication/Tabs/util';
import { useIsDarkMode } from 'hooks/useDarkMode';

View File

@@ -8,7 +8,7 @@ import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getQueryPayloadFromWidgetsData } from 'pages/Celery/CeleryOverview/CeleryOverviewUtils';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';

View File

@@ -1,7 +1,7 @@
import { QueryParams } from 'constants/query';
import { History, Location } from 'history';
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuidv4 } from 'uuid';

View File

@@ -4,9 +4,10 @@ import { useSelector } from 'react-redux';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/WidgetCard/hooks/useResolveQuery';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
@@ -79,6 +80,7 @@ export function useNavigateToExplorer(): (
);
const { getUpdatedQuery } = useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(
@@ -110,6 +112,7 @@ export function useNavigateToExplorer(): (
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
})
.then((query) => {
preparedQuery = query;
@@ -133,6 +136,13 @@ export function useNavigateToExplorer(): (
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},
[prepareQuery, minTime, maxTime, getUpdatedQuery, notifications],
[
prepareQuery,
minTime,
maxTime,
getUpdatedQuery,
dashboardData,
notifications,
],
);
}

View File

@@ -28,7 +28,7 @@ import {
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { generateGridTitle } from 'utils/generateGridTitle';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import isEqual from 'lodash-es/isEqual';

View File

@@ -27,7 +27,7 @@ import {
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
} from 'constants/antlrQueryConstants';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
@@ -258,7 +258,10 @@ function QuerySearch({
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
@@ -1185,8 +1188,8 @@ function QuerySearch({
);
// Add dynamic variables suggestions for the current key
const variableName = dashboardDynamicVariables.find(
(variable) => variable.attribute === keyName,
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === keyName,
)?.name;
if (variableName) {

View File

@@ -17,6 +17,12 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
// tests aren't paced by it; coalescing semantics stay intact.
jest.mock('../QuerySearch/constants', () => ({

View File

@@ -21,6 +21,12 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
const handleRunQuery = jest.fn();
return {
@@ -146,16 +152,15 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>,
);
// Wait for the mount fetch specifically. A debounced fetch from an earlier test
// can still land after mockClear(), so waiting on "any call" would let this
// assert against that one instead and make the result order-dependent.
await waitFor(
() =>
expect(mockedGetKeysOnMount).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.LOGS, searchText: '' }),
),
{ timeout: 2000 },
);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
timeout: 2000,
});
const lastArgs = mockedGetKeysOnMount.mock.calls[
mockedGetKeysOnMount.mock.calls.length - 1
]?.[0] as { signal: unknown; searchText: string };
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
});
it('calls provided onRun on Mod-Enter', async () => {

View File

@@ -31,6 +31,12 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: { keys: {} } },

View File

@@ -525,34 +525,6 @@ export const convertFiltersToExpressionWithExistingQuery = (
};
};
/**
* Canonical name for a comparison's operator, limited to the equality and
* membership forms. Every other shape (LIKE, BETWEEN, EXISTS, CONTAINS, REGEXP,
* the ordering operators) returns undefined, so an operator-restricted removal
* leaves it in place.
*
* The ANTLR4 runtime returns null for an absent token or rule despite the
* non-nullable TypeScript signatures.
*/
const getComparisonOperator = (ctx: ComparisonContext): string | undefined => {
if ((ctx.inClause() as unknown) !== null) {
return 'in';
}
if ((ctx.notInClause() as unknown) !== null) {
return 'not in';
}
if ((ctx.EQUALS() as unknown) !== null) {
return '=';
}
if (
(ctx.NOT_EQUALS() as unknown) !== null ||
(ctx.NEQ() as unknown) !== null
) {
return '!=';
}
return undefined;
};
/**
* Removes clauses for specified keys from a filter query expression.
*
@@ -570,16 +542,12 @@ const getComparisonOperator = (ctx: ComparisonContext): string | undefined => {
* - `true`: removes only the first clause whose value contains any `$`.
* - `string` (e.g. `"$service.name"`): removes only the clause whose value exactly
* matches that string — preferred when the specific variable reference is known.
* @param operatorsToRemove - When given, restricts removal to clauses whose operator
* is in this set (`=`, `!=`, `in`, `not in`); every other clause on the key is kept.
* Omit to remove a matching key's clauses whatever their operator.
* @returns The rewritten expression, or an empty string if all clauses were removed.
*/
export const removeKeysFromExpression = (
expression: string,
keysToRemove: string[],
removeOnlyVariableExpressions: string | boolean = false,
operatorsToRemove?: string[],
): string => {
if (!keysToRemove || keysToRemove.length === 0) {
return expression;
@@ -589,9 +557,6 @@ export const removeKeysFromExpression = (
}
const keysSet = new Set(keysToRemove.map((k) => k.trim().toLowerCase()));
const operatorsSet = operatorsToRemove
? new Set(operatorsToRemove.map((op) => op.trim().toLowerCase()))
: null;
// Tracks keys for which a variable expression has already been removed.
// Having multiple $-value clauses for the same key is invalid; we remove at most one.
const removedVariableKeys = new Set<string>();
@@ -693,13 +658,6 @@ export const removeKeysFromExpression = (
return src(ctx);
}
if (operatorsSet) {
const operator = getComparisonOperator(ctx);
if (!operator || !operatorsSet.has(operator)) {
return src(ctx);
}
}
if (removeOnlyVariableExpressions) {
// Scope the value check to value nodes only — not the full comparison text —
// so a key that contains '$' does not trigger removal when the value is a

View File

@@ -1,526 +0,0 @@
import {
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
} from 'components/QueryBuilderV2/utils';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import {
Query,
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
applyCheckboxToggle,
clearFilterFromQuery,
deriveCheckboxState,
getNotInOperator,
} from './checkboxFilterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
const KEY = 'service.name';
/**
* Mini test framework
* -------------------
* `filters.items` is the source of truth the checkbox algebra mutates.
* `filter.expression` is the derived value the backend actually reads, and it is
* authoritatively rebuilt from the items on every URL round trip
* (`useGetCompositeQueryParam` -> `convertFiltersToExpressionWithExistingQuery`).
* That rebuild is additive, so `applyCheckboxToggle` re-derives its own clauses
* into the expression itself: otherwise the round trip resurrects a clause the
* toggle removed, or appends a duplicate of one it replaced.
*
* So a case does not assert the intermediate expression the toggle emits. It
* asserts the pair that has to stay consistent:
* - `items` : exact structured clauses after the toggle
* - `expression` : the expression AFTER the round trip, which is what ships
*
* `runToggle` runs the real reducer, then feeds its output through the real
* converter to get the shipped expression.
*/
type SimpleItem = {
key: string;
op: string;
value: TagFilterItem['value'];
};
function toTagItem(item: SimpleItem, idx: number): TagFilterItem {
return {
id: `id-${idx}`,
key: { key: item.key, type: 'tag' } as TagFilterItem['key'],
op: item.op,
value: item.value,
};
}
// Serialises items into an expression (via the app's own converter) so a case's
// starting state is self-consistent (items and expression agree), the way it
// would be in the app after a prior round trip.
const serializeItems = (items: SimpleItem[]): string =>
convertFiltersToExpression({ items: items.map(toTagItem), op: 'AND' })
.expression;
function buildQuery(items: SimpleItem[], expression: string): Query {
return {
builder: {
queryData: [
{
filters: { items: items.map(toTagItem), op: 'AND' },
filter: { expression },
},
],
},
} as unknown as Query;
}
// Simulates the URL round trip: rebuild the shipped expression from the items,
// reconciled against whatever expression the toggle left behind. Trimmed to
// absorb a converter quirk that leaves a trailing space when it widens an
// operator in place (e.g. `=` -> `IN`).
function roundTripExpression(
items: TagFilterItem[],
emittedExpression: string,
): string {
const filters: TagFilter = { items, op: 'AND' };
const { filter } = convertFiltersToExpressionWithExistingQuery(
filters,
emittedExpression,
);
return (filter?.expression ?? '').trim();
}
interface ToggleAction {
value: string;
checked: boolean;
isOnlyOrAllClicked?: boolean;
previousState?: CheckedState;
sectionType?: SectionType;
source?: QuickFiltersSource;
attributeValues?: string[];
}
interface ToggleCase {
name: string;
initial?: { items?: SimpleItem[]; expression?: string };
action: ToggleAction;
expected: { items: SimpleItem[]; expression: string };
}
function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
const initialItems = c.initial?.items ?? [];
const initialExpression =
c.initial?.expression ?? serializeItems(initialItems);
const result = applyCheckboxToggle({
currentQuery: buildQuery(initialItems, initialExpression),
activeQueryIndex: 0,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
value: c.action.value,
checked: c.action.checked,
isOnlyOrAllClicked: c.action.isOnlyOrAllClicked ?? false,
previousState: c.action.previousState,
sectionType: c.action.sectionType,
});
const active = result.builder.queryData[0];
const items = active?.filters?.items ?? [];
return {
items: items.map((item) => ({
key: item.key?.key ?? '',
op: item.op,
value: item.value,
})),
expression: roundTripExpression(items, active?.filter?.expression ?? ''),
};
}
// Flat list. Every row asserts both the structured items and the shipped
// (round-tripped) expression, which must stay in sync.
const TOGGLE_CASES: ToggleCase[] = [
{
name: 'no clause, checked -> IN',
action: { value: 'a', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `service.name in ['a']`,
},
},
{
name: 'no clause, unchecked -> NOT IN',
action: { value: 'a', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: 'a' }],
expression: `service.name not in ['a']`,
},
},
{
name: 'no clause, unchecked on infra -> not in',
action: {
value: 'a',
checked: false,
source: QuickFiltersSource.INFRA_MONITORING,
},
// `nin` is what the source asks for, but re-deriving the expression
// normalises it. Nothing observes the difference: both infra pages send
// `filter.expression` and never `filters.items`.
expected: {
items: [{ key: KEY, op: 'not in', value: 'a' }],
expression: `service.name not in ['a']`,
},
},
{
name: 'IN, check another value -> appended',
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
{
name: 'IN, check when value is scalar -> promoted to array',
initial: { items: [{ key: KEY, op: 'in', value: 'a' }] },
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
{
name: 'IN, uncheck one of many -> filtered out',
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
action: { value: 'a', checked: false },
expected: {
items: [{ key: KEY, op: 'in', value: ['b'] }],
expression: `service.name in ['b']`,
},
},
{
name: 'IN, uncheck last value in array -> clause gone',
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: 'IN, uncheck scalar value -> clause gone',
initial: { items: [{ key: KEY, op: 'in', value: 'a' }] },
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: 'IN, uncheck in RELATED section -> replaced by NOT IN for that value',
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
action: { value: 'a', checked: false, sectionType: SectionType.RELATED },
expected: {
items: [{ key: KEY, op: 'not in', value: 'a' }],
expression: `service.name not in ['a']`,
},
},
{
name: 'NOT IN, was unchecked then checked -> replaced by IN for that value',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'b', checked: true, previousState: 'unchecked' },
expected: {
items: [{ key: KEY, op: 'in', value: 'b' }],
expression: `service.name in ['b']`,
},
},
{
name: 'NOT IN, re-checking an excluded value clears it, not flips it to IN',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'a', checked: true, previousState: 'unchecked' },
expected: { items: [], expression: '' },
},
{
name: 'NOT IN, re-checking one of several excluded values keeps the rest',
initial: { items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }] },
action: { value: 'a', checked: true, previousState: 'unchecked' },
expected: {
items: [{ key: KEY, op: 'not in', value: ['b'] }],
expression: `service.name not in ['b']`,
},
},
{
name: 'NOT IN, exclude another value -> appended',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'b', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: 'NOT IN, exclude when scalar -> promoted to array',
initial: { items: [{ key: KEY, op: 'not in', value: 'a' }] },
action: { value: 'b', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: 'NOT IN, check an excluded value -> removed from array',
initial: { items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }] },
action: { value: 'a', checked: true },
expected: {
items: [{ key: KEY, op: 'not in', value: ['b'] }],
expression: `service.name not in ['b']`,
},
},
{
name: 'NOT IN, check last excluded value in array -> clause gone',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'a', checked: true },
expected: { items: [], expression: '' },
},
{
name: 'NOT IN, check excluded scalar value -> clause gone',
initial: { items: [{ key: KEY, op: 'not in', value: 'a' }] },
action: { value: 'a', checked: true },
expected: { items: [], expression: '' },
},
{
name: '= check another value -> promoted to IN array',
initial: { items: [{ key: KEY, op: '=', value: 'a' }] },
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
{
name: '= uncheck -> clause gone',
initial: { items: [{ key: KEY, op: '=', value: 'a' }] },
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: '!= exclude another value -> promoted to NOT IN array',
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
action: { value: 'b', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: '!= exclude another value on infra -> not in array',
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
action: {
value: 'b',
checked: false,
source: QuickFiltersSource.INFRA_MONITORING,
},
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: '!= check -> clause gone',
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
action: { value: 'a', checked: true },
expected: { items: [], expression: '' },
},
{
name: 'Only with no clause -> IN scalar',
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
expected: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `service.name in ['a']`,
},
},
{
name: 'Only replaces a multi-value IN with a single value',
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
expected: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `service.name in ['a']`,
},
},
{
name: 'All (clicking the sole selected value) -> clause gone',
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
expected: { items: [], expression: '' },
},
{
name: 'dropping the last clause keeps other keys in the expression',
initial: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `${KEY} = 'a' AND http.method = 'GET'`,
},
action: { value: 'a', checked: false },
// The seeded items omit the http.method clause the expression carries;
// re-deriving reconciles it back, which is why items is not empty here.
expected: {
items: [{ key: 'http.method', op: '=', value: 'GET' }],
expression: `http.method = 'GET'`,
},
},
{
name: 'dropping the last clause strips the prefixed spelling too',
initial: {
items: [{ key: 'resource.service.name', op: 'in', value: 'a' }],
expression: `resource.service.name = 'a'`,
},
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: 'removing the value must keep a free-form clause on the same key',
initial: {
items: [{ key: KEY, op: '=', value: 'a' }],
expression: `${KEY} = 'a' AND ${KEY} CONTAINS 'keepme'`,
},
action: { value: 'a', checked: false },
expected: {
items: [{ key: KEY, op: 'contains', value: 'keepme' }],
expression: `service.name CONTAINS 'keepme'`,
},
},
{
name: 'a second clause on the same key must not survive an add',
initial: {
items: [{ key: KEY, op: 'in', value: ['a'] }],
expression: `${KEY} IN ['a'] AND ${KEY} != 'z'`,
},
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
];
describe('applyCheckboxToggle (items + shipped expression stay in sync)', () => {
it.each(TOGGLE_CASES)('$name', (c) => {
const got = runToggle(c);
expect(got.items).toStrictEqual(c.expected.items);
expect(got.expression).toBe(c.expected.expression);
});
});
describe('getNotInOperator', () => {
it('returns short "nin" for infra monitoring', () => {
expect(getNotInOperator(QuickFiltersSource.INFRA_MONITORING)).toBe('nin');
});
it('returns long "not in" for other sources', () => {
expect(getNotInOperator(QuickFiltersSource.LOGS_EXPLORER)).toBe('not in');
expect(getNotInOperator(QuickFiltersSource.TRACES_EXPLORER)).toBe('not in');
});
});
describe('deriveCheckboxState', () => {
const attributeValues = ['a', 'b', 'c'];
const state = (items: TagFilterItem[] | undefined): Record<string, boolean> =>
deriveCheckboxState({ attributeValues, filterItems: items, filterKey: KEY });
it('no clause for key -> everything checked', () => {
expect(state([])).toStrictEqual({ a: true, b: true, c: true });
expect(state(undefined)).toStrictEqual({ a: true, b: true, c: true });
});
it('unrelated clause only -> everything checked', () => {
expect(
state([toTagItem({ key: 'other', op: 'in', value: ['a'] }, 0)]),
).toStrictEqual({ a: true, b: true, c: true });
});
it('IN [list] -> only listed values checked', () => {
expect(
state([toTagItem({ key: KEY, op: 'in', value: ['a', 'c'] }, 0)]),
).toStrictEqual({ a: true, b: false, c: true });
});
it('= "value" -> only that value checked', () => {
expect(
state([toTagItem({ key: KEY, op: '=', value: 'b' }, 0)]),
).toStrictEqual({ a: false, b: true, c: false });
});
it('NOT IN [list] -> everything except excluded checked', () => {
expect(
state([toTagItem({ key: KEY, op: 'not in', value: ['a'] }, 0)]),
).toStrictEqual({ a: false, b: true, c: true });
});
it('!= "value" -> everything except that value checked', () => {
expect(
state([toTagItem({ key: KEY, op: '!=', value: 'b' }, 0)]),
).toStrictEqual({ a: true, b: false, c: true });
});
it('matches by base key across context prefixes', () => {
expect(
state([
toTagItem({ key: 'resource.service.name', op: 'in', value: ['a'] }, 0),
]),
).toStrictEqual({ a: true, b: false, c: false });
});
it('coerces boolean / number values to string keys', () => {
expect(
deriveCheckboxState({
attributeValues: ['true', '42'],
filterItems: [toTagItem({ key: KEY, op: '=', value: true }, 0)],
filterKey: KEY,
}),
).toStrictEqual({ true: true, '42': false });
});
});
describe('clearFilterFromQuery', () => {
it('removes the key from items and expression at the active index only', () => {
const query = {
builder: {
queryData: [
{
filters: {
items: [
toTagItem({ key: KEY, op: 'in', value: ['a'] }, 0),
toTagItem({ key: 'http.method', op: '=', value: 'GET' }, 1),
],
op: 'AND',
},
filter: { expression: `${KEY} = 'a' AND http.method = 'GET'` },
},
{
filters: {
items: [toTagItem({ key: KEY, op: 'in', value: ['a'] }, 2)],
op: 'AND',
},
filter: { expression: `${KEY} = 'a'` },
},
],
},
} as unknown as Query;
const result = clearFilterFromQuery({
currentQuery: query,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
activeQueryIndex: 0,
});
const active = result.builder.queryData[0];
expect(active.filters?.items).toStrictEqual([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
}),
]);
expect(active.filter?.expression).toBe(`http.method = 'GET'`);
// Other queries keep both halves: stripping their expression while leaving
// their items alone only churned a clause the round trip put straight back.
const other = result.builder.queryData[1];
expect(other.filters?.items).toHaveLength(1);
expect(other.filter?.expression).toBe(`${KEY} = 'a'`);
});
});

View File

@@ -1,8 +1,5 @@
/* eslint-disable sonarjs/no-identical-functions */
import {
convertFiltersToExpressionWithExistingQuery,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
@@ -13,33 +10,13 @@ import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { getKeySpellings, isKeyMatch } from './utils';
import { isKeyMatch } from './utils';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
// The operators this algebra emits, and so the only ones it may rewrite out of an
// expression. A hand-written clause on the same key (CONTAINS, EXISTS, a range) is
// none of its business and has to survive a toggle.
const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
/**
* Drops this filter's own clauses for `key` from `expression`, leaving every other
* key and any clause the checkbox does not manage untouched. Matches all context
* prefixes, since `isKeyMatch` treats `service.name` and `resource.service.name` as
* the same filter but expression rewrites match keys literally.
*/
function removeManagedClauses(expression: string, key: string): string {
return removeKeysFromExpression(
expression,
getKeySpellings(key),
false,
MANAGED_OPERATORS,
);
}
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
@@ -125,8 +102,8 @@ export function deriveCheckboxState({
}
/**
* Returns a new query with this filter's clauses for the attribute key removed from
* the active query, both from the structured filter items and the raw expression.
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
*/
export function clearFilterFromQuery({
currentQuery,
@@ -141,28 +118,24 @@ export function clearFilterFromQuery({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filter: {
expression: removeManagedClauses(
item.filter?.expression ?? '',
filter.attributeKey.key,
),
},
filters: {
...item.filters,
items:
item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || [],
op: item.filters?.op || 'AND',
},
};
}),
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
}
@@ -221,6 +194,12 @@ export function applyCheckboxToggle({
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
filter.attributeKey.key,
]);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
@@ -288,6 +267,12 @@ export function applyCheckboxToggle({
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (isArray(currentFilter.value)) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
@@ -324,10 +309,9 @@ export function applyCheckboxToggle({
? currentFilter.value.includes(value)
: currentFilter.value === value;
// When clicking an unchecked value that is not itself excluded, the user
// wants to SELECT it: replace the NOT IN filter with IN [value]. A value
// that IS in the exclusion list falls through to the removal branch below.
if (previousState === 'unchecked' && checked && !isValueInFilter) {
// When clicking unchecked "Other" item, user wants to SELECT it
// Replace NOT IN filter with IN [value]
if (previousState === 'unchecked' && checked) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
@@ -340,6 +324,12 @@ export function applyCheckboxToggle({
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (!checked || !isValueInFilter) {
// Add to NOT IN when:
// - checked=false (user explicitly unchecked to exclude)
@@ -379,6 +369,12 @@ export function applyCheckboxToggle({
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
@@ -388,6 +384,16 @@ export function applyCheckboxToggle({
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
@@ -450,18 +456,6 @@ export function applyCheckboxToggle({
}
}
if (query) {
const synced = convertFiltersToExpressionWithExistingQuery(
query.filters ?? { items: [], op: 'AND' },
removeManagedClauses(
query.filter?.expression ?? '',
filter.attributeKey.key,
),
);
query.filter = synced.filter;
query.filters = synced.filters;
}
return {
...currentQuery,
builder: {

View File

@@ -39,16 +39,3 @@ export function isKeyMatch(
): boolean {
return getKeyWithoutPrefix(itemKey) === getKeyWithoutPrefix(filterKey);
}
/**
* Every spelling of a key that `isKeyMatch` treats as equal: the base name plus
* each context-prefixed form. Expression rewrites match keys literally, so they
* need the whole list where the items side only needs `isKeyMatch`.
*/
export function getKeySpellings(key: string | undefined): string[] {
const base = getKeyWithoutPrefix(key);
if (!base) {
return [];
}
return [base, ...FIELD_CONTEXT_PREFIXES.map((prefix) => `${prefix}.${base}`)];
}

View File

@@ -1,5 +1,5 @@
import { Typography } from '@signozhq/ui/typography';
import { timeItems } from 'constants/timePreference';
import { timeItems } from 'container/NewWidget/RightContainer/timeItems';
export const menuItems = timeItems.map((item) => ({
key: item.enum,

View File

@@ -6,7 +6,7 @@ import { Typography } from '@signozhq/ui/typography';
import TimeItems, {
timePreferance,
timePreferenceType,
} from 'constants/timePreference';
} from 'container/NewWidget/RightContainer/timeItems';
import { menuItems } from './config';

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { CircleAlert } from '@signozhq/icons';
import { ThresholdProps } from 'types/api/widgets/threshold';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { getBackgroundColorAndThresholdCheck } from './utils';

View File

@@ -1,5 +1,5 @@
import { evaluateThresholdWithConvertedValue } from 'container/WidgetCard/Panels/TablePanel/utils';
import { ThresholdProps } from 'types/api/widgets/threshold';
import { evaluateThresholdWithConvertedValue } from 'container/GridTableComponent/utils';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
function doesValueSatisfyThreshold(
rawValue: number,

View File

@@ -1,6 +1,6 @@
import Uplot from 'components/Uplot';
import GridTableComponent from 'container/WidgetCard/Panels/TablePanel';
import GridValueComponent from 'container/WidgetCard/Panels/ValuePanel';
import GridTableComponent from 'container/GridTableComponent';
import GridValueComponent from 'container/GridValueComponent';
import LogsPanelComponent from 'container/LogsPanelTable/LogsPanelComponent';
import TracesTableComponent from 'container/TracesTableComponent/TracesTableComponent';
import { DataSource } from 'types/common/queryBuilder';

View File

@@ -16,6 +16,7 @@ const ROUTES = {
APPLICATION: '/services',
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',
DASHBOARD_WIDGET: '/dashboard/:dashboardId/:widgetId',
DASHBOARD_PANEL_EDITOR: '/dashboard/:dashboardId/panel/:panelId',
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',

View File

@@ -2,7 +2,7 @@ import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AlertListTabs } from 'pages/AlertList/types';
import { NEW_PANEL_ID } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
import { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import { matchPath } from 'react-router-dom';
/**

View File

@@ -6,7 +6,7 @@ import {
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/WidgetCard/Card';
import GridCard from 'container/GridCardLayout/GridCard';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { isEqual } from 'lodash-es';

View File

@@ -1,7 +1,7 @@
import { Card } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import GridCard from 'container/WidgetCard/Card';
import { Widgets } from 'types/api/widgets/widget';
import GridCard from 'container/GridCardLayout/GridCard';
import { Widgets } from 'types/api/dashboard/getAll';
function MetricOverTimeGraph({
widget,

View File

@@ -11,10 +11,10 @@ import {
getStatusCodeBarChartWidgetData,
statusCodeWidgetInfo,
} from 'container/ApiMonitoring/utils';
import BarChart from 'lib/visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/WidgetCard/Card/utils';
import { useGraphClickToShowButton } from 'container/WidgetCard/hooks/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/WidgetCard/hooks/useNavigateToExplorerPages';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -23,7 +23,7 @@ import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';

View File

@@ -1,7 +1,7 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'lib/visualization/panels/utils/baseConfigBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';

View File

@@ -17,7 +17,7 @@ jest.mock('container/ApiMonitoring/utils', () => ({
getGroupByFiltersFromGroupByValues: jest.fn(),
}));
jest.mock('container/WidgetCard/Card', () => ({
jest.mock('container/GridCardLayout/GridCard', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ customOnRowClick }) => (
<div data-testid="grid-card-mock">

View File

@@ -21,12 +21,15 @@ interface MockQueryResult {
}
// Mocks
jest.mock('lib/visualization/charts/BarChart/BarChart', () => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}),
);
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
@@ -40,7 +43,7 @@ jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
}),
}));
jest.mock('container/WidgetCard/hooks/useGraphClickToShowButton', () => ({
jest.mock('container/GridCardLayout/useGraphClickToShowButton', () => ({
useGraphClickToShowButton: (): {
componentClick: boolean;
htmlRef: HTMLElement | null;
@@ -50,7 +53,7 @@ jest.mock('container/WidgetCard/hooks/useGraphClickToShowButton', () => ({
}),
}));
jest.mock('container/WidgetCard/hooks/useNavigateToExplorerPages', () => ({
jest.mock('container/GridCardLayout/useNavigateToExplorerPages', () => ({
__esModule: true,
default: (): { navigateToExplorerPages: jest.Mock } => ({
navigateToExplorerPages: jest.fn(),

View File

@@ -10,7 +10,7 @@ import {
} from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GraphClickMetaData } from 'container/WidgetCard/hooks/useNavigateToExplorerPages';
import { GraphClickMetaData } from 'container/GridCardLayout/useNavigateToExplorerPages';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { convertNanoToMilliseconds } from 'container/MetricsExplorer/Summary/utils';
import dayjs from 'dayjs';
@@ -18,7 +18,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { cloneDeep } from 'lodash-es';
import { ArrowUpDown, ChevronDown, ChevronRight, Info } from '@signozhq/icons';
import { getWidgetQuery } from 'pages/MessagingQueues/MQDetails/MetricPage/MetricPageUtil';
import { Widgets } from 'types/api/widgets/widget';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import {
BaseAutocompleteData,

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import { Card, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import BarChart from 'lib/visualization/charts/BarChart/BarChart';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';

View File

@@ -1,7 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'lib/visualization/panels/utils/baseConfigBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -98,7 +98,7 @@ jest.mock('api/channels/getAll', () => ({
}));
// Mock alert format categories
jest.mock('constants/formats/alertFormatCategories', () => ({
jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
getCategoryByOptionId: jest.fn(() => ({ name: 'bytes' })),
getCategorySelectOptionByName: jest.fn(() => [
{ label: 'Bytes', value: 'bytes' },

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryParams } from 'constants/query';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
import PlotTag from 'components/PlotTag/PlotTag';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { AppState } from 'store/reducers';

View File

@@ -44,7 +44,7 @@ jest.mock(
},
);
jest.mock(
'components/PlotTag/PlotTag',
'container/NewWidget/LeftContainer/WidgetGraph/PlotTag',
() =>
function MockPlotTag(props: any): JSX.Element {
return (

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import BarChartTooltip from 'lib/uPlotV2/components/Tooltip/BarChartTooltip';
import {
BarTooltipProps,
@@ -8,7 +8,7 @@ import {
import { StackMode } from 'lib/uPlotV2/config/types';
import { BarChartProps } from 'lib/visualization/charts/types';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const {

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef } from 'react';
import ChartLayout from 'lib/visualization/layout/ChartLayout/ChartLayout';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
import {
LegendPosition,
@@ -13,8 +13,8 @@ import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartWrapperProps } from 'lib/visualization/charts/types';
import { useChartStacking } from 'lib/visualization/charts/ChartWrapper/useChartStacking';
import { ChartWrapperProps } from '../types';
import { useChartStacking } from './useChartStacking';
const TOOLTIP_WIDTH_PADDING = 120;
const TOOLTIP_MIN_WIDTH = 300;

View File

@@ -3,7 +3,7 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { useChartStacking } from 'lib/visualization/charts/ChartWrapper/useChartStacking';
import { useChartStacking } from '../useChartStacking';
type Hooks = Record<string, (...args: unknown[]) => void>;

View File

@@ -10,7 +10,7 @@ import { StackMode } from 'lib/uPlotV2/config/types';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from 'lib/visualization/charts/utils/stackSeriesUtils';
import { stackSeries } from '../utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {

View File

@@ -1,12 +1,12 @@
import { useCallback } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import HistogramTooltip from 'lib/uPlotV2/components/Tooltip/HistogramTooltip';
import {
HistogramTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { HistogramChartProps } from 'lib/visualization/charts/types';
import { HistogramChartProps } from '../types';
export default function Histogram(props: HistogramChartProps): JSX.Element {
const {

View File

@@ -9,18 +9,15 @@ import { useResizeObserver } from 'hooks/useDimensions';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { PieChartProps, PieSlice } from '../types';
import { calculateChartDimensions } from '../utils';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import styles from 'lib/visualization/charts/Pie/Pie.module.scss';
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
import {
getDonutGeometry,
getFillColor,
} from 'lib/visualization/charts/Pie/utils';
import { usePieInteractions } from '../../hooks/usePieInteractions';
import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { PieTooltipData } from './types';
import { getDonutGeometry, getFillColor } from './utils';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the

View File

@@ -2,9 +2,9 @@ import type { MouseEvent as ReactMouseEvent } from 'react';
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { PieSlice } from 'lib/visualization/charts/types';
import { PieSlice } from '../types';
import { getArcGeometry } from 'lib/visualization/charts/Pie/utils';
import { getArcGeometry } from './utils';
// Slices below this share of the total don't get a leader label (too cramped).
const MIN_LABEL_SHARE = 0.03;

View File

@@ -1,7 +1,7 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { getScaledFontSize } from 'lib/visualization/charts/Pie/utils';
import { getScaledFontSize } from './utils';
interface PieCenterLabelProps {
/** Sum of the visible slice values, shown in the donut hole. */

View File

@@ -4,8 +4,8 @@ import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieSlice } from 'lib/visualization/charts/types';
import Pie from 'lib/visualization/charts/Pie/Pie';
import { PieSlice } from '../../types';
import Pie from '../Pie';
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PieSlice } from 'lib/visualization/charts/types';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import { PieSlice } from '../../types';
import PieArc from '../PieArc';
jest.mock('components/Graph/yAxisConfig', () => ({
// Echo the raw value so assertions are deterministic.

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import PieCenterLabel from '../PieCenterLabel';
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn(),

View File

@@ -4,7 +4,7 @@ import {
getFillColor,
getScaledFontSize,
lightenColor,
} from 'lib/visualization/charts/Pie/utils';
} from '../utils';
describe('Pie utils', () => {
describe('getDonutGeometry', () => {

View File

@@ -8,7 +8,7 @@ import {
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from 'lib/visualization/charts/Pie/types';
} from './types';
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import TimeSeriesTooltip from 'lib/uPlotV2/components/Tooltip/TimeSeriesTooltip';
import {
TimeSeriesTooltipProps,
@@ -8,7 +8,7 @@ import {
import { StackMode } from 'lib/uPlotV2/config/types';
import { TimeSeriesChartProps } from 'lib/visualization/charts/types';
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, stack = StackMode.None, ...rest } = props;

View File

@@ -1,6 +1,6 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { calculateChartDimensions } from '../utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>

View File

@@ -1,4 +1,3 @@
import uPlot from 'uplot';
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
@@ -110,13 +109,3 @@ export interface PieChartProps {
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
'data-testid'?: string;
}
/** A uPlot series enriched with the aggregates the legend table shows. */
export type ExtendedChartDataset = uPlot.Series & {
show: boolean;
sum: number;
avg: number;
min: number;
max: number;
index: number;
};

View File

@@ -1,4 +1,4 @@
import { sortByMeanDesc } from 'lib/visualization/charts/utils/sortByMeanDesc';
import { sortByMeanDesc } from '../sortByMeanDesc';
interface Item {
name: string;

View File

@@ -2,7 +2,7 @@ import { AlignedData } from 'uplot';
import { StackMode } from 'lib/uPlotV2/config/types';
import { stackSeries } from 'lib/visualization/charts/utils/stackSeriesUtils';
import { stackSeries } from '../stackSeriesUtils';
const includeAll = (): boolean => false;

View File

@@ -6,15 +6,16 @@ import { ResizeTable } from 'components/ResizeTable';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { toast } from '@signozhq/ui/sonner';
import { getChartManagerColumns } from 'lib/visualization/components/ChartManager/getChartMangerColumns';
import {
ExtendedChartDataset,
getDefaultTableDataSet,
} from 'lib/visualization/components/ChartManager/utils';
import { getChartManagerColumns } from './getChartMangerColumns';
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
import 'lib/visualization/components/ChartManager/ChartManager.styles.scss';
import './ChartManager.styles.scss';
interface ChartManagerProps {
config: UPlotConfigBuilder;
@@ -52,6 +53,7 @@ export default function ChartManager({
onToggleSeriesVisibility,
syncSeriesVisibilityToLocalStorage,
} = usePlotContext();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const [tableDataSet, setTableDataSet] = useState<ExtendedChartDataset[]>(() =>
getDefaultTableDataSet(
@@ -117,6 +119,7 @@ export default function ChartManager({
onToggleSeriesOnOff: handleToggleSeriesOnOff,
onToggleSeriesVisibility,
yAxisUnit,
isGraphDisabled: isDashboardLocked,
decimalPrecision,
}),
[
@@ -125,6 +128,7 @@ export default function ChartManager({
handleToggleSeriesOnOff,
onToggleSeriesVisibility,
yAxisUnit,
isDashboardLocked,
decimalPrecision,
],
);

View File

@@ -1,6 +1,6 @@
import { Tooltip } from 'antd';
import 'lib/visualization/components/ChartManager/ChartManager.styles.scss';
import './ChartManager.styles.scss';
interface SeriesLabelProps {
label: string;

View File

@@ -2,7 +2,7 @@ import userEvent from '@testing-library/user-event';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { render, screen } from 'tests/test-utils';
import ChartManager from 'lib/visualization/components/ChartManager/ChartManager';
import ChartManager from '../ChartManager';
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
const mockToastSuccess = jest.fn();
@@ -32,6 +32,20 @@ jest.mock('lib/uPlotV2/hooks/useLegendsSync', () => ({
}),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (
selector?: (s: { dashboardData: { locked: boolean } | undefined }) => {
dashboardData: { locked: boolean };
},
): { dashboardData: { locked: boolean } } => {
const mockState = { dashboardData: { locked: false } };
return selector ? selector(mockState) : mockState;
},
selectIsDashboardLocked: (s: {
dashboardData: { locked: boolean } | undefined;
}): boolean => s.dashboardData?.locked ?? false,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {

View File

@@ -1,7 +1,7 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from 'tests/test-utils';
import { SeriesLabel } from 'lib/visualization/components/ChartManager/SeriesLabel';
import { SeriesLabel } from '../SeriesLabel';
describe('SeriesLabel', () => {
it('renders the label text', () => {

View File

@@ -2,8 +2,8 @@ import { render } from '@testing-library/react';
import { Y_AXIS_UNIT_NAMES } from 'components/YAxisUnitSelector/constants';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getChartManagerColumns } from 'lib/visualization/components/ChartManager/getChartMangerColumns';
import { ExtendedChartDataset } from 'lib/visualization/components/ChartManager/utils';
import { getChartManagerColumns } from '../getChartMangerColumns';
import { ExtendedChartDataset } from '../utils';
const createMockDataset = (
index: number,

View File

@@ -4,7 +4,7 @@ import {
formatTableValueWithUnit,
getDefaultTableDataSet,
getTableColumnTitle,
} from 'lib/visualization/components/ChartManager/utils';
} from '../utils';
describe('ChartManager utils', () => {
describe('getDefaultTableDataSet', () => {

Some files were not shown because too many files have changed in this diff Show More