mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-22 03:10:41 +01:00
Compare commits
189 Commits
chore/impr
...
fix/heatma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e72b40b144 | ||
|
|
cd4791bc67 | ||
|
|
bdf991964b | ||
|
|
dc0da3eee7 | ||
|
|
41c0a3754c | ||
|
|
4c3469a5ca | ||
|
|
23cf713be4 | ||
|
|
9be4db51bd | ||
|
|
bb9c3fc095 | ||
|
|
a093eb041a | ||
|
|
308b05ec3b | ||
|
|
64193f0376 | ||
|
|
f0e7663d85 | ||
|
|
8a4965d1fb | ||
|
|
1041801cc1 | ||
|
|
0ec5756645 | ||
|
|
939a244a18 | ||
|
|
688dba6e13 | ||
|
|
b051e14643 | ||
|
|
b72456f935 | ||
|
|
a10be0d95d | ||
|
|
73373f654f | ||
|
|
30bc4411d5 | ||
|
|
90a55ce72d | ||
|
|
67afca0016 | ||
|
|
4f04962e73 | ||
|
|
ff8ec64c2e | ||
|
|
02069e0a5e | ||
|
|
a09d5338b3 | ||
|
|
0241b8081c | ||
|
|
4260363b45 | ||
|
|
19c4aed27c | ||
|
|
f7b68ee687 | ||
|
|
232122ecda | ||
|
|
42fdcdefef | ||
|
|
30ad2a303d | ||
|
|
d39938d462 | ||
|
|
e81e9a2fd3 | ||
|
|
00718c0662 | ||
|
|
55a0c8dd35 | ||
|
|
9f1b1476f4 | ||
|
|
0bea671a2d | ||
|
|
0b1695c9c2 | ||
|
|
b4f42cf388 | ||
|
|
65a8a58600 | ||
|
|
a3c90ab132 | ||
|
|
da4c5578d3 | ||
|
|
bf01867c3c | ||
|
|
41187a5fca | ||
|
|
d04dc6f7db | ||
|
|
71badcf65c | ||
|
|
68fe15d5fc | ||
|
|
f8aded8c8e | ||
|
|
f6a6793e96 | ||
|
|
452046d70c | ||
|
|
2e9c9067f3 | ||
|
|
2ddd45cb8c | ||
|
|
8809d8a7ac | ||
|
|
21edb05f2b | ||
|
|
f659205866 | ||
|
|
215f6f8e0f | ||
|
|
802f7e4a3d | ||
|
|
81f1105fbc | ||
|
|
575aa57426 | ||
|
|
f75d3d8724 | ||
|
|
43538b92db | ||
|
|
ef65c2c08c | ||
|
|
8e41d3d30d | ||
|
|
b249ab8275 | ||
|
|
61bca035a3 | ||
|
|
9983b3344b | ||
|
|
1313a289b6 | ||
|
|
82a789c19e | ||
|
|
ec09873573 | ||
|
|
00cc773792 | ||
|
|
cf5dd79216 | ||
|
|
6546a0abc6 | ||
|
|
3874f3a6ec | ||
|
|
86db0fd26b | ||
|
|
f382a958db | ||
|
|
7c3481b230 | ||
|
|
24f3116527 | ||
|
|
17f0f49398 | ||
|
|
f681bacdcb | ||
|
|
ded4ed3d76 | ||
|
|
1f84f9c166 | ||
|
|
87127b580c | ||
|
|
fc4bd13fce | ||
|
|
075a4b164d | ||
|
|
2170189d14 | ||
|
|
b7720b655e | ||
|
|
f34973b897 | ||
|
|
d9d041a8fa | ||
|
|
a0e66e3592 | ||
|
|
a0dff26fbc | ||
|
|
db0b1b2b2a | ||
|
|
fd8ed18592 | ||
|
|
392b5826fd | ||
|
|
4b4023d706 | ||
|
|
f8d803d787 | ||
|
|
a33320c665 | ||
|
|
695a481989 | ||
|
|
3fc763b95b | ||
|
|
027b65574d | ||
|
|
43ec98b309 | ||
|
|
a196a436be | ||
|
|
3f456b6602 | ||
|
|
f777e068c3 | ||
|
|
5a690695e6 | ||
|
|
bf362d15db | ||
|
|
7e0f70ffa7 | ||
|
|
590046adf3 | ||
|
|
106cb9f6c2 | ||
|
|
f95699b1f7 | ||
|
|
1e75b66379 | ||
|
|
b80013d075 | ||
|
|
4a6e25708d | ||
|
|
3b8f9a7830 | ||
|
|
68bff5b053 | ||
|
|
732c8ae18a | ||
|
|
6911630c2a | ||
|
|
2d78d8add9 | ||
|
|
8e47c7d337 | ||
|
|
991e734c33 | ||
|
|
40c881b8f5 | ||
|
|
a2c8dd5275 | ||
|
|
32ae7a26d5 | ||
|
|
4067b04fa2 | ||
|
|
70409f5ee3 | ||
|
|
5d9953bb7e | ||
|
|
edfff0ee72 | ||
|
|
44556060a3 | ||
|
|
545e0342d2 | ||
|
|
2f559f34d3 | ||
|
|
e4d90b91b7 | ||
|
|
f33616a598 | ||
|
|
2ac94fc2fc | ||
|
|
7292a6946b | ||
|
|
dff49a4135 | ||
|
|
690c207519 | ||
|
|
d9b6d1f425 | ||
|
|
1eb974d91a | ||
|
|
1141f6bf14 | ||
|
|
a3fb926726 | ||
|
|
fe1e86f385 | ||
|
|
e30d9a1a14 | ||
|
|
c9a345713c | ||
|
|
22f8eba44a | ||
|
|
e8ad9044d5 | ||
|
|
576cd5052a | ||
|
|
4214ccf93d | ||
|
|
63f5c17238 | ||
|
|
fd6abd67dd | ||
|
|
a78c35d61c | ||
|
|
b2c4916a86 | ||
|
|
feff1a0179 | ||
|
|
eafac1be69 | ||
|
|
510d6985d0 | ||
|
|
758aacb065 | ||
|
|
5599c352d0 | ||
|
|
96d0a2340c | ||
|
|
c6b415ad04 | ||
|
|
9b922f297f | ||
|
|
e81a4c4046 | ||
|
|
9ba13848ea | ||
|
|
2578f25dd4 | ||
|
|
82352973bb | ||
|
|
3eb55692f1 | ||
|
|
61ceb6d300 | ||
|
|
e33fc20579 | ||
|
|
b130a61049 | ||
|
|
132e9bb740 | ||
|
|
9d577731e1 | ||
|
|
4d54568fe1 | ||
|
|
6f82624523 | ||
|
|
da0bc4f438 | ||
|
|
2dcb764632 | ||
|
|
1bc25e16b8 | ||
|
|
eebc6b400d | ||
|
|
30c4508113 | ||
|
|
c4fe619920 | ||
|
|
d597adddda | ||
|
|
9a05f33e7a | ||
|
|
3812937c6f | ||
|
|
34aa719cba | ||
|
|
cdfce4c4ab | ||
|
|
d8ef672f83 | ||
|
|
00efffc127 | ||
|
|
dec922a83f |
@@ -26,135 +26,6 @@ process on top of it.
|
||||
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
|
||||
report the story as done without it.
|
||||
|
||||
## Where it lands in the sidebar
|
||||
|
||||
The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`), so
|
||||
a page sits where someone would click it in the product. Four things decide that,
|
||||
and all four are part of writing the story, not a follow-up.
|
||||
|
||||
**Title.** `Pages/<Area>/<Page>`, where `<Area>` is the nav section and `<Page>`
|
||||
is the label the nav gives it.
|
||||
|
||||
- The leaf is the product's label, never the component's name: `MetricsExplorer`
|
||||
is `Metrics/Explorer`, `MeterExplorer` is `Metering/Cost Meter`,
|
||||
`AIAssistantPage` is `Noz`.
|
||||
- Never repeat the area in the leaf: `Alerts/Rules`, not `Alerts/AlertRules`.
|
||||
- A leaf never shares its name with a sibling folder. The folder wins and the
|
||||
page becomes `List`, or `Overview` for a tab strip: `Services/List` beside
|
||||
`Services/Detail`.
|
||||
- Title Case with spaces. No camelCase, no kebab.
|
||||
- Four levels is the floor to stay under: `Pages/Alerts/Channels/New` is as deep
|
||||
as it goes.
|
||||
- Pages nobody navigates to on purpose go under `Pages/System` (`Status`,
|
||||
`Unauthorized`, `Workspace Locked`), and the pre-session pages under
|
||||
`Pages/Auth`.
|
||||
- A page whose permission stories earn their own folder becomes one:
|
||||
`Pages/Settings/Billing/Overview` beside `Pages/Settings/Billing/Authz`. See
|
||||
**Permission stories** below.
|
||||
|
||||
**Order.** The `storySort.order` literal in `.storybook/preview.tsx` carries the
|
||||
order for every level. A new page in an existing area is appended to that area's
|
||||
array, in the order the product lists it; a new area goes where the side nav
|
||||
puts it. Storybook parses the order out of the file statically, so it has to
|
||||
stay an inline literal. Missing entries fall to the end of their level rather
|
||||
than disappearing, so a forgotten edit is a page at the bottom of its area, not
|
||||
a broken sidebar.
|
||||
|
||||
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag
|
||||
filter answers questions with. Only these:
|
||||
|
||||
| Tag | When |
|
||||
| --- | --- |
|
||||
| `authz` | The page gates UI on permission checks through `lib/authz` (`AuthZButton`, `AuthZGuard`, `useAuthZ`). Both the page's file and its `Authz` file carry it. |
|
||||
| `role-gated` | The page still branches on the legacy role (`user.role`, `hasEditPermission`) and has no authz check. |
|
||||
| `beta` | `isBeta` on its nav entry. Drop the tag when the product drops the badge. |
|
||||
| `legacy` | Superseded by another page but still routed. The doc comment names the page to start from instead. |
|
||||
| `play` | The story file has a `play` function, so at least one state is reached by an interaction. |
|
||||
|
||||
`autodocs` comes from `preview.tsx` and is never written on a meta.
|
||||
|
||||
**Doc comment on the meta.** What the page is, in the page's own terms, then a
|
||||
blank line, then the route:
|
||||
|
||||
```tsx
|
||||
const pageStory = storyMocks(logsExplorerMocks, {
|
||||
route: explorerRoute('explorer'),
|
||||
layout: 'app',
|
||||
});
|
||||
|
||||
/**
|
||||
* The logs explorer: the query builder, the list, the frequency chart and the log
|
||||
* detail drawer, with quick filters and saved views beside them.
|
||||
*
|
||||
* Route: `/logs/logs-explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Logs/Explorer',
|
||||
tags: ['play'],
|
||||
component: LogsModulePage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<LogsExplorerArgs>;
|
||||
```
|
||||
|
||||
The `pageStory` const and the trailing `parameters` line are what make the doc
|
||||
comment safe. The comment compiles to a `parameters` property that the csf plugin
|
||||
appends after the spread, so a meta that spreads `storyMocks(...)` and stops
|
||||
there loses `parameters.signoz` and renders the page against the global handlers
|
||||
alone: every one of the page's endpoints misses. Restating `parameters` as a
|
||||
literal gives the plugin something to merge into. `resolveStory` logs the
|
||||
combination that says it happened, so the console names it rather than leaving it
|
||||
to be found by reading the page.
|
||||
|
||||
It is the description on the page's Docs page, which is the only place a reader
|
||||
who is not in the code finds out what the page is for. Two or three sentences:
|
||||
what it shows, what drives it, and the gating worth knowing about (`Gated on
|
||||
authz permissions`, `follows the legacy editor role`). A control-driven route
|
||||
says so instead of a path: ``Route: `/metrics-explorer/*`, the tab control picks
|
||||
which``.
|
||||
|
||||
## Permission stories
|
||||
|
||||
A page that gates UI on `lib/authz` keeps its permission states in a folder of
|
||||
their own, so the page's own file stays about the page and the sidebar answers
|
||||
"what does this permission do" in one place.
|
||||
|
||||
**Layout.** A second story file at `stories/authz/<Page>.authz.stories.tsx`,
|
||||
titled `Pages/<Area>/<Page>/Authz`, which turns the page into a folder: its own
|
||||
file is retitled `Pages/<Area>/<Page>/Overview`, and `.storybook/preview.tsx`
|
||||
gains the sub-order (`'Billing', ['Overview', 'Authz']`). Both files carry the
|
||||
`authz` tag and share the page's one mocks module, which the authz file imports
|
||||
as `../<Page>.stories.mocks`. It declares no controls and no mock data of its
|
||||
own: a permission story that needs a new response is a control the page's mocks
|
||||
were missing.
|
||||
|
||||
**One story per permission the page reads**, named for what is gone: `NoRead`,
|
||||
`NoList`, `NoUpdate`, `NoCreate`, `NoDelete`. Then the combinations the page
|
||||
itself distinguishes, and only those: `NoManage` where two permissions gate one
|
||||
button, `ReadOnly` where everything but reading is denied, `NoSubscriptionAccess`
|
||||
where none of the resource's permissions are held, and `CheckFailed` for
|
||||
`authzState: 'error'`, which is the page's fail-open path rather than a denial.
|
||||
|
||||
**Revoke, never allow-list.** Each story is a full grant minus what its name
|
||||
says: `args: { revoked: ['read:subscription'] }`. The `Revoked` control subtracts
|
||||
from the preset, so the story stays "an admin missing one permission" as the
|
||||
catalogue grows, and the diff against the page's `Default` is the one permission.
|
||||
Rebuilding the allow-list by hand drifts the moment a resource is added.
|
||||
|
||||
**Never a role preset in this folder.** `access: 'viewer'` moves the legacy role,
|
||||
the side nav and every other resource's permissions at the same time, so the
|
||||
story no longer shows what its name claims. A persona is a story on the page's
|
||||
own file, and only when the product has that persona.
|
||||
|
||||
**Pair the revocation with the state that renders the gated control.** A button
|
||||
that only exists on a trial needs the plan too:
|
||||
`args: { plan: 'on-trial', revoked: ['create:subscription'] }`. A permission
|
||||
whose denial changes nothing on screen gets no story: say so in the PR.
|
||||
|
||||
Verify these by their disabled states, not their text. The page reads the same
|
||||
either way, so a story that is wrong looks right: read `disabled` off the buttons
|
||||
the permission gates, and check the denial callout is there or gone.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
|
||||
@@ -179,29 +50,7 @@ the permission gates, and check the denial callout is there or gone.
|
||||
- **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/`. A page that is a tab strip over
|
||||
several routes gets one story file per tab, in its own folder under the module
|
||||
page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its
|
||||
own mocks and `__story_mockdata__/`; the builders more than one tab needs stay
|
||||
in the module page's own `stories/__story_mockdata__/`
|
||||
(`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as
|
||||
`../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab
|
||||
strip is there, and the `route` its mocks return decides which tab is open.
|
||||
A page's permission stories go one level further down, in
|
||||
`stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see
|
||||
**Permission stories**.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a
|
||||
control: a drawer, a modal, an edit mode the page holds in component state.
|
||||
Drive it with `userEvent` and the queries from `storybook/test`, take the first
|
||||
of a repeated row action, and wait on the state's own text. The page fetches
|
||||
before it renders a row, so the finder needs a timeout past the 1s default. A
|
||||
state the app drops again on its own, such as one keyed on an array identity
|
||||
that a refetch replaces, does not get a story: it would not survive being
|
||||
looked at. A *sequence* of such states, a wizard's steps or a
|
||||
questionnaire's pages, is still a control: declare the steps in the mocks
|
||||
module and walk them from a `play` on the meta that destructures `mount`, which
|
||||
is what makes Storybook replay it on an arg change. See
|
||||
[references/controls.md](references/controls.md).
|
||||
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:
|
||||
|
||||
@@ -225,13 +74,6 @@ the permission gates, and check the denial callout is there or gone.
|
||||
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__/`.
|
||||
- **The story's own doc comment is per state.** Every `export const` gets one:
|
||||
what that state shows, not how it is built. It renders in the States list on
|
||||
the page's Docs page, so `Undocumented.` there is a story nobody described.
|
||||
- **Story names come from a fixed vocabulary** where one fits: `Default`,
|
||||
`Viewer`, `Empty`, `Loading`, `Error`. Page-specific states get page-specific
|
||||
names (`NoIngestion`, `Unlicensed`), never a second spelling of one of those
|
||||
(`ViewerAccess`, `NonAdmin`).
|
||||
- **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
|
||||
@@ -243,16 +85,6 @@ the permission gates, and check the denial callout is there or gone.
|
||||
## Done means
|
||||
|
||||
- [ ] `Default` shows the page with data, checked in dark and light
|
||||
- [ ] title follows the sidebar rules, tags declared, and the page's entry added
|
||||
to the `storySort.order` literal in `.storybook/preview.tsx`
|
||||
- [ ] the meta carries its doc comment with the `Route:` line, the meta restates
|
||||
`parameters: { ...pageStory.parameters }` after the spread, and every story
|
||||
export carries its own doc comment
|
||||
- [ ] the page's Docs page renders: description, controls table, and one row per
|
||||
state with no `Undocumented.`
|
||||
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it
|
||||
reads, each reached by `revoked`, none of them a role preset, and each one
|
||||
checked by the `disabled` state of what the permission gates
|
||||
- [ ] 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
|
||||
|
||||
@@ -128,83 +128,20 @@ export const servicesMocks = defineStoryMocks({
|
||||
// src/pages/Services/stories/Services.stories.tsx
|
||||
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
|
||||
|
||||
const pageStory = storyMocks(servicesMocks, {
|
||||
route: ROUTES.APPLICATION,
|
||||
layout: 'app',
|
||||
});
|
||||
|
||||
/**
|
||||
* Every instrumented service with its p99, error rate and throughput.
|
||||
*
|
||||
* Route: `/services`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Services/List',
|
||||
title: 'Pages/Services',
|
||||
component: Services,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
...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.
|
||||
|
||||
## A step the page keeps in component state
|
||||
|
||||
A wizard's step, a questionnaire's page, a picker's next question: the page holds
|
||||
it in `useState` and nothing in the URL says which one is open. It is still a
|
||||
control. Declare the steps in the mocks module and drive them from a `play` on
|
||||
the **meta**, so every story of the page inherits the walk and only sets `args`:
|
||||
|
||||
```tsx
|
||||
// <Page>.stories.mocks.tsx
|
||||
export const SETUP_STEPS = ['pick-source', 'pick-framework', 'configure'] as const;
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number];
|
||||
|
||||
controls: {
|
||||
step: choiceControl<SetupStep>('Setup step', { group: SETUP, options: SETUP_STEPS, value: 'pick-source' }),
|
||||
},
|
||||
```
|
||||
|
||||
```tsx
|
||||
// <Page>.stories.tsx
|
||||
const meta = {
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
},
|
||||
...storyMocks(pageMocks),
|
||||
} satisfies Meta<PageArgs>;
|
||||
|
||||
export const Configure: Story = { args: { step: 'configure' } };
|
||||
```
|
||||
|
||||
**Destructuring `mount` is what makes it a control.** Storybook re-runs a play
|
||||
function on an arg change only for a story whose play asks to be remounted
|
||||
(`usesMount`); otherwise it re-renders the tree the previous walk left behind and
|
||||
the panel looks broken. With `mount` destructured, the story renders when `play`
|
||||
calls it, and every arg change replays the walk from a fresh mount.
|
||||
|
||||
The walk itself:
|
||||
|
||||
- one `answer` function per step, in an array indexed the same as the step list,
|
||||
so reaching step *n* is `answers.slice(0, STEPS.indexOf(step))`;
|
||||
- answer each step with the least its Next button accepts, and prefer a "do this
|
||||
later" over filling a slider;
|
||||
- run them sequentially (`reduce` over a promise), since each answer is what
|
||||
renders the step the next one reads;
|
||||
- bail out when the page did not start where the walk expects, such as a source
|
||||
deep-linked past the questions. Check for the first step's own text rather than
|
||||
reading another control's value.
|
||||
|
||||
An endpoint that only settles the transition between two steps (the profile a
|
||||
questionnaire saves before its last page) takes a plain resolver, or the Data
|
||||
control on `loading` strands the walk halfway.
|
||||
|
||||
## Not a control
|
||||
|
||||
- Anything the global controls already cover: banner, side nav, data state,
|
||||
access preset, granted permissions, revoked permissions, check 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`,
|
||||
@@ -218,12 +155,9 @@ control on `loading` strands the walk halfway.
|
||||
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
|
||||
|
||||
A permission that visibly changes the page is a story too, but it goes in the
|
||||
page's `Authz` folder, one per permission, turned with the `Revoked` control.
|
||||
See **Permission stories** in SKILL.md.
|
||||
|
||||
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.
|
||||
|
||||
@@ -12,12 +12,11 @@ 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/List` →
|
||||
`pages-services-list`, plus the story export in kebab-case. Render one story on
|
||||
its own:
|
||||
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-list--default&viewMode=story
|
||||
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
|
||||
```
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: storybook-visual-diff
|
||||
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Also surveys where a component is used across the UI, ringing each instance in red and collecting every one into a single contact sheet. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, find which pages a change affects, or show every place a component appears.
|
||||
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
|
||||
---
|
||||
|
||||
# Storybook visual diff
|
||||
@@ -22,7 +22,7 @@ says, take it and do not ask again; ask only for what is genuinely missing, in
|
||||
|
||||
| To settle | Ask | Options |
|
||||
| --- | --- | --- |
|
||||
| Job | "What should this run produce?" | shoot only · survey where a component is used (§4) · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
|
||||
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
|
||||
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
|
||||
| Themes | "Which themes?" | dark · dark + light |
|
||||
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
|
||||
@@ -39,7 +39,6 @@ The job decides which loop below to run:
|
||||
| Job | Loop |
|
||||
| --- | --- |
|
||||
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
|
||||
| **usage survey** | §1, §4, stop. One run, no diff: the question is where a component appears, not what moved. |
|
||||
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
|
||||
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
|
||||
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
|
||||
@@ -123,8 +122,6 @@ node scripts/story-shots.mjs .story-shots/baseline \
|
||||
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
|
||||
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
|
||||
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
|
||||
| `--highlight <selector>` | also write `<id>--highlight.png`, every match ringed in red with 6px of padding |
|
||||
| `--crop <selector>` | also write one `crops/<id>--<n>.png` per match, and montage the theme's crops into `crops.png` |
|
||||
| `--flat` | write `<out>/<id>.png`, no theme directory |
|
||||
| `--no-caption` | leave the caption band off the shots |
|
||||
| `--list` | print the matched stories and exit |
|
||||
@@ -132,9 +129,8 @@ node scripts/story-shots.mjs .story-shots/baseline \
|
||||
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
|
||||
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
|
||||
rows) and how the run was configured (args, clock, width, height, grow, motion,
|
||||
settle, ignore, highlight, crop). Keep the flags identical between the two runs
|
||||
or the diff pairs nothing. `--highlight` and `--crop` write extra files beside
|
||||
the shots; §4 is what they are for.
|
||||
settle, ignore). Keep the flags identical between the two runs or the diff pairs
|
||||
nothing.
|
||||
|
||||
Every shot carries the caption band described below, so a single screenshot says
|
||||
what it is on its own. `--no-caption` leaves it off, and so does a machine
|
||||
@@ -212,75 +208,10 @@ pixelmatch's `includeAA: false`. So the script implements that comparison:
|
||||
A pair whose shots are different sizes is compared over the overlap, and every
|
||||
row and column that exists in only one of them counts as changed.
|
||||
|
||||
Pairing is by `<theme>/<story-id>.png`. A file that exists on one side only (a
|
||||
story added on the feature branch, renamed, retitled, or one whose capture
|
||||
failed) has nothing to compare against. It is not skipped: the side that has the
|
||||
shot is written out, captioned `missing previous` or `missing current`, and every
|
||||
one of its pixels counts as changed, so it sorts to the top of the report and is
|
||||
printed with that note. In the parallel modes the run that does not have it gets
|
||||
a placeholder tile saying so, in the theme's own colours, so the montage keeps
|
||||
its three tiles. Without this a whole component going missing reads as a clean
|
||||
run.
|
||||
|
||||
## 4. Surveying where a component is used
|
||||
|
||||
A different question from a diff: not *what moved*, but *where does this
|
||||
component appear and what does each instance look like*. One run answers it.
|
||||
|
||||
```bash
|
||||
node scripts/story-shots.mjs .story-shots/button-group --port 6007 --theme dark,light \
|
||||
--highlight '.ant-btn-group, div[role="group"][class*="button-group"]' \
|
||||
--crop '.ant-btn-group, div[role="group"][class*="button-group"]' \
|
||||
--stories pages-home--default,pages-alerts-history--default,...
|
||||
```
|
||||
|
||||
Four things come out, per theme:
|
||||
|
||||
- `<theme>/<id>.png` — the page as it is.
|
||||
- `<theme>/<id>--highlight.png` — the same page with every instance ringed in
|
||||
red. This is what says *where on the page*, which a crop cannot.
|
||||
- `<theme>/crops/<id>--<n>.png` — each instance on its own.
|
||||
- `<theme>/crops.png` — every crop of that theme in one labelled contact sheet.
|
||||
|
||||
The sheet is the useful artifact. Twelve instances across nine pages is one
|
||||
image to read, not twelve files to open in turn, and the label under each says
|
||||
which story it came from.
|
||||
|
||||
### Finding the selector and the stories
|
||||
|
||||
1. **Grep the source for the import, not the tag.** `Button.Group` and
|
||||
`ButtonGroup` are two different components in this repo: antd's, and
|
||||
`@signozhq/ui/button`'s. A survey that greps one misses the other.
|
||||
2. **Read the rendered markup, not the JSX.** `--crop` takes a CSS selector
|
||||
against the DOM. antd's group is `.ant-btn-group`; the design-system one is a
|
||||
`div[role="group"]` whose class is a hashed CSS module, hence
|
||||
`[class*="button-group"]`. Open the built component under
|
||||
`node_modules/@signozhq/ui/dist/` when the class is not obvious.
|
||||
3. **Map each source file to the story that renders it.** Follow the consumers:
|
||||
a container renders inside a page, and the page's story is the one to shoot.
|
||||
A component behind a drawer or a tab needs the story whose `args` open it
|
||||
(`--args drawer:endpoint-stats`), not the page default.
|
||||
4. **Let the run itself confirm the mapping.** Each story logs `N cropped`. A
|
||||
`0 cropped` line means that story never reaches the state, so swap the story
|
||||
rather than the selector.
|
||||
|
||||
### What a zero means
|
||||
|
||||
- **`0 cropped` on a story** — the component is not on that page in that state.
|
||||
Wrong story, or the state is behind an interaction the story has no `play`
|
||||
for. A modal nobody opens cannot be surveyed; say so instead of shooting the
|
||||
page it sits behind.
|
||||
- **An instance in the source with no crop** — a container whose children are
|
||||
all conditional renders as a 0x0 box. Both `--crop` and `--highlight` skip
|
||||
anything under 1px, since there is nothing on screen to ring. That is a
|
||||
finding about the component, not a failure of the run: it is in the tree and
|
||||
invisible.
|
||||
|
||||
A story logged `viewport-sized content, stopped chasing Npx` is shot back at
|
||||
`--height` with its own scrollbar, and what is below the fold there is laid out
|
||||
but never painted. The crops of such a story are taken at the chased height
|
||||
instead, so they are not the black rectangles the page shot would give; the
|
||||
`--highlight` shot still shows only what fits the viewport.
|
||||
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
|
||||
(new on the feature branch, renamed, retitled) has nothing to pair with and is
|
||||
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
|
||||
before reading the numbers.
|
||||
|
||||
## What makes a shot reproducible
|
||||
|
||||
@@ -361,6 +292,3 @@ for `--ignore` when a region cannot be settled.
|
||||
- **Stories behind a hover, drawer or modal** only render what their `play`
|
||||
reaches. If a state is missing from the shot, the story needs the `play`, not
|
||||
the script.
|
||||
- **The caption's temporary file is written beside the shot**, not in the system
|
||||
temp directory. `/tmp` is often a different filesystem, and the rename back
|
||||
over the shot then fails with `EXDEV: cross-device link not permitted`.
|
||||
|
||||
@@ -171,14 +171,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelDefect:
|
||||
enum:
|
||||
- none
|
||||
- missing_type
|
||||
- multiple_notifiers
|
||||
- unsupported_notifier
|
||||
- unrepresentable
|
||||
type: string
|
||||
AlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
headers:
|
||||
@@ -392,83 +384,13 @@ components:
|
||||
required:
|
||||
- routingKey
|
||||
type: object
|
||||
AlertmanagertypesChannelRepair:
|
||||
properties:
|
||||
action:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelRepairAction'
|
||||
applied:
|
||||
type: boolean
|
||||
blockers:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
channels:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
|
||||
nullable: true
|
||||
type: array
|
||||
defect:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelDefect'
|
||||
detail:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- defect
|
||||
- action
|
||||
- applied
|
||||
type: object
|
||||
AlertmanagertypesChannelRepairAction:
|
||||
enum:
|
||||
- none
|
||||
- retype
|
||||
- split
|
||||
- delete
|
||||
type: string
|
||||
AlertmanagertypesChannelSlackAction:
|
||||
properties:
|
||||
confirm:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
|
||||
name:
|
||||
type: string
|
||||
style:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- text
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
actions:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
|
||||
type: array
|
||||
apiUrl:
|
||||
format: password
|
||||
type: string
|
||||
channel:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
fallback:
|
||||
type: string
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
|
||||
type: array
|
||||
footer:
|
||||
type: string
|
||||
pretext:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
@@ -476,37 +398,9 @@ components:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
titleLink:
|
||||
type: string
|
||||
required:
|
||||
- apiUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfirmation:
|
||||
properties:
|
||||
dismissText:
|
||||
type: string
|
||||
okText:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- text
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackField:
|
||||
properties:
|
||||
short:
|
||||
nullable: true
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
- value
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
@@ -1075,11 +969,6 @@ components:
|
||||
- duration
|
||||
- repeatType
|
||||
type: object
|
||||
AlertmanagertypesRepairChannelParams:
|
||||
properties:
|
||||
apply:
|
||||
type: boolean
|
||||
type: object
|
||||
AlertmanagertypesRepeatOn:
|
||||
enum:
|
||||
- sunday
|
||||
@@ -3321,53 +3210,6 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAreaChartAppearance:
|
||||
properties:
|
||||
fillMode:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
|
||||
fillOpacity:
|
||||
$ref: '#/components/schemas/DashboardtypesFillOpacity'
|
||||
lineInterpolation:
|
||||
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
|
||||
lineStyle:
|
||||
$ref: '#/components/schemas/DashboardtypesLineStyle'
|
||||
showPoints:
|
||||
type: boolean
|
||||
spanGaps:
|
||||
$ref: '#/components/schemas/DashboardtypesSpanGaps'
|
||||
type: object
|
||||
DashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
|
||||
type: object
|
||||
DashboardtypesAreaChartVisualization:
|
||||
properties:
|
||||
fillSpans:
|
||||
type: boolean
|
||||
stack:
|
||||
$ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesAreaFillMode:
|
||||
enum:
|
||||
- solid
|
||||
- gradient
|
||||
type: string
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -3599,11 +3441,6 @@ components:
|
||||
- gradient
|
||||
- none
|
||||
type: string
|
||||
DashboardtypesFillOpacity:
|
||||
maximum: 1
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesGettableDashboardV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -3715,6 +3552,79 @@ components:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHeatmapAxes:
|
||||
properties:
|
||||
yScale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
|
||||
type: object
|
||||
DashboardtypesHeatmapChartAppearance:
|
||||
properties:
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
|
||||
type: object
|
||||
DashboardtypesHeatmapColorMode:
|
||||
enum:
|
||||
- palette
|
||||
- opacity
|
||||
type: string
|
||||
DashboardtypesHeatmapColorScale:
|
||||
enum:
|
||||
- log
|
||||
- sqrt
|
||||
- linear
|
||||
type: string
|
||||
DashboardtypesHeatmapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
maxCount:
|
||||
nullable: true
|
||||
type: number
|
||||
minCount:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesHeatmapPalette:
|
||||
enum:
|
||||
- ice
|
||||
- moss
|
||||
- rust
|
||||
- graphite
|
||||
- ember
|
||||
- lagoon
|
||||
- orchid
|
||||
- verdant
|
||||
- lava
|
||||
- beacon
|
||||
type: string
|
||||
DashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesHeatmapYScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -4066,8 +3976,8 @@ components:
|
||||
DashboardtypesPanelPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
@@ -4079,38 +3989,26 @@ components:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
- signoz/TimeSeriesPanel
|
||||
- signoz/BarChartPanel
|
||||
- signoz/AreaChartPanel
|
||||
- signoz/NumberPanel
|
||||
- signoz/PieChartPanel
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/AreaChartPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4123,6 +4021,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4441,12 +4351,6 @@ components:
|
||||
are connected.
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesStackMode:
|
||||
enum:
|
||||
- none
|
||||
- normal
|
||||
- percent
|
||||
type: string
|
||||
DashboardtypesStorableDashboardData:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
@@ -9805,8 +9709,6 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -9819,7 +9721,6 @@ components:
|
||||
- fieldContext
|
||||
- config
|
||||
- enabled
|
||||
- origin
|
||||
type: object
|
||||
SpantypesSpanMapperConfig:
|
||||
properties:
|
||||
@@ -9848,75 +9749,48 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
type: integer
|
||||
required:
|
||||
- id
|
||||
- orgId
|
||||
- name
|
||||
- condition
|
||||
- enabled
|
||||
- origin
|
||||
- version
|
||||
type: object
|
||||
SpantypesSpanMapperGroupCondition:
|
||||
nullable: true
|
||||
properties:
|
||||
attributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
resource:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpantypesSpanMapperGroupConditionKey:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- value
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperOperation:
|
||||
enum:
|
||||
- move
|
||||
- copy
|
||||
type: string
|
||||
SpantypesSpanMapperOrigin:
|
||||
enum:
|
||||
- user
|
||||
- system
|
||||
type: string
|
||||
SpantypesSpanMapperSource:
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
enabled:
|
||||
type: boolean
|
||||
key:
|
||||
type: string
|
||||
operation:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
priority:
|
||||
type: integer
|
||||
required:
|
||||
@@ -9924,7 +9798,6 @@ components:
|
||||
- context
|
||||
- operation
|
||||
- priority
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperTestSpan:
|
||||
properties:
|
||||
@@ -11175,9 +11048,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- cloud-integration:read
|
||||
- VIEWER
|
||||
summary: Agent check-in
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11227,9 +11100,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:list
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:list
|
||||
- ADMIN
|
||||
summary: List accounts
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11284,9 +11157,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:create
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:create
|
||||
- ADMIN
|
||||
summary: Create account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11329,9 +11202,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:delete
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:delete
|
||||
- ADMIN
|
||||
summary: Disconnect account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11397,9 +11270,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:read
|
||||
- ADMIN
|
||||
summary: Get account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11446,9 +11319,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:update
|
||||
- ADMIN
|
||||
summary: Update account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11504,9 +11377,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:list
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:list
|
||||
- ADMIN
|
||||
summary: List account services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11579,9 +11452,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:read
|
||||
- ADMIN
|
||||
summary: Get service for account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11633,9 +11506,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- cloud-integration:update
|
||||
- ADMIN
|
||||
summary: Update service
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11690,9 +11563,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- cloud-integration:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- cloud-integration:read
|
||||
- VIEWER
|
||||
summary: Agent check-in
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11743,17 +11616,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ingestion-key:create
|
||||
- serviceaccount:create
|
||||
- factor-api-key:create
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ingestion-key:create
|
||||
- serviceaccount:create
|
||||
- factor-api-key:create
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
summary: Get connection credentials
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11803,8 +11668,10 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11859,8 +11726,10 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get service
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -20461,85 +20330,6 @@ paths:
|
||||
summary: Update notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/{id}/repair:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'This endpoint diagnoses a stored channel that the v2 API cannot
|
||||
read and applies the fitting action: a channel carrying several notifier configurations
|
||||
is split into one channel per configuration, keeping this ID for the first;
|
||||
a channel whose notifier kind v2 does not model is deleted; a channel with
|
||||
an empty stored type has it rewritten from its data. A delete is refused while
|
||||
a routing policy still names the channel. Nothing is written unless apply=true;
|
||||
by default the response only shows what would happen.'
|
||||
operationId: RepairNotificationChannel
|
||||
parameters:
|
||||
- in: query
|
||||
name: apply
|
||||
schema:
|
||||
type: boolean
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesRepairChannelParams'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelRepair'
|
||||
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:
|
||||
- notification-channel:update
|
||||
- tokenizer:
|
||||
- notification-channel:update
|
||||
summary: Repair notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/test:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -295,8 +295,6 @@
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"signoz/no-dashboard-fetch-outside-root": "error",
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"signoz/no-msw-in-story-file": "error",
|
||||
// Bans msw imports in *.stories.tsx; handlers/mock data belong in the sibling .stories.mocks.tsx
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -27,22 +27,12 @@ const mockAliases = [
|
||||
find: /^(?:src\/)?api\/common\/logEvent$/,
|
||||
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, the suite mounts a mock store per test.
|
||||
find: /^(?:src\/)?store$/,
|
||||
replacement: `${srcPath}/storybook/mocks/store.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`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, a test opens the one tooltip it is about.
|
||||
find: /^@signozhq\/ui\/tooltip$/,
|
||||
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -65,12 +55,12 @@ const isExcluded = (plugin: PluginOption): boolean =>
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/storybook/docs/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
|
||||
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', '@storybook/addon-docs'],
|
||||
addons: ['@storybook/addon-a11y'],
|
||||
core: { disableTelemetry: true },
|
||||
viteFinal: async (viteConfig) => {
|
||||
const plugins = (viteConfig.plugins ?? [])
|
||||
@@ -87,14 +77,6 @@ const config: StorybookConfig = {
|
||||
|
||||
return {
|
||||
...viteConfig,
|
||||
build: {
|
||||
...viteConfig.build,
|
||||
// `vite.config.ts` sets this for the app; Storybook's builder replaces
|
||||
// `build` wholesale, which leaves rolldown-vite on its default
|
||||
// lightningcss. That one rejects `:global()` in a plain stylesheet, which
|
||||
// the app has, and the static build dies in CSS minification.
|
||||
cssMinify: 'esbuild',
|
||||
},
|
||||
plugins,
|
||||
resolve: {
|
||||
...viteConfig.resolve,
|
||||
|
||||
@@ -6,17 +6,6 @@
|
||||
-->
|
||||
<link rel="stylesheet" href="storybook-fonts.css" />
|
||||
|
||||
<!--
|
||||
Third-party frames are the one thing msw cannot answer: a cross-origin iframe
|
||||
navigates outside the service worker's scope, so the YouTube embeds and the
|
||||
docs pane in onboarding reach the real network. Same intent as the boot data
|
||||
below, enforced by the browser instead.
|
||||
-->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="frame-src 'self' blob: data:"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
@@ -35,38 +24,3 @@
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// The wall clock every story reads. Chart windows, `4 mins ago` labels and
|
||||
// trial countdowns all derive from `now`, and Chromatic does not freeze the
|
||||
// clock, so a live one redraws every chart axis between two builds of the
|
||||
// same code. `performance.now` and the timers keep running, so anything
|
||||
// waiting on a timeout still resolves. `?storyClock=live`, or an ISO
|
||||
// instant, overrides it.
|
||||
//
|
||||
// `new Date()` is the frozen instant, which is what the app renders from.
|
||||
// `Date.now()` runs on from it instead, because it is also what code measures
|
||||
// elapsed time with: `lodash.debounce` compares two `Date.now()` readings to
|
||||
// decide its trailing call is due, so a frozen one re-arms its timer forever
|
||||
// and every debounced input in the app (the onboarding catalogue search, the
|
||||
// pipelines search, the log filter) silently stops filtering.
|
||||
(() => {
|
||||
const asked = new URLSearchParams(window.location.search).get('storyClock');
|
||||
if (asked === 'live') return;
|
||||
|
||||
const frozen = Date.parse(asked || '2026-06-15T12:00:00.000Z');
|
||||
if (Number.isNaN(frozen)) return;
|
||||
|
||||
const RealDate = Date;
|
||||
const started = performance.now();
|
||||
class FrozenDate extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length ? args : [frozen]));
|
||||
}
|
||||
static now() {
|
||||
return frozen + (performance.now() - started);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
|
||||
import PageDocs from '../src/storybook/docs/PageDocs';
|
||||
import ThemedDocsContainer from '../src/storybook/docs/ThemedDocsContainer';
|
||||
import { withProviders } from '../src/storybook/decorators/withProviders';
|
||||
import { globalMocks } from '../src/storybook/globals';
|
||||
import { resetStoryHistory } from '../src/storybook/navigation/containment';
|
||||
@@ -15,12 +13,7 @@ import {
|
||||
} from '../src/storybook/runtime/resolveStory';
|
||||
import { allModes } from './modes';
|
||||
|
||||
import i18n from '../src/ReactI18';
|
||||
|
||||
// `src/index.tsx` does this at boot: without it `@monaco-editor/react` falls back
|
||||
// to its loader default and pulls Monaco from cdn.jsdelivr.net, which msw does
|
||||
// not report because the requests look like static assets.
|
||||
import '../src/lib/monaco/setup';
|
||||
import '../src/ReactI18';
|
||||
|
||||
import '../src/styles.scss';
|
||||
|
||||
@@ -70,127 +63,10 @@ const { worker, ready } = (holder.__signozStorybookWorker ??=
|
||||
};
|
||||
})());
|
||||
|
||||
/**
|
||||
* `t()` answers with the key until the namespace's JSON has landed, and a `play`
|
||||
* that clicks as soon as the story renders is quick enough to catch it: the
|
||||
* channel form's "Channel name is mandatory" arrives as `channel_name_required`.
|
||||
* Every namespace under `public/locales/en` is loaded once, ahead of the first
|
||||
* story.
|
||||
*/
|
||||
const translationsReady = i18n.loadNamespaces(
|
||||
Object.keys(import.meta.glob('../public/locales/en/*.json')).map((path) =>
|
||||
path.slice(path.lastIndexOf('/') + 1, -'.json'.length),
|
||||
),
|
||||
);
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { expanded: true },
|
||||
// The sidebar order, mirroring the app's own side nav
|
||||
// (`container/SideNav/menuItems.tsx`), so a page sits where someone would
|
||||
// click it in the product. Storybook's default is the order the story files
|
||||
// happen to be globbed in, which puts `src/modules` first. Anything missing
|
||||
// from a level lands after the entries listed for it, in file order, so a new
|
||||
// story shows up at the end of its area rather than disappearing. Stories
|
||||
// inside a file are never listed, so they keep the order they are declared
|
||||
// in, `Default` first. Storybook parses this out of the file, so it has to
|
||||
// stay an inline literal.
|
||||
options: {
|
||||
storySort: {
|
||||
order: [
|
||||
'Docs',
|
||||
'Pages',
|
||||
[
|
||||
'Home',
|
||||
'Alerts',
|
||||
[
|
||||
'Rules',
|
||||
'Triggered',
|
||||
'Overview',
|
||||
'History',
|
||||
'Create',
|
||||
'Edit',
|
||||
'Planned Downtime',
|
||||
'Routing Policies',
|
||||
'Channels',
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
['List', 'Detail', 'Panel Editor', 'Public'],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', 'Funnel Details'],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
[
|
||||
'Overview',
|
||||
'Kubernetes',
|
||||
[
|
||||
'Clusters',
|
||||
'Nodes',
|
||||
'Namespaces',
|
||||
'Pods',
|
||||
'Deployments',
|
||||
'DaemonSets',
|
||||
'StatefulSets',
|
||||
'Jobs',
|
||||
'Volumes',
|
||||
],
|
||||
],
|
||||
'Integrations',
|
||||
['List', 'Details', 'Cloud Account'],
|
||||
'Exceptions',
|
||||
['List', 'Detail'],
|
||||
'External APIs',
|
||||
'AI Observability',
|
||||
['Overview', 'Explorer', 'Model Pricing', 'Attribute Mapping'],
|
||||
'Noz',
|
||||
'Metering',
|
||||
['Cost Meter', 'Usage Explorer'],
|
||||
'Messaging Queues',
|
||||
['Overview', 'Kafka', 'Kafka Detail', 'Celery'],
|
||||
'Onboarding',
|
||||
['Questionnaire', 'Add Data Source'],
|
||||
'Settings',
|
||||
[
|
||||
'Workspace',
|
||||
'Account',
|
||||
'Billing',
|
||||
['Overview', 'Authz'],
|
||||
'MCP Server',
|
||||
'Roles',
|
||||
'Role Details',
|
||||
'Role Editor',
|
||||
'Members',
|
||||
'Service Accounts',
|
||||
'Ingestion',
|
||||
'Single Sign-on',
|
||||
'Keyboard Shortcuts',
|
||||
],
|
||||
'Auth',
|
||||
['Login', 'Sign Up', 'Forgot Password', 'Reset Password'],
|
||||
'System',
|
||||
[
|
||||
'Status',
|
||||
'Support',
|
||||
'License',
|
||||
'Not Found',
|
||||
'Unauthorized',
|
||||
'Error Fallback',
|
||||
'Workspace Locked',
|
||||
'Workspace Suspended',
|
||||
'Workspace Access Restricted',
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
docs: { page: PageDocs, container: ThemedDocsContainer },
|
||||
// One cloud snapshot per theme, for every story. A mode carries Storybook
|
||||
// globals, so `theme` here is the same toolbar global the app reads out of
|
||||
// localStorage. Widths are Chromatic's only real dimension, as they are
|
||||
@@ -198,9 +74,6 @@ const preview: Preview = {
|
||||
// one it is given.
|
||||
chromatic: { modes: allModes },
|
||||
},
|
||||
// Every page story gets a docs page: the descriptions on the meta and on each
|
||||
// story are the page's documentation, and without this they render nowhere.
|
||||
tags: ['autodocs'],
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'SigNoz color scheme',
|
||||
@@ -246,21 +119,12 @@ const preview: Preview = {
|
||||
world.apply();
|
||||
world.install(worker);
|
||||
|
||||
await Promise.all([ready, translationsReady]);
|
||||
await ready;
|
||||
},
|
||||
],
|
||||
beforeEach: () => {
|
||||
clearBlockedNavigations();
|
||||
resetStoryHistory();
|
||||
|
||||
// The runner clears its console/network buffer before it navigates, so
|
||||
// anything the outgoing story still has in flight would be reported
|
||||
// against this one. Stamping the moment this story starts gives the runner
|
||||
// a line to discard those by. `Date.now()` is faked for the stories, so
|
||||
// this reads the one clock the runner's own timestamps share.
|
||||
document.body.dataset.signozStoryStartedAt = String(
|
||||
performance.timeOrigin + performance.now(),
|
||||
);
|
||||
},
|
||||
// After `play`, which is the moment both capture stacks shoot at.
|
||||
afterEach: settleForCapture,
|
||||
|
||||
@@ -88,16 +88,10 @@ self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// msw bypasses server-sent events here, because it answers a request in one
|
||||
// piece and has no stream to hand back. A story is not a live connection
|
||||
// either: it wants the backlog a page renders, and one response carries that
|
||||
// fine. Left bypassed, `/api/v3/logs/livetail` reaches the real network and
|
||||
// the live tail story is a spinner over ERR_CONNECTION_REFUSED. Restore the
|
||||
// bypass and re-check `Pages/Logs/Live Tail` if msw regenerates this file.
|
||||
//
|
||||
// if (accept.includes('text/event-stream')) {
|
||||
// return
|
||||
// }
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
|
||||
@@ -25,23 +25,7 @@ const IGNORED_MESSAGES = [
|
||||
/violates the following Content Security Policy directive/,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
at: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const messagesByPage = new WeakMap<Page, CapturedMessage[]>();
|
||||
|
||||
/**
|
||||
* When the story under test started rendering, stamped by the preview's
|
||||
* `beforeEach`. Messages captured before it belong to the previous story: the
|
||||
* runner clears this buffer ahead of the navigation, so whatever that story
|
||||
* still had in flight lands here.
|
||||
*/
|
||||
const storyStartedAt = (page: Page): Promise<number> =>
|
||||
page
|
||||
.evaluate(() => Number(document.body.dataset.signozStoryStartedAt ?? 0))
|
||||
.catch(() => 0);
|
||||
const messagesByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/**
|
||||
* Only `console.error` fails a story. `console.warn` is dev-time advice from
|
||||
@@ -59,14 +43,14 @@ const config: TestRunnerConfig = {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages: CapturedMessage[] = [];
|
||||
const messages: string[] = [];
|
||||
messagesByPage.set(page, messages);
|
||||
page.on('console', (message) => {
|
||||
if (
|
||||
message.type() === 'error' &&
|
||||
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
|
||||
) {
|
||||
messages.push({ at: Date.now(), text: `[error] ${message.text()}` });
|
||||
messages.push(`[error] ${message.text()}`);
|
||||
}
|
||||
});
|
||||
// The console message alone ("Failed to load resource") doesn't name the
|
||||
@@ -74,23 +58,12 @@ const config: TestRunnerConfig = {
|
||||
// actionable instead of just a status code.
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push({
|
||||
at: Date.now(),
|
||||
text: `[response] ${response.status()} ${response.url()}`,
|
||||
});
|
||||
messages.push(`[response] ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
async postVisit(page, context): Promise<void> {
|
||||
const captured = messagesByPage.get(page) ?? [];
|
||||
if (captured.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = await storyStartedAt(page);
|
||||
const messages = captured
|
||||
.filter((message) => message.at >= startedAt)
|
||||
.map((message) => message.text);
|
||||
const messages = messagesByPage.get(page) ?? [];
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
// Mock for uplot library used in tests
|
||||
export interface MockUPlotInstance {
|
||||
/** Consumers read `root.parentElement` to detect a re-mounted container. */
|
||||
root: HTMLDivElement;
|
||||
setData: jest.Mock;
|
||||
setSize: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
|
||||
const root = document.createElement('div');
|
||||
// Real uPlot mounts its root inside the target; without it a re-render reads
|
||||
// `root.parentElement` off undefined and throws.
|
||||
target?.appendChild(root);
|
||||
return {
|
||||
root,
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
|
||||
function MockUPlot(
|
||||
_options: unknown,
|
||||
_data: unknown,
|
||||
_target: HTMLElement,
|
||||
target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
return createMockUPlotInstance();
|
||||
return createMockUPlotInstance(target);
|
||||
}
|
||||
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -162,7 +162,6 @@
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Rule: no-msw-in-story-file
|
||||
*
|
||||
* A `.stories.tsx` file is the human-facing surface: it must not carry msw
|
||||
* handlers or response payloads. Those belong in the sibling
|
||||
* `<Page>.stories.mocks.tsx` module (and its `__story_mockdata__` builders).
|
||||
*
|
||||
* This rule flags any import from `msw` inside a `*.stories.tsx` file. It
|
||||
* does not match `*.stories.mocks.tsx`, which is where msw imports belong.
|
||||
*/
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow importing from msw inside a .stories.tsx file; move handlers/mock data to the sibling .stories.mocks.tsx module',
|
||||
category: 'Storybook',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
noMsw:
|
||||
'Do not import from msw in a .stories.tsx file. Move the handler and its mock data to the sibling <Page>.stories.mocks.tsx module (and __story_mockdata__ for builders).',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.filename || '';
|
||||
if (!filename.endsWith('.stories.tsx')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === 'msw') {
|
||||
context.report({ node, messageId: 'noMsw' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
|
||||
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
|
||||
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
|
||||
import noMswInStoryFile from './rules/no-msw-in-story-file.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -32,6 +31,5 @@ export default {
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
|
||||
'no-return-text-nodes': noReturnTextNodes,
|
||||
'no-msw-in-story-file': noMswInStoryFile,
|
||||
},
|
||||
};
|
||||
|
||||
48
frontend/pnpm-lock.yaml
generated
48
frontend/pnpm-lock.yaml
generated
@@ -363,9 +363,6 @@ importers:
|
||||
'@storybook/addon-a11y':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/addon-docs':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/react-vite':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
|
||||
@@ -2187,12 +2184,6 @@ packages:
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16'
|
||||
react: '>=16'
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
@@ -3775,15 +3766,6 @@ packages:
|
||||
peerDependencies:
|
||||
storybook: ^10.5.9
|
||||
|
||||
'@storybook/addon-docs@10.5.9':
|
||||
resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.5.9
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@storybook/builder-vite@10.5.9':
|
||||
resolution: {integrity: sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==}
|
||||
peerDependencies:
|
||||
@@ -4207,9 +4189,6 @@ packages:
|
||||
'@types/mdast@4.0.3':
|
||||
resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==}
|
||||
|
||||
'@types/mdx@2.0.14':
|
||||
resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==}
|
||||
|
||||
'@types/ms@0.7.31':
|
||||
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
||||
|
||||
@@ -12220,12 +12199,6 @@ snapshots:
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.14
|
||||
'@types/react': 18.0.26
|
||||
react: 18.2.0
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
@@ -13646,25 +13619,6 @@ snapshots:
|
||||
axe-core: 4.13.0
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
|
||||
'@storybook/addon-docs@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@18.0.26)(react@18.2.0)
|
||||
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/icons': 2.1.0(react@18.2.0)
|
||||
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
ts-dedent: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
transitivePeerDependencies:
|
||||
- '@types/react-dom'
|
||||
- esbuild
|
||||
- rollup
|
||||
- vite
|
||||
- webpack
|
||||
|
||||
'@storybook/builder-vite@10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
@@ -14110,8 +14064,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.2
|
||||
|
||||
'@types/mdx@2.0.14': {}
|
||||
|
||||
'@types/ms@0.7.31': {}
|
||||
|
||||
'@types/node@16.18.25': {}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"GET_STARTED": "SigNoz | Get Started",
|
||||
"SERVICE_METRICS": "SigNoz | Service Metrics",
|
||||
"SERVICE_MAP": "SigNoz | Service Map",
|
||||
"TRACE": "SigNoz | Trace",
|
||||
"HOME": "SigNoz | Home",
|
||||
"TRACE_DETAIL": "SigNoz | Trace Detail",
|
||||
"TRACES_EXPLORER": "SigNoz | Traces Explorer",
|
||||
@@ -55,6 +56,7 @@
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
|
||||
"MCP_SERVER": "SigNoz | MCP Server",
|
||||
"AI_ASSISTANT": "SigNoz | AI Assistant",
|
||||
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
|
||||
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"GET_STARTED_AZURE_MONITORING": "SigNoz | Get Started | AZURE",
|
||||
"GET_STARTED": "SigNoz | Get Started with SigNoz Cloud",
|
||||
"GET_STARTED_WITH_CLOUD": "SigNoz | Get Started with SigNoz Cloud",
|
||||
"TRACE": "SigNoz | Trace",
|
||||
"TRACE_DETAIL": "SigNoz | Trace Detail",
|
||||
"TRACES_EXPLORER": "SigNoz | Traces Explorer",
|
||||
"SETTINGS": "SigNoz | Settings",
|
||||
@@ -78,6 +79,7 @@
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
|
||||
"MCP_SERVER": "SigNoz | MCP Server",
|
||||
"AI_ASSISTANT": "SigNoz | AI Assistant",
|
||||
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
|
||||
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"ROLE_CREATE": "SigNoz | Create Role",
|
||||
|
||||
@@ -15,8 +15,6 @@ export const CONFIG_KEYS = [
|
||||
'motion',
|
||||
'settle',
|
||||
'ignore',
|
||||
'highlight',
|
||||
'crop',
|
||||
];
|
||||
|
||||
let tools;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { parseArgs } from 'node:util';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
bodyFont,
|
||||
@@ -17,8 +18,7 @@ import {
|
||||
|
||||
/**
|
||||
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
|
||||
* moved, per pair, largest first. A shot only one run has is reported too,
|
||||
* labelled with the side it is missing from and counted as changed in full.
|
||||
* moved, per pair, largest first.
|
||||
*
|
||||
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
|
||||
* distance from the baseline pixel is over `threshold` of the largest distance
|
||||
@@ -55,8 +55,7 @@ if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
|
||||
--tint <#rrggbb> override the mode's highlight colour
|
||||
--no-caption do not stamp the story and the run settings on top
|
||||
|
||||
Prints "<changed pixels> <relative path>", largest first; a shot only one run
|
||||
has is printed as "(missing previous)" or "(missing current)". Needs ImageMagick.`);
|
||||
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
@@ -326,11 +325,6 @@ const shotOf = (run, rel) =>
|
||||
|
||||
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
|
||||
|
||||
/** What the shot was shot in, falling back to the directory it sits in. */
|
||||
const themeOf = (rel) =>
|
||||
(shotOf(afterRun, rel) ?? shotOf(baseRun, rel))?.theme ??
|
||||
rel.split(path.sep)[0];
|
||||
|
||||
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
|
||||
const withoutCaption = (file, { width, height }, top) =>
|
||||
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
|
||||
@@ -375,156 +369,20 @@ const pngs = async (dir, prefix = '') => {
|
||||
const results = [];
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
/** The half-built tiles, under the output directory so nothing is left elsewhere. */
|
||||
const scratch = Object.fromEntries(
|
||||
['body', 'diff', 'shot', 'missing'].map((name) => [
|
||||
name,
|
||||
path.join(outDir, `.story-shots-${process.pid}-${name}.png`),
|
||||
]),
|
||||
);
|
||||
|
||||
/** One labelled tile of a parallel montage. */
|
||||
const tile = (label, file, background) => [
|
||||
'(',
|
||||
`label:${literal(label)}`,
|
||||
file,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
'-bordercolor',
|
||||
background,
|
||||
'-border',
|
||||
'12',
|
||||
')',
|
||||
];
|
||||
|
||||
/** The tiles side by side under one caption. */
|
||||
const montage = ({
|
||||
tiles,
|
||||
width,
|
||||
background,
|
||||
foreground,
|
||||
caption,
|
||||
target,
|
||||
theme,
|
||||
}) => {
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
// The tiles end up side by side, so they are read at the montage's width.
|
||||
String(Math.round(pointsize(width * 3) * 0.62)),
|
||||
...tiles.flat(),
|
||||
'-gravity',
|
||||
'north',
|
||||
'+append',
|
||||
caption.length ? scratch.body : target,
|
||||
]);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: scratch.body, to: target, theme });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A tile standing in for a shot the run does not have, sized like the one it
|
||||
* does. The gutter's colours are the theme's own inverted, so they go back the
|
||||
* other way here and the tile reads as a shot rather than as a hole.
|
||||
*/
|
||||
const placeholder = (
|
||||
file,
|
||||
{ width, height },
|
||||
text,
|
||||
{ background, foreground },
|
||||
) =>
|
||||
magick([
|
||||
'-size',
|
||||
`${width}x${height}`,
|
||||
'-background',
|
||||
foreground,
|
||||
'-fill',
|
||||
background,
|
||||
'-gravity',
|
||||
'center',
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
String(pointsize(width * 3)),
|
||||
`label:${literal(text)}`,
|
||||
file,
|
||||
]);
|
||||
|
||||
const [baseFiles, afterFiles] = await Promise.all([
|
||||
pngs(baseDir),
|
||||
pngs(afterDir),
|
||||
]);
|
||||
const inBase = new Set(baseFiles);
|
||||
const inAfter = new Set(afterFiles);
|
||||
|
||||
for (const rel of [...new Set([...baseFiles, ...afterFiles])].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
for (const rel of (await pngs(baseDir)).sort()) {
|
||||
const afterFile = path.join(afterDir, rel);
|
||||
const target = path.join(outDir, rel);
|
||||
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
|
||||
|
||||
// A story added, removed or renamed since the baseline has nothing to
|
||||
// compare against, so the side that does have it is written out under the
|
||||
// label of the side that does not, and every one of its pixels counts.
|
||||
if (!inBase.has(rel) || !inAfter.has(rel)) {
|
||||
const gone = inAfter.has(rel) ? 'previous' : 'current';
|
||||
const held = gone === 'previous' ? 'current' : 'previous';
|
||||
const run = gone === 'previous' ? afterRun : baseRun;
|
||||
const image = readRgba(
|
||||
gone === 'previous' ? afterFile : path.join(baseDir, rel),
|
||||
captionOf(run, rel),
|
||||
);
|
||||
const theme = themeOf(rel);
|
||||
const colors = palette(theme);
|
||||
const caption = captionLines(rel, [`missing ${gone}`]);
|
||||
|
||||
if (opts.mode.endsWith('-parallel')) {
|
||||
// The montage keeps its three tiles: the run that has the shot shows it,
|
||||
// and the run that does not, like the diff, says so in its place. There
|
||||
// is nothing to compare, so nothing is tinted.
|
||||
await writeRgba(image, scratch.shot);
|
||||
placeholder(scratch.missing, image, `missing ${gone}`, colors);
|
||||
|
||||
const sides = {
|
||||
[held]: tile(sideLabel(held, run), scratch.shot, colors.background),
|
||||
[gone]: tile(
|
||||
sideLabel(gone, gone === 'previous' ? baseRun : afterRun),
|
||||
scratch.missing,
|
||||
colors.background,
|
||||
),
|
||||
};
|
||||
montage({
|
||||
tiles: [
|
||||
sides.previous,
|
||||
sides.current,
|
||||
tile('diff', scratch.missing, colors.background),
|
||||
],
|
||||
width: image.width,
|
||||
...colors,
|
||||
caption,
|
||||
target,
|
||||
theme,
|
||||
});
|
||||
} else {
|
||||
await writeRgba(image, caption.length ? scratch.body : target);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: scratch.body, to: target, theme });
|
||||
}
|
||||
}
|
||||
|
||||
results.push([image.width * image.height, rel, `missing ${gone}`]);
|
||||
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
|
||||
let after;
|
||||
try {
|
||||
after = readRgba(afterFile, captionOf(afterRun, rel));
|
||||
} catch {
|
||||
console.error(`missing in after: ${rel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
|
||||
const after = readRgba(afterFile, captionOf(afterRun, rel));
|
||||
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
|
||||
const diff = diffPair(base, after, opts.mode);
|
||||
const target = path.join(outDir, rel);
|
||||
const parallel = opts.mode.endsWith('-parallel');
|
||||
// With no tiles to label, a run's own settings go in the caption instead.
|
||||
const caption = captionLines(
|
||||
@@ -533,54 +391,70 @@ for (const rel of [...new Set([...baseFiles, ...afterFiles])].sort((a, b) =>
|
||||
? []
|
||||
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
|
||||
);
|
||||
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
|
||||
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
|
||||
|
||||
// The gutter is the opposite of the theme's own background, so the tiles and
|
||||
// the caption keep an edge instead of bleeding into it.
|
||||
const theme = themeOf(rel);
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const theme = shot?.theme ?? rel.split(path.sep)[0];
|
||||
const { background, foreground } = palette(theme);
|
||||
|
||||
if (parallel) {
|
||||
await writeRgba(diff, scratch.diff);
|
||||
montage({
|
||||
tiles: [
|
||||
tile(
|
||||
sideLabel('previous', baseRun),
|
||||
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
|
||||
background,
|
||||
),
|
||||
tile(
|
||||
sideLabel('current', afterRun),
|
||||
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
|
||||
background,
|
||||
),
|
||||
tile('diff', scratch.diff, background),
|
||||
],
|
||||
width: after.width,
|
||||
await writeRgba(diff, diffFile);
|
||||
const tile = (label, file) => [
|
||||
'(',
|
||||
`label:${literal(label)}`,
|
||||
file,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
'-bordercolor',
|
||||
background,
|
||||
'-border',
|
||||
'12',
|
||||
')',
|
||||
];
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
caption,
|
||||
target,
|
||||
theme,
|
||||
});
|
||||
} else {
|
||||
await writeRgba(diff, caption.length ? scratch.body : target);
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
// The tiles end up side by side, so they are read at the montage's width.
|
||||
String(Math.round(pointsize(after.width * 3) * 0.62)),
|
||||
...tile(
|
||||
sideLabel('previous', baseRun),
|
||||
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
|
||||
),
|
||||
...tile(
|
||||
sideLabel('current', afterRun),
|
||||
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
|
||||
),
|
||||
...tile('diff', diffFile),
|
||||
'-gravity',
|
||||
'north',
|
||||
'+append',
|
||||
caption.length ? body : target,
|
||||
]);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: scratch.body, to: target, theme });
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
} else {
|
||||
await writeRgba(diff, caption.length ? body : target);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
}
|
||||
|
||||
results.push([diff.changed, rel]);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.values(scratch).map((file) => rm(file, { force: true })),
|
||||
);
|
||||
|
||||
results
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([changed, rel, note]) => {
|
||||
const suffix = note ? ` (${note})` : '';
|
||||
console.log(`${String(changed).padStart(10)} ${rel}${suffix}`);
|
||||
});
|
||||
.forEach(([changed, rel]) =>
|
||||
console.log(`${String(changed).padStart(10)} ${rel}`),
|
||||
);
|
||||
|
||||
console.error(`diffs in ${outDir}`);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { renameSync, rmSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
bodyFont,
|
||||
CONFIG_KEYS,
|
||||
hasMagick,
|
||||
literal,
|
||||
magick,
|
||||
palette,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
@@ -43,8 +39,6 @@ const { values: opts, positionals } = parseArgs({
|
||||
clock: { type: 'string', default: FROZEN_CLOCK },
|
||||
motion: { type: 'boolean', default: false },
|
||||
ignore: { type: 'string', multiple: true, default: [] },
|
||||
highlight: { type: 'string', multiple: true, default: [] },
|
||||
crop: { type: 'string', multiple: true, default: [] },
|
||||
flat: { type: 'boolean', default: false },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
list: { type: 'boolean', default: false },
|
||||
@@ -78,10 +72,6 @@ if (opts.help || (!outDir && !opts.list)) {
|
||||
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
|
||||
--motion keep animations and transitions running
|
||||
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
|
||||
--highlight <selector>
|
||||
also shoot <id>--highlight.png, every match ringed in red
|
||||
--crop <selector> also write one <id>--<n>.png per match under <theme>/crops,
|
||||
and montage them into <theme>/crops.png
|
||||
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
|
||||
--no-caption do not stamp the story and the run settings on the shot
|
||||
--list print the matched stories and exit
|
||||
@@ -145,17 +135,10 @@ if (!stories.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** A repeatable, comma-separated flag read as one CSS selector list. */
|
||||
const selectorList = (values) =>
|
||||
values
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
const ignoreSelectors = selectorList(opts.ignore);
|
||||
const highlightSelector = selectorList(opts.highlight);
|
||||
const cropSelector = selectorList(opts.crop);
|
||||
const ignoreSelectors = opts.ignore
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
|
||||
console.error(`--clock: not a date: ${opts.clock}`);
|
||||
@@ -169,12 +152,13 @@ if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
|
||||
* their bottom - is done by the preview itself, so a Chromatic build and a shot
|
||||
* from here see the same page.
|
||||
*/
|
||||
const ignoreCss = (ignore) => {
|
||||
const extra = ignore ? `, ${ignore}` : '';
|
||||
return `[data-shot-ignore], [data-chromatic='ignore']${extra} {
|
||||
const ignoreCss = (
|
||||
ignore,
|
||||
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
|
||||
.map((selector) => `, ${selector}`)
|
||||
.join('')} {
|
||||
visibility: hidden !important;
|
||||
}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
|
||||
@@ -264,9 +248,7 @@ const runConfig = {
|
||||
grow: opts.grow,
|
||||
motion: opts.motion ? 'live' : 'still',
|
||||
settle: opts.settle,
|
||||
ignore: ignoreSelectors,
|
||||
highlight: highlightSelector,
|
||||
crop: cropSelector,
|
||||
ignore: ignoreSelectors.join(', '),
|
||||
};
|
||||
|
||||
const captioning = !opts['no-caption'] && hasMagick();
|
||||
@@ -279,8 +261,6 @@ const configLine = settingsLine(runConfig, CONFIG_KEYS);
|
||||
|
||||
for (const theme of themes.length ? themes : [null]) {
|
||||
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
|
||||
const cropDir = path.join(dir, 'crops');
|
||||
const crops = [];
|
||||
await mkdir(dir, { recursive: true });
|
||||
if (theme) {
|
||||
console.log(`\n[${theme}]`);
|
||||
@@ -315,9 +295,6 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
// the viewport, kept only to flag the story in the log.
|
||||
let chasing = 0;
|
||||
|
||||
// What the story's line in the log says beyond ok/busy.
|
||||
const notes = [];
|
||||
|
||||
const url = new URL(`${base}/iframe.html`);
|
||||
url.searchParams.set('viewMode', 'story');
|
||||
url.searchParams.set('id', story.id);
|
||||
@@ -470,160 +447,47 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
shot = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The band goes on the shot itself so a single screenshot says what it
|
||||
* is, and its height is returned so a diff can take it back off. The
|
||||
* temporary is written beside the shot rather than in the system temp
|
||||
* directory: those are often separate filesystems, and a rename across
|
||||
* one fails with EXDEV.
|
||||
*/
|
||||
const caption = (target, lines) => {
|
||||
if (!captioning) {
|
||||
return 0;
|
||||
}
|
||||
const file = path.join(dir, `${story.id}.png`);
|
||||
await writeFile(file, shot);
|
||||
|
||||
// The band goes on the shot itself so a single screenshot says what it
|
||||
// is, and its height is recorded so a diff can take it back off.
|
||||
let caption = 0;
|
||||
if (captioning) {
|
||||
const temporary = path.join(
|
||||
path.dirname(target),
|
||||
`.caption-${process.pid}.png`,
|
||||
os.tmpdir(),
|
||||
`story-shots-caption-${process.pid}.png`,
|
||||
);
|
||||
const rows = stamp({
|
||||
caption = stamp({
|
||||
lines: [
|
||||
`${story.title}/${story.name}`,
|
||||
[story.id, theme ?? 'default', stable ? '' : '(busy)']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
...lines,
|
||||
configLine,
|
||||
].filter(Boolean),
|
||||
from: target,
|
||||
from: file,
|
||||
to: temporary,
|
||||
theme: theme ?? 'dark',
|
||||
});
|
||||
renameSync(temporary, target);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const file = path.join(dir, `${story.id}.png`);
|
||||
await writeFile(file, shot);
|
||||
|
||||
const record = (relative, caption) =>
|
||||
shots.push({
|
||||
file: path.posix.join(opts.flat ? '' : (theme ?? 'default'), relative),
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
name: story.name,
|
||||
theme: theme ?? 'default',
|
||||
status: stable ? 'ok' : 'busy',
|
||||
caption,
|
||||
});
|
||||
|
||||
record(`${story.id}.png`, caption(file, [configLine]));
|
||||
|
||||
// The crops are taken before anything is drawn over the page, so a
|
||||
// component's own shot carries no ring and no label: the montage at the
|
||||
// end of the theme is what names them.
|
||||
if (cropSelector) {
|
||||
await mkdir(cropDir, { recursive: true });
|
||||
|
||||
// A page that sizes itself in `vh` was shot back at `--height` with
|
||||
// its own scrollbar, and what is below the fold there is laid out but
|
||||
// never painted: cropping it gives a black rectangle. The crops alone
|
||||
// are taken at the height the rounds had reached, which is where the
|
||||
// page does paint.
|
||||
if (chasing) {
|
||||
await page.setViewportSize({
|
||||
width: Number(opts.width),
|
||||
height: chasing,
|
||||
});
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
|
||||
const matches = page.locator(cropSelector);
|
||||
let kept = 0;
|
||||
for (let index = 0; index < (await matches.count()); index += 1) {
|
||||
const element = matches.nth(index);
|
||||
// A group whose children are all conditional renders as a 0x0 box.
|
||||
// It has no counterpart on screen, so there is nothing to crop.
|
||||
const box = await element.boundingBox();
|
||||
if (!box || box.width < 1 || box.height < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
kept += 1;
|
||||
const relative = `${story.id}--${kept}.png`;
|
||||
// The scroll that brings an element into view needs a frame before
|
||||
// the crop, or the region comes back unpainted.
|
||||
await element.scrollIntoViewIfNeeded({ timeout: 15_000 });
|
||||
await page.waitForTimeout(250);
|
||||
await element.screenshot({
|
||||
path: path.join(cropDir, relative),
|
||||
timeout: 15_000,
|
||||
});
|
||||
crops.push({
|
||||
file: path.join(cropDir, relative),
|
||||
label: `${story.title}/${story.name} #${kept}`,
|
||||
});
|
||||
record(path.posix.join('crops', relative), 0);
|
||||
}
|
||||
notes.push(`${kept} cropped`);
|
||||
|
||||
if (chasing) {
|
||||
await page.setViewportSize({
|
||||
width: Number(opts.width),
|
||||
height: Number(opts.height),
|
||||
});
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
await rename(temporary, file);
|
||||
}
|
||||
|
||||
if (highlightSelector) {
|
||||
const ringed = await page.evaluate(
|
||||
([selector, padding]) => {
|
||||
const layer = document.createElement('div');
|
||||
// The shot is viewport-sized, so the rings are placed in viewport
|
||||
// coordinates and survive a page that stayed scrollable.
|
||||
layer.style.cssText =
|
||||
'position:fixed;inset:0;pointer-events:none;z-index:2147483647';
|
||||
let drawn = 0;
|
||||
for (const element of document.querySelectorAll(selector)) {
|
||||
const box = element.getBoundingClientRect();
|
||||
if (box.width < 1 || box.height < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
drawn += 1;
|
||||
const ring = document.createElement('div');
|
||||
ring.style.cssText = `position:fixed;box-sizing:border-box;border:3px solid #ff003a;border-radius:4px;left:${
|
||||
box.left - padding
|
||||
}px;top:${box.top - padding}px;width:${
|
||||
box.width + padding * 2
|
||||
}px;height:${box.height + padding * 2}px`;
|
||||
layer.append(ring);
|
||||
}
|
||||
document.documentElement.append(layer);
|
||||
window.__storyShotsHighlight = layer;
|
||||
return drawn;
|
||||
},
|
||||
[highlightSelector, 6],
|
||||
);
|
||||
|
||||
const highlighted = path.join(dir, `${story.id}--highlight.png`);
|
||||
await writeFile(highlighted, await page.screenshot());
|
||||
record(
|
||||
`${story.id}--highlight.png`,
|
||||
caption(highlighted, [`${ringed} highlighted`, configLine]),
|
||||
);
|
||||
notes.push(`${ringed} highlighted`);
|
||||
await page.evaluate(() => {
|
||||
window.__storyShotsHighlight?.remove();
|
||||
delete window.__storyShotsHighlight;
|
||||
});
|
||||
}
|
||||
if (chasing) {
|
||||
notes.push(`viewport-sized content, stopped chasing ${chasing}px`);
|
||||
}
|
||||
shots.push({
|
||||
file: path.posix.join(
|
||||
opts.flat ? '' : (theme ?? 'default'),
|
||||
`${story.id}.png`,
|
||||
),
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
name: story.name,
|
||||
theme: theme ?? 'default',
|
||||
status: stable ? 'ok' : 'busy',
|
||||
caption,
|
||||
});
|
||||
console.log(
|
||||
` ${stable ? 'ok ' : 'busy'} ${story.id}${
|
||||
notes.length ? ` (${notes.join(', ')})` : ''
|
||||
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -633,52 +497,6 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// One image of every crop the theme produced, labelled with the story it came
|
||||
// from. A component that appears on eight pages is a survey rather than eight
|
||||
// screenshots to open one after another.
|
||||
if (crops.length && captioning) {
|
||||
const sheet = path.join(dir, 'crops.png');
|
||||
const { background, foreground } = palette(theme ?? 'dark');
|
||||
|
||||
// `montage -label` sizes every tile to the widest *image*, so a label
|
||||
// longer than its crop runs under the next one. Each crop is composed with
|
||||
// its own label first, which sizes the tile to whichever of the two is
|
||||
// wider, and the sheet is then a montage of finished tiles.
|
||||
const tiles = crops.map(({ file, label }, index) => {
|
||||
const tile = path.join(cropDir, `.tile-${index}.png`);
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
'16',
|
||||
file,
|
||||
`label:${literal(label)}`,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
tile,
|
||||
]);
|
||||
return tile;
|
||||
});
|
||||
|
||||
magick([
|
||||
'montage',
|
||||
'-background',
|
||||
background,
|
||||
'-tile',
|
||||
'2x',
|
||||
'-geometry',
|
||||
'+16+16',
|
||||
...tiles,
|
||||
sheet,
|
||||
]);
|
||||
tiles.forEach((tile) => rmSync(tile, { force: true }));
|
||||
console.log(` ${crops.length} crops -> ${sheet}`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
@@ -28,4 +28,4 @@ until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 --testTimeout 30000 "$@"
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 "$@"
|
||||
|
||||
@@ -1588,10 +1588,15 @@ describe('PrivateRoute', () => {
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
|
||||
TRACE_DETAIL: {
|
||||
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACE_DETAIL_OLD: {
|
||||
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
|
||||
// route definition comes last, and both keys are authz-aware either way.
|
||||
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
|
||||
|
||||
@@ -53,6 +53,17 @@ export const TracesFunnelDetails = Loadable(
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceFilter = Loadable(
|
||||
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
|
||||
);
|
||||
|
||||
export const TraceDetailOldRedirect = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetailOldRedirect" */ 'pages/TraceDetailOldRedirect/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailV3 = Loadable(
|
||||
() =>
|
||||
import(
|
||||
|
||||
@@ -47,7 +47,9 @@ import {
|
||||
SomethingWentWrong,
|
||||
StatusPage,
|
||||
SupportPage,
|
||||
TraceDetailOldRedirect,
|
||||
TraceDetailV3,
|
||||
TraceFilter,
|
||||
TracesExplorer,
|
||||
TracesFunnelDetails,
|
||||
TracesFunnels,
|
||||
@@ -130,6 +132,14 @@ const routes: AppRoutes[] = [
|
||||
exact: true,
|
||||
key: 'LOGS_SAVE_VIEWS',
|
||||
},
|
||||
// Legacy /trace-old/:id redirects to the current /trace/:id view.
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
component: TraceDetailOldRedirect,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL_OLD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
@@ -214,6 +224,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'ALERT_OVERVIEW',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACE,
|
||||
exact: true,
|
||||
component: TraceFilter,
|
||||
isPrivate: true,
|
||||
key: 'TRACE',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACES_EXPLORER,
|
||||
exact: true,
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
AlertmanagertypesRepairChannelParamsDTO,
|
||||
AlertmanagertypesTestableNotificationChannelDTO,
|
||||
AlertmanagertypesUpdatableNotificationChannelDTO,
|
||||
CreateChannel201,
|
||||
@@ -36,9 +35,6 @@ import type {
|
||||
ListNotificationChannels200,
|
||||
ListNotificationChannelsParams,
|
||||
RenderErrorResponseDTO,
|
||||
RepairNotificationChannel200,
|
||||
RepairNotificationChannelParams,
|
||||
RepairNotificationChannelPathParameters,
|
||||
UpdateChannelByIDPathParameters,
|
||||
UpdateNotificationChannel200,
|
||||
UpdateNotificationChannelPathParameters,
|
||||
@@ -1148,113 +1144,6 @@ export const useUpdateNotificationChannel = <
|
||||
> => {
|
||||
return useMutation(getUpdateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint diagnoses a stored channel that the v2 API cannot read and applies the fitting action: a channel carrying several notifier configurations is split into one channel per configuration, keeping this ID for the first; a channel whose notifier kind v2 does not model is deleted; a channel with an empty stored type has it rewritten from its data. A delete is refused while a routing policy still names the channel. Nothing is written unless apply=true; by default the response only shows what would happen.
|
||||
* @summary Repair notification channel
|
||||
*/
|
||||
export const repairNotificationChannel = (
|
||||
{ id }: RepairNotificationChannelPathParameters,
|
||||
alertmanagertypesRepairChannelParamsDTO?: BodyType<AlertmanagertypesRepairChannelParamsDTO>,
|
||||
params?: RepairNotificationChannelParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<RepairNotificationChannel200>({
|
||||
url: `/api/v2/notification_channels/${id}/repair`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesRepairChannelParamsDTO,
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getRepairNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof repairNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: RepairNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
|
||||
params?: RepairNotificationChannelParams;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof repairNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: RepairNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
|
||||
params?: RepairNotificationChannelParams;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['repairNotificationChannel'];
|
||||
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 repairNotificationChannel>>,
|
||||
{
|
||||
pathParams: RepairNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
|
||||
params?: RepairNotificationChannelParams;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data, params } = props ?? {};
|
||||
|
||||
return repairNotificationChannel(pathParams, data, params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RepairNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof repairNotificationChannel>>
|
||||
>;
|
||||
export type RepairNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesRepairChannelParamsDTO>
|
||||
| undefined;
|
||||
export type RepairNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Repair notification channel
|
||||
*/
|
||||
export const useRepairNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof repairNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: RepairNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
|
||||
params?: RepairNotificationChannelParams;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof repairNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: RepairNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
|
||||
params?: RepairNotificationChannelParams;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRepairNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
|
||||
* @summary Test notification channel
|
||||
|
||||
@@ -40,73 +40,7 @@ export interface AlertmanagertypesChannelDTO {
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
|
||||
slack = 'slack',
|
||||
}
|
||||
export interface AlertmanagertypesChannelSlackConfirmationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dismissText?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
okText?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackActionDTO {
|
||||
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
style?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackFieldDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
short?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
@@ -116,26 +50,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
* @type string
|
||||
*/
|
||||
channel?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
footer?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
pretext?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
@@ -148,10 +62,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
titleLink?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
|
||||
@@ -596,13 +506,6 @@ export type AlertmanagertypesChannelConfigDTO =
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
|
||||
export enum AlertmanagertypesChannelDefectDTO {
|
||||
none = 'none',
|
||||
missing_type = 'missing_type',
|
||||
multiple_notifiers = 'multiple_notifiers',
|
||||
unsupported_notifier = 'unsupported_notifier',
|
||||
unrepresentable = 'unrepresentable',
|
||||
}
|
||||
export enum AlertmanagertypesChannelKindDTO {
|
||||
slack = 'slack',
|
||||
email = 'email',
|
||||
@@ -624,63 +527,6 @@ export enum AlertmanagertypesChannelListSortDTO {
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export enum AlertmanagertypesChannelRepairActionDTO {
|
||||
none = 'none',
|
||||
retype = 'retype',
|
||||
split = 'split',
|
||||
delete = 'delete',
|
||||
}
|
||||
export interface AlertmanagertypesListedNotificationChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
kind: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelRepairDTO {
|
||||
action: AlertmanagertypesChannelRepairActionDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
applied: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
blockers?: string[];
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
channels?: AlertmanagertypesListedNotificationChannelDTO[] | null;
|
||||
defect: AlertmanagertypesChannelDefectDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
detail?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ModelLabelSetDTO {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -1174,6 +1020,32 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListedNotificationChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
kind: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListableNotificationChannelDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -2577,13 +2449,6 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesRepairChannelParamsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
apply?: boolean;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesTestableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
@@ -4144,52 +4009,6 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAreaFillModeDTO {
|
||||
solid = 'solid',
|
||||
gradient = 'gradient',
|
||||
}
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 1
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesFillOpacityDTO = number | null;
|
||||
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesAreaFillModeDTO;
|
||||
fillOpacity?: DashboardtypesFillOpacityDTO | null;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
lineStyle?: DashboardtypesLineStyleDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
showPoints?: boolean;
|
||||
spanGaps?: DashboardtypesSpanGapsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4267,11 +4086,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesStackModeDTO {
|
||||
none = 'none',
|
||||
normal = 'normal',
|
||||
percent = 'percent',
|
||||
}
|
||||
export enum DashboardtypesTimePreferenceDTO {
|
||||
global_time = 'global_time',
|
||||
last_5_min = 'last_5_min',
|
||||
@@ -4284,27 +4098,6 @@ export enum DashboardtypesTimePreferenceDTO {
|
||||
last_1_week = 'last_1_week',
|
||||
last_1_month = 'last_1_month',
|
||||
}
|
||||
export interface DashboardtypesAreaChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
stack?: DashboardtypesStackModeDTO;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesAreaChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -5002,6 +4795,29 @@ export enum DashboardtypesFillModeDTO {
|
||||
gradient = 'gradient',
|
||||
none = 'none',
|
||||
}
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTimeSeriesChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesFillModeDTO;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
@@ -5054,18 +4870,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind {
|
||||
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
|
||||
}
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/AreaChartPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind;
|
||||
spec: DashboardtypesAreaChartPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind {
|
||||
'signoz/NumberPanel' = 'signoz/NumberPanel',
|
||||
}
|
||||
@@ -5268,16 +5072,93 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
export enum DashboardtypesHeatmapYScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
}
|
||||
export interface DashboardtypesHeatmapAxesDTO {
|
||||
yScale?: DashboardtypesHeatmapYScaleDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesHeatmapColorModeDTO {
|
||||
palette = 'palette',
|
||||
opacity = 'opacity',
|
||||
}
|
||||
export enum DashboardtypesHeatmapPaletteDTO {
|
||||
ice = 'ice',
|
||||
moss = 'moss',
|
||||
rust = 'rust',
|
||||
graphite = 'graphite',
|
||||
ember = 'ember',
|
||||
lagoon = 'lagoon',
|
||||
orchid = 'orchid',
|
||||
verdant = 'verdant',
|
||||
lava = 'lava',
|
||||
beacon = 'beacon',
|
||||
}
|
||||
export enum DashboardtypesHeatmapColorScaleDTO {
|
||||
log = 'log',
|
||||
sqrt = 'sqrt',
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface DashboardtypesHeatmapColorsDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fill?: string;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
maxCount?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
minCount?: number | null;
|
||||
mode?: DashboardtypesHeatmapColorModeDTO;
|
||||
palette?: DashboardtypesHeatmapPaletteDTO;
|
||||
scale?: DashboardtypesHeatmapColorScaleDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapChartAppearanceDTO {
|
||||
colors?: DashboardtypesHeatmapColorsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapPanelSpecDTO {
|
||||
axes?: DashboardtypesHeatmapAxesDTO;
|
||||
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/HeatmapPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
|
||||
spec: DashboardtypesHeatmapPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6196,13 +6077,13 @@ export interface DashboardtypesListableDashboardViewDTO {
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
|
||||
'signoz/NumberPanel' = 'signoz/NumberPanel',
|
||||
'signoz/PieChartPanel' = 'signoz/PieChartPanel',
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -10880,22 +10761,6 @@ export interface SpantypesGettableFlamegraphTraceDTO {
|
||||
startTimestampMillis: number;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOriginDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
}
|
||||
export interface SpantypesSpanMapperGroupConditionKeyDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
@@ -10903,11 +10768,11 @@ export type SpantypesSpanMapperGroupConditionDTO = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
attributes: string[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
resource: string[] | null;
|
||||
} | null;
|
||||
|
||||
export interface SpantypesSpanMapperGroupDTO {
|
||||
@@ -10937,7 +10802,6 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -10947,10 +10811,6 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
@@ -11008,16 +10868,11 @@ export enum SpantypesSpanMapperOperationDTO {
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -11059,7 +10914,6 @@ export interface SpantypesSpanMapperDTO {
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -13619,25 +13473,6 @@ export type UpdateNotificationChannel200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type RepairNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type RepairNotificationChannelParams = {
|
||||
/**
|
||||
* @type boolean
|
||||
* @description undefined
|
||||
*/
|
||||
apply?: boolean;
|
||||
};
|
||||
|
||||
export type RepairNotificationChannel200 = {
|
||||
data: AlertmanagertypesChannelRepairDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyOrganization200 = {
|
||||
data: TypesOrganizationDTO;
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type {
|
||||
GetAIObservabilityFieldsKeys200,
|
||||
GetAIObservabilityFieldsValues200,
|
||||
GetAIObservabilityFieldsKeysParams,
|
||||
GetAIObservabilityFieldsValuesParams,
|
||||
GetFieldsKeys200,
|
||||
GetFieldsKeysParams,
|
||||
GetFieldsValues200,
|
||||
GetFieldsValuesParams,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type FieldKeysConfig = GetFieldsKeysParams;
|
||||
export type FieldKeysConfig =
|
||||
| GetFieldsKeysParams
|
||||
| GetAIObservabilityFieldsKeysParams;
|
||||
|
||||
export type FieldValuesConfig = GetFieldsValuesParams;
|
||||
export type FieldValuesConfig =
|
||||
| GetFieldsValuesParams
|
||||
| GetAIObservabilityFieldsValuesParams;
|
||||
|
||||
export type FieldKeysConfigProp = Omit<
|
||||
FieldKeysConfig,
|
||||
|
||||
49
frontend/src/api/trace/getFilters.ts
Normal file
49
frontend/src/api/trace/getFilters.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import omitBy from 'lodash-es/omitBy';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/trace/getFilters';
|
||||
|
||||
const getFilters = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const duration =
|
||||
omitBy(props.other, (_, key) => !key.startsWith('duration')) || [];
|
||||
|
||||
const nonDuration = omitBy(props.other, (_, key) =>
|
||||
key.startsWith('duration'),
|
||||
);
|
||||
|
||||
const exclude: string[] = [];
|
||||
|
||||
props.isFilterExclude.forEach((value, key) => {
|
||||
if (value) {
|
||||
exclude.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await axios.post<PayloadProps>(`/getSpanFilters`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
getFilters: props.getFilters,
|
||||
...nonDuration,
|
||||
maxDuration: String((duration.duration || [])[0] || ''),
|
||||
minDuration: String((duration.duration || [])[1] || ''),
|
||||
exclude,
|
||||
spanKind: props.spanKind,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getFilters;
|
||||
62
frontend/src/api/trace/getSpans.ts
Normal file
62
frontend/src/api/trace/getSpans.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import omitBy from 'lodash-es/omitBy';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/trace/getSpans';
|
||||
|
||||
const getSpans = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const updatedSelectedTags = props.selectedTags.map((e) => ({
|
||||
Key: `${e.Key}.(string)`,
|
||||
Operator: e.Operator,
|
||||
StringValues: e.StringValues,
|
||||
NumberValues: e.NumberValues,
|
||||
BoolValues: e.BoolValues,
|
||||
}));
|
||||
|
||||
const exclude: string[] = [];
|
||||
|
||||
props.isFilterExclude.forEach((value, key) => {
|
||||
if (value) {
|
||||
exclude.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
const other = Object.fromEntries(props.selectedFilter);
|
||||
|
||||
const duration = omitBy(other, (_, key) => !key.startsWith('duration')) || [];
|
||||
|
||||
const nonDuration = omitBy(other, (_, key) => key.startsWith('duration'));
|
||||
|
||||
const response = await axios.post<PayloadProps>(
|
||||
`/getFilteredSpans/aggregates`,
|
||||
{
|
||||
start: String(props.start),
|
||||
end: String(props.end),
|
||||
function: props.function,
|
||||
groupBy: props.groupBy === 'none' ? '' : props.groupBy,
|
||||
step: props.step,
|
||||
tags: updatedSelectedTags,
|
||||
...nonDuration,
|
||||
maxDuration: String((duration.duration || [])[0] || ''),
|
||||
minDuration: String((duration.duration || [])[1] || ''),
|
||||
exclude,
|
||||
spanKind: props.spanKind,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getSpans;
|
||||
65
frontend/src/api/trace/getSpansAggregate.ts
Normal file
65
frontend/src/api/trace/getSpansAggregate.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import omitBy from 'lodash-es/omitBy';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/trace/getSpanAggregate';
|
||||
import { TraceFilterEnum } from 'types/reducer/trace';
|
||||
|
||||
const getSpanAggregate = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const preProps = {
|
||||
start: String(props.start),
|
||||
end: String(props.end),
|
||||
limit: props.limit,
|
||||
offset: props.offset,
|
||||
order: props.order,
|
||||
orderParam: props.orderParam,
|
||||
};
|
||||
|
||||
const exclude: TraceFilterEnum[] = [];
|
||||
|
||||
props.isFilterExclude.forEach((value, key) => {
|
||||
if (value) {
|
||||
exclude.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
const updatedSelectedTags = props.selectedTags.map((e) => ({
|
||||
Key: `${e.Key}.(string)`,
|
||||
Operator: e.Operator,
|
||||
StringValues: e.StringValues,
|
||||
NumberValues: e.NumberValues,
|
||||
BoolValues: e.BoolValues,
|
||||
}));
|
||||
|
||||
const other = Object.fromEntries(props.selectedFilter);
|
||||
|
||||
const duration = omitBy(other, (_, key) => !key.startsWith('duration')) || [];
|
||||
|
||||
const nonDuration = omitBy(other, (_, key) => key.startsWith('duration'));
|
||||
|
||||
const response = await axios.post<PayloadProps>(`/getFilteredSpans`, {
|
||||
...preProps,
|
||||
tags: updatedSelectedTags,
|
||||
...nonDuration,
|
||||
maxDuration: String((duration.duration || [])[0] || ''),
|
||||
minDuration: String((duration.duration || [])[1] || ''),
|
||||
exclude,
|
||||
spanKind: props.spanKind,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getSpanAggregate;
|
||||
49
frontend/src/api/trace/getTagFilter.ts
Normal file
49
frontend/src/api/trace/getTagFilter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { omitBy } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/trace/getTagFilters';
|
||||
import { TraceFilterEnum } from 'types/reducer/trace';
|
||||
|
||||
const getTagFilters = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const duration =
|
||||
omitBy(props.other, (_, key) => !key.startsWith('duration')) || [];
|
||||
|
||||
const exclude: TraceFilterEnum[] = [];
|
||||
|
||||
props.isFilterExclude.forEach((value, key) => {
|
||||
if (value) {
|
||||
exclude.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
const nonDuration = omitBy(props.other, (_, key) =>
|
||||
key.startsWith('duration'),
|
||||
);
|
||||
|
||||
const response = await axios.post<PayloadProps>(`/getTagFilters`, {
|
||||
start: String(props.start),
|
||||
end: String(props.end),
|
||||
...nonDuration,
|
||||
maxDuration: String((duration.duration || [])[0] || ''),
|
||||
minDuration: String((duration.duration || [])[1] || ''),
|
||||
exclude,
|
||||
spanKind: props.spanKind,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getTagFilters;
|
||||
31
frontend/src/api/trace/getTagValue.ts
Normal file
31
frontend/src/api/trace/getTagValue.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/trace/getTagValue';
|
||||
|
||||
const getTagValue = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/getTagValues`, {
|
||||
start: props.start.toString(),
|
||||
end: props.end.toString(),
|
||||
tagKey: {
|
||||
Key: props.tagKey.Key,
|
||||
Type: props.tagKey.Type,
|
||||
},
|
||||
spanKind: props.spanKind,
|
||||
});
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getTagValue;
|
||||
@@ -198,6 +198,10 @@ function createBaseSpec(
|
||||
: undefined,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
having: normalizeHaving(queryData.having),
|
||||
// Heatmap only. Every other request type rejects an axis, and
|
||||
// `panelTypeDataSourceFormValuesMap` is what keeps one from being carried onto a
|
||||
// query the panel type switched away from.
|
||||
bucketOptions: queryData.bucketOptions,
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map((func: QueryFunction): QueryFunction => {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import dayjs from 'dayjs';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
|
||||
const minTime = dayjs('2025-01-15T11:00:00Z').valueOf() * 1_000_000;
|
||||
const maxTime = dayjs('2025-01-15T12:00:00Z').valueOf() * 1_000_000;
|
||||
|
||||
function TimePickerFixture(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedTime, setSelectedTime] = useState('1h');
|
||||
|
||||
return (
|
||||
<CustomTimePicker
|
||||
isModalTimeSelection
|
||||
items={[
|
||||
{ label: 'Last 15 minutes', value: '15m' },
|
||||
{ label: 'Last 1 hour', value: '1h' },
|
||||
{ label: 'Last 6 hours', value: '6h' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
]}
|
||||
maxTime={maxTime}
|
||||
minTime={minTime}
|
||||
newPopover
|
||||
open={open}
|
||||
onCustomDateHandler={(): void => undefined}
|
||||
onError={(): void => undefined}
|
||||
onSelect={(value): void => setSelectedTime(value)}
|
||||
onValidCustomDateChange={(): void => undefined}
|
||||
selectedTime={selectedTime}
|
||||
selectedValue="15 Jan 2025 11:00:00 - 15 Jan 2025 12:00:00"
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Custom Time Picker',
|
||||
component: TimePickerFixture,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 400 })],
|
||||
} satisfies Meta<typeof TimePickerFixture>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Interaction: the time-range menu is open with its relative-range choices. */
|
||||
export const TimeRangeMenuOpen: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByRole('textbox'));
|
||||
await screen.findByText('RELATIVE TIMES');
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the timezone menu is reached through the real time-range footer. */
|
||||
export const TimezoneMenuOpen: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByRole('textbox'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Change Timezone' }),
|
||||
);
|
||||
await screen.findByPlaceholderText('Search timezones...');
|
||||
},
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
export const fieldSuggestionsHandlers = [
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
fieldKeysResponse(['service.name', 'body'], {
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
export const noFieldSuggestionsHandlers = [
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldKeysResponse([]))),
|
||||
),
|
||||
];
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from '../FieldsSelector';
|
||||
import {
|
||||
fieldSuggestionsHandlers,
|
||||
noFieldSuggestionsHandlers,
|
||||
} from './FieldsSelector.stories.mocks';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Fields Selector',
|
||||
component: FieldsSelector,
|
||||
tags: ['play'],
|
||||
args: {
|
||||
allowCustomFields: true,
|
||||
defaultPosition: { x: 40, y: 40 },
|
||||
fields: [
|
||||
{
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
height: 560,
|
||||
isOpen: true,
|
||||
onClose: (): void => undefined,
|
||||
onFieldsChange: (): void => undefined,
|
||||
signal: DataSource.LOGS,
|
||||
title: 'Edit log columns',
|
||||
width: 420,
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: fieldSuggestionsHandlers,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof FieldsSelector>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Open: the draggable field editor shows its selected and available columns. */
|
||||
export const Open: Story = {};
|
||||
|
||||
/** Mutation: adding a suggested field exposes the real unsaved-change footer. */
|
||||
export const UnsavedChanges: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
// One Add per suggested field, so the first row's is the one clicked.
|
||||
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.click(addField);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: the suggestion request succeeds with no columns to add. */
|
||||
export const NoResults: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: noFieldSuggestionsHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Limit: available columns cannot be added once the configured maximum is reached. */
|
||||
export const MaximumFields: Story = {
|
||||
args: {
|
||||
fields: [
|
||||
{
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
name: 'severity_text',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
maxFields: 2,
|
||||
},
|
||||
};
|
||||
|
||||
/** Required: mandatory fields remain present without removal controls. */
|
||||
export const RequiredFields: Story = {
|
||||
args: {
|
||||
fields: [
|
||||
{
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
name: 'service.name',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
requiredFields: ['resource:service.name:string'],
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,10 @@
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
|
||||
.label {
|
||||
// Typography.Text takes its display from this token; at its `inline` default
|
||||
// the label blockifies as a flex item and the text rides the top of the row.
|
||||
--typography-text-display: flex;
|
||||
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import { CustomMultiSelect, CustomSelect } from '../index';
|
||||
|
||||
const options = [
|
||||
{ label: 'Checkout', value: 'checkout' },
|
||||
{ label: 'Frontend', value: 'frontend' },
|
||||
{ label: 'Payments', value: 'payments' },
|
||||
{ label: 'Search', value: 'search' },
|
||||
];
|
||||
|
||||
const longOptions = Array.from({ length: 24 }, (_, index) => ({
|
||||
label: `Service ${String(index + 1).padStart(2, '0')}`,
|
||||
value: `service-${index + 1}`,
|
||||
}));
|
||||
|
||||
const meta = {
|
||||
title: 'Components/New Select',
|
||||
component: CustomSelect,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 360 })],
|
||||
args: {
|
||||
'aria-label': 'Service',
|
||||
options,
|
||||
placeholder: 'Select a service',
|
||||
},
|
||||
} satisfies Meta<typeof CustomSelect>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type TooltipsStory = StoryObj<GlobalMockArgs>;
|
||||
|
||||
/** Interaction: the body-portal menu is open for stacking and clipping review. */
|
||||
export const PortalOpen: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Density: a long result list keeps the menu scrollable. */
|
||||
export const LongResults: Story = {
|
||||
args: { options: longOptions },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('Service 24');
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: the select reports its supported no-data state. */
|
||||
export const NoResults: Story = {
|
||||
args: { noDataMessage: 'No services found', options: [] },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('No services found');
|
||||
},
|
||||
};
|
||||
|
||||
/** Loading: the open menu keeps its in-progress refresh feedback visible. */
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
options: [],
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('Refreshing values...');
|
||||
},
|
||||
};
|
||||
|
||||
/** Error: a retryable failed request remains visible in the open menu. */
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
errorMessage: 'Could not load services',
|
||||
onRetry: (): void => undefined,
|
||||
options: [],
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('Could not load services');
|
||||
},
|
||||
};
|
||||
|
||||
/** Selection: selected and unavailable options are distinguishable before choosing. */
|
||||
export const SelectedDisabled: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ label: 'Checkout', value: 'checkout' },
|
||||
{ disabled: true, label: 'Legacy billing', value: 'legacy-billing' },
|
||||
{ label: 'Payments', value: 'payments' },
|
||||
],
|
||||
value: 'checkout',
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByRole('option', { name: 'Legacy billing' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Overflow: a multi-select preserves its selected values when its trigger is constrained. */
|
||||
export const MultiValueOverflow: Story = {
|
||||
render: (): JSX.Element => (
|
||||
<div style={{ maxWidth: 280 }}>
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
maxTagCount={2}
|
||||
options={longOptions}
|
||||
value={['service-1', 'service-2', 'service-3', 'service-4']}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const LONG_LABEL_OPTION = {
|
||||
label:
|
||||
'checkout-service.production-eu-central-1.svc.cluster.local:8080/v1/orders/{orderId}/payment-authorisation',
|
||||
value: 'checkout-payment-authorisation',
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the select renders, held open: the selected chip revealing the
|
||||
* option label it was cut from. Nothing bounds that label, so the chip is given
|
||||
* one long enough to need the reveal.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { tooltipsOpen: true },
|
||||
render: (): JSX.Element => (
|
||||
<div style={{ maxWidth: 280 }}>
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={14}
|
||||
options={[LONG_LABEL_OPTION, ...options]}
|
||||
value={[LONG_LABEL_OPTION.value]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
/**
|
||||
* The catch-all has no route of its own: it answers for whatever pathname the
|
||||
* `Switch` ran out of routes for, and it calls nothing.
|
||||
*/
|
||||
export const notFoundMocks = defineStoryMocks({
|
||||
controls: {},
|
||||
config: () => ({ route: '/no-such-page' }),
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import NotFound from '../index';
|
||||
import { notFoundMocks } from './NotFound.stories.mocks';
|
||||
|
||||
type NotFoundArgs = PageStoryArgs<typeof notFoundMocks>;
|
||||
|
||||
/**
|
||||
* The catch-all route mounts it with no props, and its `defaultProps` is what
|
||||
* keeps the component itself from typing as one that takes the story's args.
|
||||
*/
|
||||
function CatchAllPage(): JSX.Element {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
const pageStory = storyMocks(notFoundMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The shell around a pathname no route matched: the side nav stays, the content
|
||||
* area carries the 404.
|
||||
*
|
||||
* Route: any unmatched path.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/System/Not Found',
|
||||
component: CatchAllPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<NotFoundArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<NotFoundArgs>;
|
||||
|
||||
/**
|
||||
* What the app shows for a pathname no route matched, inside the shell: the
|
||||
* side nav is still there, and the way back is the home button.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Borrows the query builder's control metrics rather than the component defaults the
|
||||
* toggle group and number input ship with: 36px tall, 2px radius, and the
|
||||
* `--query-builder-v2-*` surface the selects above this row already paint on.
|
||||
*/
|
||||
.bucketOptions {
|
||||
--toggle-group-radius: var(--radius-1);
|
||||
--toggle-group-item-size: 36px;
|
||||
--toggle-group-item-font-size: 13px;
|
||||
--toggle-group-item-padding-left: var(--spacing-6);
|
||||
--toggle-group-item-padding-right: var(--spacing-6);
|
||||
|
||||
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
|
||||
// formula, which has no add-ons ancestor to pick the toggle palette up from.
|
||||
--toggle-group-secondary-bg: var(
|
||||
--query-builder-v2-toggle-group-background-color,
|
||||
var(--l1-background-hover)
|
||||
);
|
||||
--toggle-group-secondary-border: var(
|
||||
--query-builder-v2-toggle-group-border-color,
|
||||
var(--l2-border)
|
||||
);
|
||||
--toggle-group-secondary-active-bg: var(
|
||||
--query-builder-v2-toggle-group-active-background-color,
|
||||
var(--l1-background)
|
||||
);
|
||||
--toggle-group-secondary-bg-hover: var(
|
||||
--query-builder-v2-toggle-group-background-color-hover,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
--input-height: 36px;
|
||||
--input-font-size: 13px;
|
||||
--input-border-radius: var(--radius-1);
|
||||
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
--input-background: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
padding: var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.bounds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
color: var(--l3-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle group exposes a font-size token but no family, so each item's label carries
|
||||
* the query builder's value type itself.
|
||||
*/
|
||||
.toggleLabel {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
|
||||
.numberInput {
|
||||
width: 104px;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Unfilled: a pill on the same row as the toggles above reads as another control,
|
||||
* and the bounds are derived rather than set here. */
|
||||
.bound {
|
||||
padding: var(--spacing-1) 0;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
.overflowBound {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hintIcon {
|
||||
flex: none;
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
margin-left: auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InputNumber } from '@signozhq/ui/input-number';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ChevronUp, Info } from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
BUCKET_KIND_HINTS,
|
||||
BUCKET_KIND_OPTIONS,
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
LOG_SCALE_OPTIONS,
|
||||
MAX_NUM_BUCKETS,
|
||||
} from './constants';
|
||||
import {
|
||||
BucketKindOption,
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
isLinearBuckets,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
logScaleOf,
|
||||
previewUpperBounds,
|
||||
} from './utils';
|
||||
|
||||
import styles from './BucketOptions.module.scss';
|
||||
|
||||
function BucketOptions({
|
||||
bucketOptions,
|
||||
unit,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
|
||||
unit?: string;
|
||||
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
|
||||
/** Omitted where the section isn't dismissable, as on a formula. */
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
// A linear axis has no bounds to describe until it has a max value, so the picked
|
||||
// kind is held here rather than read back off the emitted options: it has to survive
|
||||
// the gap between choosing Linear and filling the field in.
|
||||
const [kind, setKind] = useState<BucketKindOption>(
|
||||
kindOptionOf(bucketOptions),
|
||||
);
|
||||
const linearSpec =
|
||||
bucketOptions && isLinearBuckets(bucketOptions)
|
||||
? bucketOptions.spec
|
||||
: undefined;
|
||||
const [logScale, setLogScale] = useState<number>(logScaleOf(bucketOptions));
|
||||
const [maxValue, setMaxValue] = useState<number | null>(
|
||||
linearSpec?.maxValue ?? null,
|
||||
);
|
||||
const [numBuckets, setNumBuckets] = useState<number | null>(
|
||||
linearSpec?.numBuckets ?? null,
|
||||
);
|
||||
|
||||
const emitLinear = useCallback(
|
||||
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
|
||||
// An incomplete linear axis is sent as no axis at all rather than as a spec the
|
||||
// request would reject.
|
||||
if (nextMaxValue === null || nextMaxValue <= 0) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleKindChange = useCallback(
|
||||
(value: string): void => {
|
||||
// Radix clears the value when the active item is clicked again; a bucket axis is
|
||||
// always one of the three, so keep the current pick instead.
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKind = value as BucketKindOption;
|
||||
setKind(nextKind);
|
||||
|
||||
if (nextKind === 'auto') {
|
||||
onChange(undefined);
|
||||
} else if (nextKind === 'log') {
|
||||
onChange(logBuckets(logScale));
|
||||
} else {
|
||||
emitLinear(maxValue, numBuckets);
|
||||
}
|
||||
},
|
||||
[emitLinear, logScale, maxValue, numBuckets, onChange],
|
||||
);
|
||||
|
||||
const handleScaleChange = useCallback(
|
||||
(value: string): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScale = Number(value);
|
||||
setLogScale(nextScale);
|
||||
onChange(logBuckets(nextScale));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleMaxValueChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setMaxValue(next);
|
||||
emitLinear(next, numBuckets);
|
||||
},
|
||||
[emitLinear, numBuckets],
|
||||
);
|
||||
|
||||
const handleNumBucketsChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setNumBuckets(next);
|
||||
emitLinear(maxValue, next);
|
||||
},
|
||||
[emitLinear, maxValue],
|
||||
);
|
||||
|
||||
// The toggle group takes a ReactNode label, which is how each item picks up the
|
||||
// query builder's value type — the component exposes no font-family token.
|
||||
const kindItems = useMemo(
|
||||
() =>
|
||||
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const scaleItems = useMemo(
|
||||
() =>
|
||||
LOG_SCALE_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
// The kind toggle can be ahead of what has been emitted, so the preview describes
|
||||
// the picked kind rather than the emitted options.
|
||||
const previewedOptions = useMemo(():
|
||||
| Querybuildertypesv5BucketOptionsDTO
|
||||
| undefined => {
|
||||
if (kind === 'log') {
|
||||
return logBuckets(logScale);
|
||||
}
|
||||
if (kind === 'linear' && maxValue !== null) {
|
||||
return linearBuckets(maxValue, numBuckets);
|
||||
}
|
||||
return undefined;
|
||||
}, [kind, logScale, maxValue, numBuckets]);
|
||||
|
||||
const bounds =
|
||||
kind === 'linear' && !previewedOptions
|
||||
? undefined
|
||||
: previewUpperBounds(previewedOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.bucketOptions} data-testid="bucket-options">
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Bucket by</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={kind}
|
||||
items={kindItems}
|
||||
onChange={handleKindChange}
|
||||
testId="bucket-options-kind"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kind === 'log' && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Scale</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={String(logScale)}
|
||||
items={scaleItems}
|
||||
onChange={handleScaleChange}
|
||||
testId="bucket-options-scale"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'linear' && (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Max value</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={0}
|
||||
value={maxValue}
|
||||
onChange={handleMaxValueChange}
|
||||
placeholder="Required"
|
||||
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
|
||||
data-testid="bucket-options-max-value"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Buckets</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={1}
|
||||
max={MAX_NUM_BUCKETS}
|
||||
precision={0}
|
||||
value={numBuckets}
|
||||
onChange={handleNumBucketsChange}
|
||||
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
|
||||
data-testid="bucket-options-num-buckets"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
className={cx('periscope-btn', 'ghost', styles.closeBtn)}
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={onClose}
|
||||
data-testid="bucket-options-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.bounds} data-testid="bucket-options-bounds">
|
||||
<span className={styles.label}>
|
||||
Bounds
|
||||
<Tooltip title={BUCKET_KIND_HINTS[kind]} placement="top">
|
||||
<Info size={12} className={styles.hintIcon} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
{bounds ? (
|
||||
<>
|
||||
{bounds.map((bound) => (
|
||||
<span className={styles.bound} key={bound}>
|
||||
{formatUpperBound(bound, unit)}
|
||||
</span>
|
||||
))}
|
||||
{hasBoundsBeyondPreview(previewedOptions) && (
|
||||
<span className={styles.muted}>…</span>
|
||||
)}
|
||||
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.muted}>Set a max value to see the bounds</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BucketOptions.defaultProps = {
|
||||
bucketOptions: undefined,
|
||||
unit: undefined,
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export default BucketOptions;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MAX_LOG_SCALE } from '../constants';
|
||||
import {
|
||||
bandsPerDoublingFromScale,
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
previewUpperBounds,
|
||||
} from '../utils';
|
||||
|
||||
describe('bucket option scales', () => {
|
||||
it.each([
|
||||
[-2, 0.25],
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 4],
|
||||
[3, 8],
|
||||
[4, 16],
|
||||
])('scale %i is %i bands per doubling', (scale, bands) => {
|
||||
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kindOptionOf', () => {
|
||||
it('reads no options as auto', () => {
|
||||
expect(kindOptionOf(undefined)).toBe('auto');
|
||||
});
|
||||
|
||||
it('reads the kind off the options', () => {
|
||||
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
|
||||
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewUpperBounds', () => {
|
||||
it('doubles at one band per doubling', () => {
|
||||
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
|
||||
1, 2, 4, 8, 16, 32, 64, 128,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the finest log axis when no options are set', () => {
|
||||
const bounds = previewUpperBounds(undefined);
|
||||
|
||||
expect(bounds).toHaveLength(8);
|
||||
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
|
||||
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
|
||||
});
|
||||
|
||||
it('spaces a linear axis evenly by bucket width', () => {
|
||||
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
|
||||
);
|
||||
});
|
||||
|
||||
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
|
||||
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
|
||||
});
|
||||
|
||||
it('has no bounds to preview for a linear axis without a usable max value', () => {
|
||||
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
|
||||
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBoundsBeyondPreview', () => {
|
||||
it('is always true for a log axis, which has no top', () => {
|
||||
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
|
||||
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks whether a linear axis runs past the preview', () => {
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUpperBound', () => {
|
||||
it('reads a bound in the panel unit when one is set', () => {
|
||||
expect(formatUpperBound(1, 'ms')).toContain('ms');
|
||||
});
|
||||
|
||||
it('keeps three significant digits when unitless', () => {
|
||||
expect(formatUpperBound(128, undefined)).toBe('128');
|
||||
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
|
||||
* builder keeps its own copy so an out-of-range axis is refused before the request
|
||||
* rather than after it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
|
||||
* resolution ClickHouse buckets at, so it is both the finest available and what an
|
||||
* absent `bucketOptions` resolves to.
|
||||
*/
|
||||
export const MAX_LOG_SCALE = 4;
|
||||
|
||||
/** One band per 16x, the coarsest axis worth rendering. */
|
||||
export const MIN_LOG_SCALE = -4;
|
||||
|
||||
export const MAX_NUM_BUCKETS = 512;
|
||||
|
||||
export const DEFAULT_NUM_BUCKETS = 60;
|
||||
|
||||
/** Every scale the request accepts, coarsest first. The bounds strip below says
|
||||
* how coarse a given one is, so the numbers stand alone. */
|
||||
export const LOG_SCALES = Array.from(
|
||||
{ length: MAX_LOG_SCALE - MIN_LOG_SCALE + 1 },
|
||||
(_, index) => MIN_LOG_SCALE + index,
|
||||
);
|
||||
|
||||
/** How many leading upper bounds the bounds strip previews before eliding. */
|
||||
export const PREVIEW_BOUND_COUNT = 8;
|
||||
|
||||
/** The kind toggle's options. `auto` sends no options and lets the server choose. */
|
||||
export const BUCKET_KIND_OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
];
|
||||
|
||||
export const LOG_SCALE_OPTIONS = LOG_SCALES.map((scale) => ({
|
||||
value: String(scale),
|
||||
label: String(scale),
|
||||
}));
|
||||
|
||||
export const BUCKET_KIND_HINTS = {
|
||||
auto:
|
||||
'Bounds are picked for you: a log axis at scale 4, the finest the query can return.',
|
||||
log: 'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. A lower scale means fewer, coarser bands.',
|
||||
linear:
|
||||
'Bounds are spaced evenly from 0 up to the max value, so a band covers the same width wherever it sits. Everything above the max value lands in a single overflow band.',
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Querybuildertypesv5BucketOptionsDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind,
|
||||
Querybuildertypesv5BucketOptionsLogDTO,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import {
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
MAX_LOG_SCALE,
|
||||
PREVIEW_BOUND_COUNT,
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* The toggle's own vocabulary: the two kinds the request takes, plus `auto` for
|
||||
* sending no options at all and letting the server pick the axis.
|
||||
*/
|
||||
export type BucketKindOption = 'auto' | 'log' | 'linear';
|
||||
|
||||
export const isLinearBuckets = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
|
||||
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
|
||||
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
|
||||
|
||||
export const linearBuckets = (
|
||||
maxValue: number,
|
||||
numBuckets: number | null,
|
||||
): Querybuildertypesv5BucketOptionsLinearDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
|
||||
});
|
||||
|
||||
export const logBuckets = (
|
||||
scale: number,
|
||||
): Querybuildertypesv5BucketOptionsLogDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale },
|
||||
});
|
||||
|
||||
/**
|
||||
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
|
||||
* no scale, so it reads as the default too.
|
||||
*/
|
||||
export const logScaleOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number =>
|
||||
bucketOptions && !isLinearBuckets(bucketOptions)
|
||||
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
|
||||
: MAX_LOG_SCALE;
|
||||
|
||||
/** A log axis places `2^scale` bounds per doubling. */
|
||||
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
|
||||
|
||||
export const kindOptionOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): BucketKindOption => {
|
||||
if (!bucketOptions) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
return isLinearBuckets(bucketOptions) ? 'linear' : 'log';
|
||||
};
|
||||
|
||||
/**
|
||||
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
|
||||
* index 0's boundary — and a linear one at the top of its first band; both continue
|
||||
* past what the strip shows, and everything above the last one lands in the overflow
|
||||
* band the UI labels separately.
|
||||
*
|
||||
* `undefined` for a linear axis with no max value yet: without a top there is nothing
|
||||
* to divide.
|
||||
*/
|
||||
export function previewUpperBounds(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number[] | undefined {
|
||||
if (bucketOptions && isLinearBuckets(bucketOptions)) {
|
||||
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
|
||||
|
||||
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const width = maxValue / numBuckets;
|
||||
|
||||
return Array.from(
|
||||
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
|
||||
(_, index) => (index + 1) * width,
|
||||
);
|
||||
}
|
||||
|
||||
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
|
||||
|
||||
return Array.from(
|
||||
{ length: PREVIEW_BOUND_COUNT },
|
||||
(_, index) => 2 ** (index / bands),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
|
||||
* buckets than the strip holds ends where the strip does.
|
||||
*/
|
||||
export function hasBoundsBeyondPreview(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): boolean {
|
||||
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
|
||||
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
|
||||
* log axis distinguishable without printing the float in full.
|
||||
*/
|
||||
export function formatUpperBound(bound: number, unit?: string): string {
|
||||
if (unit) {
|
||||
return getYAxisFormattedValue(String(bound), unit);
|
||||
}
|
||||
|
||||
return Number(bound.toPrecision(3)).toString();
|
||||
}
|
||||
@@ -3,14 +3,21 @@ import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
@@ -57,6 +65,7 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
[QueryBuilderField.BucketOptions]: 'bucketOptions',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
@@ -116,6 +125,22 @@ const REDUCE_TO: AddOn = {
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const BUCKET_OPTIONS: AddOn = {
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
key: QueryBuilderField.BucketOptions,
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
@@ -196,7 +221,7 @@ function QueryAddOns({
|
||||
isForTraceOperator,
|
||||
});
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
const { handleSetQueryData, currentQuery } = useQueryBuilder();
|
||||
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
@@ -210,8 +235,25 @@ function QueryAddOns({
|
||||
addOns = [...ADD_ONS];
|
||||
}
|
||||
|
||||
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
|
||||
}, [panelType, query.dataSource, showReduceTo]);
|
||||
if (showReduceTo) {
|
||||
addOns = [...addOns, REDUCE_TO];
|
||||
}
|
||||
|
||||
if (
|
||||
panelType === PANEL_TYPES.HEATMAP &&
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
addOns = [...addOns, BUCKET_OPTIONS];
|
||||
}
|
||||
|
||||
return addOns;
|
||||
}, [
|
||||
panelType,
|
||||
query.dataSource,
|
||||
query.aggregateAttribute?.type,
|
||||
showReduceTo,
|
||||
]);
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() =>
|
||||
@@ -392,6 +434,13 @@ function QueryAddOns({
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeBucketOptions = useCallback(
|
||||
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
|
||||
handleChangeQueryData('bucketOptions', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-add-ons" data-testid="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
@@ -559,6 +608,19 @@ function QueryAddOns({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find(
|
||||
(view) => view.key === QueryBuilderField.BucketOptions,
|
||||
) && (
|
||||
<div className="add-on-content" data-testid="bucket-options-content">
|
||||
<BucketOptions
|
||||
bucketOptions={query.bucketOptions}
|
||||
unit={currentQuery.unit}
|
||||
onChange={handleChangeBucketOptions}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -26,8 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
handleSetQueryData: typeof mockHandleSetQueryData;
|
||||
currentQuery: { unit: string | undefined };
|
||||
} => ({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
currentQuery: { unit: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -330,4 +332,170 @@ describe('QueryAddOns', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
describe('bucket options', () => {
|
||||
function renderHeatmap(overrides: Partial<any> = {}): void {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('is offered on a metrics heatmap only', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('query-add-on-bucket_options'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on other panel types', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on a heatmap over another signal', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.LOGS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
|
||||
'is not offered for a %s metric, which carries its own buckets',
|
||||
(type) => {
|
||||
renderHeatmap({
|
||||
aggregateAttribute: { key: 'http_duration_bucket', type },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("auto-opens on the query's own kind", () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('sends no options for Auto', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Auto' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith(
|
||||
'bucketOptions',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the picked scale', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '0' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the coarser scales the request accepts below one band per doubling', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '-4' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: -4 },
|
||||
});
|
||||
});
|
||||
|
||||
it('previews the bounds the picked axis will carry', () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
const bounds = within(screen.getByTestId('bucket-options-bounds'));
|
||||
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
|
||||
expect(bounds.getByText(bound)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends nothing for a linear axis until it has a max value', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Set a max value to see the bounds'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the linear axis once a max value is filled in', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
{ kind: 'linear', spec: { maxValue: 500 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes back to the toggle bar', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByTestId('bucket-options-close'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('bucket-options-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum QueryBuilderField {
|
||||
Limit = 'limit',
|
||||
Legend = 'legend_format',
|
||||
ReduceTo = 'reduce_to',
|
||||
BucketOptions = 'bucket_options',
|
||||
// Builder level
|
||||
Formula = 'formula',
|
||||
AdditionalQueries = 'additional_queries',
|
||||
|
||||
@@ -76,6 +76,7 @@ export const RAW_QUERY_FIELDS: Omit<
|
||||
[QueryBuilderField.Limit]: { state: 'hidden' },
|
||||
[QueryBuilderField.Legend]: { state: 'hidden' },
|
||||
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
|
||||
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
|
||||
[QueryBuilderField.Formula]: { state: 'hidden' },
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import CheckboxFilterHeader from './CheckboxFilterHeader';
|
||||
import CheckboxValueRow from './CheckboxValueRow';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import useActiveQueryIndex from './useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from './useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from './useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from './useCheckboxFilterState';
|
||||
|
||||
@@ -15,21 +15,13 @@ function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
// AI observability builds a single query in the row-level views, so its
|
||||
// filters always drive the first one there.
|
||||
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
|
||||
return isListView || panelType === PANEL_TYPES.TRACE
|
||||
? 0
|
||||
: lastUsedQuery || 0;
|
||||
}
|
||||
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, panelType, source, lastUsedQuery]);
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
@@ -56,57 +56,6 @@ export function mockFieldsValuesAPI(response: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records every request the AI observability values endpoint receives, so a test
|
||||
* can assert both the routing and the query params it was called with.
|
||||
*/
|
||||
export function mockAIObservabilityFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
}): { requests: URLSearchParams[] } {
|
||||
const requests: URLSearchParams[] = [];
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://localhost/api/v1/ai_observability/fields/values',
|
||||
(req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return { requests };
|
||||
}
|
||||
|
||||
/** Fails the test if the signal-wide values endpoint is hit at all. */
|
||||
export function forbidFieldsValuesAPI(): { called: boolean } {
|
||||
const state = { called: false };
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) => {
|
||||
state.called = true;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function mockFieldsValuesAPILoading(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
|
||||
@@ -16,7 +16,7 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
forbidFieldsValuesAPI,
|
||||
mockAIObservabilityFieldsValuesAPI,
|
||||
mockFieldsValuesAPI,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
it('reads values from the AI observability endpoint and never the signal-wide one', async () => {
|
||||
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
|
||||
stringValues: ['openai', 'anthropic'],
|
||||
});
|
||||
const fieldsEndpoint = forbidFieldsValuesAPI();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('openai')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument();
|
||||
expect(fieldsEndpoint.called).toBe(false);
|
||||
expect(aiEndpoint.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('forwards the filter key and the time range to the AI observability endpoint', async () => {
|
||||
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
|
||||
stringValues: ['openai'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('openai');
|
||||
|
||||
const params = aiEndpoint.requests[0];
|
||||
expect(params.get('name')).toBe(DEFAULT_FILTER.attributeKey.key);
|
||||
expect(params.get('startUnixMilli')).toBe(
|
||||
String(DEFAULT_USE_FIELD_APIS.startUnixMilli),
|
||||
);
|
||||
expect(params.get('endUnixMilli')).toBe(
|
||||
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps non-AI sources on the signal-wide endpoint', async () => {
|
||||
mockFieldsValuesAPI({ stringValues: ['production'] });
|
||||
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
|
||||
stringValues: ['should-not-be-used'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('production')).resolves.toBeInTheDocument();
|
||||
await waitFor(() => expect(aiEndpoint.requests).toHaveLength(0));
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldValuesConfig } from 'api/querySuggestions/types';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
@@ -43,43 +42,32 @@ export function useFieldValues({
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
: undefined;
|
||||
|
||||
// The AI values endpoint is already gen_ai-scoped: no signal, no source.
|
||||
const fieldValuesConfig: FieldValuesConfig = isAIObservability
|
||||
? {
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}
|
||||
: {
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
};
|
||||
|
||||
const {
|
||||
data: values,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useFieldValuesSuggestion(fieldValuesConfig, builderQueryType, { enabled });
|
||||
const { data, isLoading, isFetching } = useGetFieldsValues(
|
||||
{
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const relatedValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
@@ -90,9 +78,10 @@ export function useFieldValues({
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || []
|
||||
);
|
||||
}, [values]);
|
||||
}, [data]);
|
||||
|
||||
const allValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
@@ -112,7 +101,7 @@ export function useFieldValues({
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues, ...boolValues];
|
||||
}, [values]);
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
}
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Collapse } from 'antd';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
|
||||
import { DurationSection } from 'pages/TracesExplorer/Filter/DurationSection';
|
||||
import {
|
||||
AllTraceFilterKeys,
|
||||
AllTraceFilterKeyValue,
|
||||
HandleRunProps,
|
||||
traceFilterKeys,
|
||||
} from 'constants/traceFilterKeys';
|
||||
unionTagFilterItems,
|
||||
} from 'pages/TracesExplorer/Filter/filterUtils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
|
||||
import { DurationSection } from './DurationSection';
|
||||
import { FilterType, HandleRunProps, unionTagFilterItems } from './utils';
|
||||
|
||||
import './Duration.styles.scss';
|
||||
|
||||
export type { FilterType };
|
||||
export type FilterType = Record<
|
||||
AllTraceFilterKeys,
|
||||
{ values: string[] | string; keys: BaseAutocompleteData }
|
||||
>;
|
||||
|
||||
function Duration({
|
||||
filter,
|
||||
@@ -35,7 +39,7 @@ function Duration({
|
||||
}: {
|
||||
filter: IQuickFiltersConfig;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
source: QuickFiltersSource;
|
||||
source?: QuickFiltersSource;
|
||||
}): JSX.Element {
|
||||
const [selectedFilters, setSelectedFilters] =
|
||||
useState<
|
||||
@@ -48,11 +52,26 @@ function Duration({
|
||||
filter.defaultOpen ? 'durationNano' : '',
|
||||
]);
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
lastUsedQuery,
|
||||
panelType,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
|
||||
// Otherwise use lastUsedQuery for non-ListView modes
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const syncSelectedFilters = useMemo((): FilterType => {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { AllTraceFilterKeys } from 'constants/traceFilterKeys';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type FilterType = Record<
|
||||
AllTraceFilterKeys,
|
||||
{ values: string[] | string; keys: BaseAutocompleteData }
|
||||
>;
|
||||
|
||||
export interface HandleRunProps {
|
||||
resetAll?: boolean;
|
||||
clearByType?: AllTraceFilterKeys;
|
||||
}
|
||||
|
||||
function convertToStringArr(value: string | string[] | undefined): string[] {
|
||||
if (value) {
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const addFilter = (
|
||||
filterType: AllTraceFilterKeys,
|
||||
value: string,
|
||||
setSelectedFilters: Dispatch<
|
||||
SetStateAction<
|
||||
| Record<
|
||||
AllTraceFilterKeys,
|
||||
{ values: string[] | string; keys: BaseAutocompleteData }
|
||||
>
|
||||
| undefined
|
||||
>
|
||||
>,
|
||||
keys: BaseAutocompleteData,
|
||||
): void => {
|
||||
setSelectedFilters((prevFilters) => {
|
||||
const isDuration = [
|
||||
'durationNanoMax',
|
||||
'durationNanoMin',
|
||||
'durationNano',
|
||||
].includes(filterType);
|
||||
|
||||
// Convert value to string array
|
||||
const valueArray = convertToStringArr(value);
|
||||
|
||||
// If previous filters are undefined, initialize them
|
||||
if (!prevFilters) {
|
||||
return {
|
||||
[filterType]: { values: isDuration ? value : valueArray, keys },
|
||||
} as unknown as FilterType;
|
||||
}
|
||||
|
||||
// If the filter type doesn't exist, initialize it
|
||||
if (!prevFilters[filterType]?.values.length) {
|
||||
return {
|
||||
...prevFilters,
|
||||
[filterType]: { values: isDuration ? value : valueArray, keys },
|
||||
};
|
||||
}
|
||||
|
||||
// If the value already exists, don't add it again
|
||||
if (convertToStringArr(prevFilters[filterType].values).includes(value)) {
|
||||
return prevFilters;
|
||||
}
|
||||
|
||||
// Otherwise, add the value to the existing array
|
||||
return {
|
||||
...prevFilters,
|
||||
[filterType]: {
|
||||
values: isDuration
|
||||
? value
|
||||
: [...convertToStringArr(prevFilters[filterType].values), value],
|
||||
keys,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** Merges two filter lists; later items win on the same key + operator. */
|
||||
export function unionTagFilterItems(
|
||||
items1: TagFilterItem[],
|
||||
items2: TagFilterItem[],
|
||||
): TagFilterItem[] {
|
||||
const unionMap = new Map<string, TagFilterItem>();
|
||||
|
||||
items1?.forEach((item) => {
|
||||
const keyOp = `${item?.key?.key}_${item?.op}`;
|
||||
unionMap.set(keyOp, item);
|
||||
});
|
||||
|
||||
items2?.forEach((item) => {
|
||||
const keyOp = `${item?.key?.key}_${item?.op}`;
|
||||
unionMap.set(keyOp, item);
|
||||
});
|
||||
|
||||
return Array.from(unionMap?.values());
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import { isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
|
||||
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
|
||||
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
|
||||
import Duration from './FilterRenderers/Duration/Duration';
|
||||
import Slider from './FilterRenderers/Slider/Slider';
|
||||
@@ -114,13 +113,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
const shouldShowDropdownInListView =
|
||||
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
|
||||
|
||||
// AI observability builds a single query in the row-level views, so there is
|
||||
// no query for the selector to switch between.
|
||||
const isAIObservabilityRowView =
|
||||
source === QuickFiltersSource.AI_OBSERVABILITY &&
|
||||
(isListView || panelType === PANEL_TYPES.TRACE);
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// clear all the filters for the query which is in sync with filters
|
||||
const handleReset = (): void => {
|
||||
@@ -167,10 +167,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
|
||||
|
||||
// In ListView, always show the 0th query's name; otherwise use the active query's name
|
||||
const displayedQueryName =
|
||||
isListView || isAIObservabilityRowView
|
||||
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
|
||||
: lastQueryName;
|
||||
const displayedQueryName = isListView
|
||||
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
|
||||
: lastQueryName;
|
||||
|
||||
const handleQueryChange = (value: number): void => {
|
||||
setLastUsedQuery(value);
|
||||
@@ -183,9 +182,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
<Typography.Text className="text">
|
||||
{displayedQueryName ? 'Filters for' : 'Filters'}
|
||||
</Typography.Text>
|
||||
{queryOptions.length > 1 &&
|
||||
!isAIObservabilityRowView &&
|
||||
(!isListView || shouldShowDropdownInListView) ? (
|
||||
{queryOptions.length > 1 && (!isListView || shouldShowDropdownInListView) ? (
|
||||
<Combobox open={open} onOpenChange={setOpen}>
|
||||
<ComboboxTrigger
|
||||
placeholder="Select a query"
|
||||
@@ -321,7 +318,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldKeysConfig } from 'api/querySuggestions/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import {
|
||||
BuilderQueryType,
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
@@ -43,31 +41,23 @@ function OtherFilters({
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
: undefined;
|
||||
|
||||
const fieldKeysConfig: FieldKeysConfig = isAIObservability
|
||||
? { searchText: inputValue }
|
||||
: {
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
};
|
||||
|
||||
const { data: fetchedKeys, isFetching } = useFieldKeysSuggestion(
|
||||
fieldKeysConfig,
|
||||
builderQueryType,
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
},
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
|
||||
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
|
||||
// add, render) can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = (fetchedKeys ?? []).map((attr) => ({
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
name: attr.name,
|
||||
signal: attr.signal as TelemetryFieldKey['signal'],
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
@@ -81,7 +71,7 @@ function OtherFilters({
|
||||
),
|
||||
);
|
||||
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
|
||||
}, [fetchedKeys, addedFilters]);
|
||||
}, [data, addedFilters]);
|
||||
|
||||
const handleAddFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => [...prev, filter]);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { SignalType } from '../../types';
|
||||
import OtherFilters from '../OtherFilters';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const FIELDS_KEYS_URL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const AI_KEYS_URL = `${BASE_URL}/api/v1/ai_observability/fields/keys`;
|
||||
|
||||
function keysResponse(name: string): Record<string, unknown> {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
[name]: [{ name, fieldContext: 'attribute', fieldDataType: 'string' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('OtherFilters - AI observability keys', () => {
|
||||
let fieldsKeysCalled: boolean;
|
||||
let aiKeysParams: URLSearchParams | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
fieldsKeysCalled = false;
|
||||
aiKeysParams = undefined;
|
||||
|
||||
server.use(
|
||||
rest.get(FIELDS_KEYS_URL, (_, res, ctx) => {
|
||||
fieldsKeysCalled = true;
|
||||
return res(ctx.status(200), ctx.json(keysResponse('http.route')));
|
||||
}),
|
||||
rest.get(AI_KEYS_URL, (req, res, ctx) => {
|
||||
aiKeysParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(keysResponse('gen_ai.request.model')));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function renderOtherFilters(signal: SignalType): void {
|
||||
render(
|
||||
<OtherFilters
|
||||
signal={signal}
|
||||
inputValue=""
|
||||
addedFilters={[]}
|
||||
setAddedFilters={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('reads AI observability keys from their own endpoint', async () => {
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
|
||||
await expect(
|
||||
screen.findByText('gen_ai.request.model'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(fieldsKeysCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not narrow the AI keys by fieldContext', async () => {
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
|
||||
// A `trace` context would return only the computed per-trace aggregates,
|
||||
// which cannot be filtered on.
|
||||
await waitFor(() => expect(aiKeysParams).toBeDefined());
|
||||
expect(aiKeysParams?.get('fieldContext')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps other signals on the signal-wide keys endpoint', async () => {
|
||||
renderOtherFilters(SignalType.TRACES);
|
||||
|
||||
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
|
||||
await waitFor(() => expect(aiKeysParams).toBeUndefined());
|
||||
});
|
||||
});
|
||||
@@ -7,5 +7,4 @@ export const SIGNAL_DATA_SOURCE_MAP = {
|
||||
[SignalType.EXCEPTIONS]: DataSource.TRACES,
|
||||
[SignalType.API_MONITORING]: DataSource.TRACES,
|
||||
[SignalType.METER_EXPLORER]: DataSource.METRICS,
|
||||
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
const LAST_USED_QUERY = 2;
|
||||
|
||||
function mockQueryBuilder(panelType: PANEL_TYPES): void {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
lastUsedQuery: LAST_USED_QUERY,
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useActiveQueryIndex', () => {
|
||||
describe('AI observability builds a single query in the row-level views', () => {
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'drives the first query in %s',
|
||||
(panelType) => {
|
||||
mockQueryBuilder(panelType);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'follows the last used query in %s',
|
||||
(panelType) => {
|
||||
mockQueryBuilder(panelType);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(LAST_USED_QUERY);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('other sources are unchanged', () => {
|
||||
it('lets the traces explorer track the last used query in list view', () => {
|
||||
mockQueryBuilder(PANEL_TYPES.LIST);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.TRACES_EXPLORER),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(LAST_USED_QUERY);
|
||||
});
|
||||
|
||||
it('pins single-query sources to the first query in list view', () => {
|
||||
mockQueryBuilder(PANEL_TYPES.LIST);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.INFRA_MONITORING),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks the last used query outside list view', () => {
|
||||
mockQueryBuilder(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActiveQueryIndex(QuickFiltersSource.LOGS_EXPLORER),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(LAST_USED_QUERY);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { attributeValuesResponse } from '@/storybook/msw/__story_mockdata__/attributes';
|
||||
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
import { FiltersType } from '../types';
|
||||
|
||||
const customFilters = [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
},
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
},
|
||||
];
|
||||
|
||||
export const queryBuilder = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: { expression: '' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
queryName: 'Logs query',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
lastUsedQuery: 0,
|
||||
panelType: 'graph',
|
||||
redirectWithQueryBuilderData: (): void => undefined,
|
||||
setLastUsedQuery: (): void => undefined,
|
||||
};
|
||||
|
||||
export const checkboxConfig = [
|
||||
{
|
||||
attributeKey: {
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: true,
|
||||
title: 'Service name',
|
||||
type: FiltersType.CHECKBOX,
|
||||
},
|
||||
];
|
||||
|
||||
export const attributeValuesHandler = (
|
||||
values: readonly string[],
|
||||
): RequestHandler =>
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(attributeValuesResponse(values))),
|
||||
);
|
||||
|
||||
export const handlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
quickFiltersResponse(QuickfiltertypesSourceDTO.logs, customFilters),
|
||||
),
|
||||
),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldKeysResponse(['k8s.namespace.name']))),
|
||||
),
|
||||
attributeValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
];
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
),
|
||||
];
|
||||
|
||||
export const LONG_FILTER_VALUES = [
|
||||
'checkout-service.production-eu-central-1.svc.cluster.local',
|
||||
'payments-authorisation-worker.production-us-east-2.svc.cluster.local',
|
||||
'catalog-availability-projector.staging-ap-south-1.svc.cluster.local',
|
||||
];
|
||||
|
||||
export const selectedServiceQueryBuilder = {
|
||||
...queryBuilder,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: { expression: '' },
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['checkout', 'payments'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryName: 'Logs query',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ComponentProps, ComponentType } from 'react';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import {
|
||||
attributeValuesHandler,
|
||||
checkboxConfig,
|
||||
handlers,
|
||||
LONG_FILTER_VALUES,
|
||||
loadingFiltersHandlers,
|
||||
queryBuilder,
|
||||
selectedServiceQueryBuilder,
|
||||
} from './QuickFilters.stories.mocks';
|
||||
import { QuickFiltersSource, SignalType } from '../types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Quick Filters',
|
||||
// `QuickFilters.defaultProps` declares `onFilterChange: null` against a prop
|
||||
// typed as an optional function, so the component does not satisfy
|
||||
// `ComponentType` as written. The defaults are load-bearing for the jest
|
||||
// suite, hence the cast rather than a change to them.
|
||||
component: QuickFilters as unknown as ComponentType<
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>,
|
||||
tags: ['play'],
|
||||
// The rail the explorers give it (`Explorer.styles.scss`, `.filter`).
|
||||
decorators: [withCanvas({ width: 260 })],
|
||||
args: {
|
||||
config: checkboxConfig,
|
||||
handleFilterVisibilityChange: (): void => undefined,
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
},
|
||||
parameters: {
|
||||
msw: { handlers },
|
||||
signoz: { queryBuilder },
|
||||
},
|
||||
} satisfies Meta<typeof QuickFilters>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type TooltipsStory = StoryObj<
|
||||
ComponentProps<typeof QuickFilters> & GlobalMockArgs
|
||||
>;
|
||||
|
||||
/**
|
||||
* The settings control renders disabled while its permission check is in
|
||||
* flight and is swapped for the enabled one once the check answers, so it is
|
||||
* looked up again on every attempt; a click on the disabled one is dropped in
|
||||
* silence.
|
||||
*/
|
||||
const settingsControl = (canvasElement: HTMLElement): Promise<HTMLElement> =>
|
||||
waitFor(() => {
|
||||
const control = within(canvasElement).getByTestId('settings-icon-container');
|
||||
|
||||
expect(control).toBeEnabled();
|
||||
|
||||
return control;
|
||||
});
|
||||
|
||||
/** Interaction: the settings panel is opened through the admin settings control. */
|
||||
export const SettingsOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(await settingsControl(canvasElement));
|
||||
await screen.findByText('Edit quick filters');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: changing the settings list reveals the fixed save and discard footer. */
|
||||
export const SettingsDirtyFooter: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(await settingsControl(canvasElement));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Add' }));
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Loading: dynamic filters are intentionally left pending to display the panel skeleton. */
|
||||
export const LoadingFilters: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: loadingFiltersHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: a loaded quick-filter configuration with no filters has no result rows. */
|
||||
export const NoResults: Story = {
|
||||
args: { config: [], signal: undefined },
|
||||
};
|
||||
|
||||
/** Selection: an expanded checkbox shows the actual selected service values. */
|
||||
export const SelectedExpandedCheckbox: Story = {
|
||||
args: { signal: undefined },
|
||||
parameters: {
|
||||
signoz: {
|
||||
queryBuilder: selectedServiceQueryBuilder,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the panel renders, held open: the reveal on each truncated
|
||||
* filter value. Nothing bounds those values, so the panel is answered with
|
||||
* service names long enough to be cut. The Service name filter carries them, so
|
||||
* the signal that would add the workspace's own dynamic filters is left off.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { signal: undefined, tooltipsOpen: true },
|
||||
parameters: {
|
||||
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
},
|
||||
};
|
||||
@@ -24,7 +24,6 @@ export enum SignalType {
|
||||
API_MONITORING = 'api_monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai_observability',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +69,6 @@ export enum QuickFiltersSource {
|
||||
API_MONITORING = 'api-monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai-observability',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import { CustomSelect } from '../../NewSelect';
|
||||
import SignozModal from '../SignozModal';
|
||||
|
||||
function ModalFixture(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button data-testid="open-signoz-modal" onClick={(): void => setOpen(true)}>
|
||||
Open modal
|
||||
</Button>
|
||||
<SignozModal
|
||||
footer={null}
|
||||
open={open}
|
||||
onCancel={(): void => setOpen(false)}
|
||||
title="Create saved view"
|
||||
>
|
||||
<CustomSelect
|
||||
aria-label="View scope"
|
||||
options={[
|
||||
{ label: 'This workspace', value: 'workspace' },
|
||||
{ label: 'My views', value: 'personal' },
|
||||
]}
|
||||
placeholder="Select a scope"
|
||||
/>
|
||||
</SignozModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Signoz Modal',
|
||||
component: ModalFixture,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 400 })],
|
||||
} satisfies Meta<typeof ModalFixture>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Interaction: the modal and its nested body-portal select are both genuinely open. */
|
||||
export const OpenWithNestedSelect: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = within(canvasElement).getByTestId('open-signoz-modal');
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('dialog', { name: 'Create saved view' });
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
await userEvent.click(trigger);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'View scope' }),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
@@ -71,7 +71,7 @@ interface ITableConfig {
|
||||
instance: Virtualizer<HTMLDivElement, Element>,
|
||||
) => void;
|
||||
}
|
||||
export interface ITableV3Props<T> {
|
||||
interface ITableV3Props<T> {
|
||||
columns: ColumnDef<T, any>[];
|
||||
data: T[];
|
||||
config: ITableConfig;
|
||||
@@ -201,5 +201,5 @@ export function TableV3<T>(props: ITableV3Props<T>): JSX.Element {
|
||||
|
||||
TableV3.defaultProps = {
|
||||
customClassName: '',
|
||||
virtualiserRef: undefined,
|
||||
virtualiserRef: null,
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import type { ITableV3Props } from '../TableV3';
|
||||
import { TableV3 } from '../TableV3';
|
||||
|
||||
type TraceRow = {
|
||||
id: string;
|
||||
traceId: string;
|
||||
service: string;
|
||||
duration: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TraceRow>[] = [
|
||||
{ accessorKey: 'traceId', header: 'Trace ID', size: 280 },
|
||||
{ accessorKey: 'service', header: 'Service', size: 220 },
|
||||
{ accessorKey: 'duration', header: 'Duration', size: 140 },
|
||||
{ accessorKey: 'status', header: 'Status', size: 160 },
|
||||
];
|
||||
|
||||
const rows: TraceRow[] = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `trace-${index + 1}`,
|
||||
traceId: `c0ffee${String(index + 1).padStart(10, '0')}7f4a9d1c`,
|
||||
service: index % 2 === 0 ? 'checkout-service' : 'catalog-service',
|
||||
duration: `${80 + index * 6} ms`,
|
||||
status: index % 5 === 0 ? 'Error' : 'OK',
|
||||
}));
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Table V3',
|
||||
component: TableV3,
|
||||
decorators: [withCanvas({ height: 360, maxWidth: 640, overflow: 'auto' })],
|
||||
args: {
|
||||
columns,
|
||||
config: { defaultColumnMinSize: 120, defaultColumnMaxSize: 400 },
|
||||
data: rows,
|
||||
setColumnWidths: (): void => undefined,
|
||||
},
|
||||
} satisfies Meta<ITableV3Props<TraceRow>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Density and overflow: the virtualized table's wide, resizable column layout. */
|
||||
export const WideVirtualizedDataset: Story = {};
|
||||
|
||||
/** Data: the table's native no-row layout, with headers retained for structural review. */
|
||||
export const EmptyDataset: Story = {
|
||||
args: { data: [] },
|
||||
};
|
||||
@@ -1,239 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { GroupedStatusCounts } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
|
||||
import type { StatusCountItem } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
|
||||
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8sV2/components/ValidateColumnValueWrapper';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import TanStackTable from '../index';
|
||||
import type { TableColumnDef, TanStackTableProps } from '../types';
|
||||
|
||||
type ServiceRow = {
|
||||
id: string;
|
||||
service: string;
|
||||
endpoint: string;
|
||||
latency: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
const rows: ServiceRow[] = [
|
||||
{
|
||||
id: 'checkout',
|
||||
service: 'checkout-service',
|
||||
endpoint: 'POST /api/v1/checkout',
|
||||
latency: '184 ms',
|
||||
owner: 'Payments platform',
|
||||
},
|
||||
{
|
||||
id: 'catalog',
|
||||
service: 'catalog-service',
|
||||
endpoint: 'GET /api/v2/products/{productId}/availability',
|
||||
latency: '96 ms',
|
||||
owner: 'Storefront experience',
|
||||
},
|
||||
{
|
||||
id: 'identity',
|
||||
service: 'identity-service',
|
||||
endpoint: 'POST /api/v1/session/refresh',
|
||||
latency: '242 ms',
|
||||
owner: 'Identity and access management',
|
||||
},
|
||||
];
|
||||
|
||||
const columns: TableColumnDef<ServiceRow>[] = [
|
||||
{
|
||||
id: 'service',
|
||||
header: 'Service',
|
||||
accessorKey: 'service',
|
||||
pin: 'left',
|
||||
width: { fixed: 180 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'endpoint',
|
||||
header: 'Endpoint',
|
||||
accessorKey: 'endpoint',
|
||||
width: { fixed: 320 },
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={String(value)}>
|
||||
{String(value)}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
header: 'P95 latency',
|
||||
accessorKey: 'latency',
|
||||
width: { fixed: 140 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'owner',
|
||||
header: 'Owner',
|
||||
accessorKey: 'owner',
|
||||
width: { fixed: 240 },
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rowActions = (): JSX.Element => (
|
||||
<DropdownMenuSimple
|
||||
align="end"
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'open', label: 'Open service details' },
|
||||
{ key: 'copy', label: 'Copy service link' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
aria-label="Service actions"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
variant="outlined"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: 'Components/TanStack Table View',
|
||||
component: TanStackTable,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ height: 360, maxWidth: 640 })],
|
||||
args: {
|
||||
columns,
|
||||
data: rows,
|
||||
disableVirtualScroll: true,
|
||||
getRowKey: (row): string => row.id,
|
||||
},
|
||||
} satisfies Meta<TanStackTableProps<ServiceRow>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type TooltipsStory = StoryObj<GlobalMockArgs>;
|
||||
|
||||
/** Data and overflow: pinned columns, clipped long cells, and a horizontal scroll surface. */
|
||||
export const HorizontalOverflow: Story = {
|
||||
args: { testId: 'tanstack-table' },
|
||||
};
|
||||
|
||||
/** Data: a page-sized result with the shared pagination controls and total count. */
|
||||
export const Pagination: Story = {
|
||||
args: {
|
||||
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
|
||||
},
|
||||
};
|
||||
|
||||
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
|
||||
export const Empty: Story = {
|
||||
args: { data: [], testId: 'tanstack-empty-table' },
|
||||
};
|
||||
|
||||
/** Data: the table's real skeleton rows shown while the first page is loading. */
|
||||
export const Loading: Story = {
|
||||
args: { data: [], isLoading: true, skeletonRowCount: 5 },
|
||||
};
|
||||
|
||||
/** Interaction: opens a row action menu rendered through the shared portal. */
|
||||
export const RowActionsMenu: Story = {
|
||||
args: { renderRowActions: rowActions, testId: 'tanstack-actions-table' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const firstRow = within(canvasElement).getByTestId('tanstack-actions-table');
|
||||
|
||||
await userEvent.hover(firstRow.querySelector('tbody tr') as HTMLElement);
|
||||
await userEvent.click(
|
||||
await within(firstRow).findByLabelText('Service actions'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByRole('menuitem', { name: 'Open service details' }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
const RESTART_COUNTS: StatusCountItem[] = [
|
||||
{
|
||||
label: 'Restarts in the last 24 hours',
|
||||
value: 37,
|
||||
color: Color.BG_CHERRY_500,
|
||||
breakdown: [
|
||||
{ label: 'CrashLoopBackOff', value: 14 },
|
||||
{ label: 'OOMKilled', value: 11 },
|
||||
{ label: 'Liveness probe failed', value: 6 },
|
||||
{ label: 'Readiness probe failed', value: 4 },
|
||||
{ label: 'Image pull backoff', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tooltipColumns: TableColumnDef<ServiceRow>[] = [
|
||||
columns[0],
|
||||
{
|
||||
id: 'cpuRequest',
|
||||
header: 'CPU request',
|
||||
width: { fixed: 140 },
|
||||
cell: ({ rowId }): JSX.Element => (
|
||||
<ValidateColumnValueWrapper
|
||||
attribute="CPU request"
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
rowId={rowId}
|
||||
value={-1}
|
||||
>
|
||||
<TanStackTable.Text>0.5</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'restarts',
|
||||
header: 'Restarts',
|
||||
width: { fixed: 140 },
|
||||
cell: ({ rowId }): JSX.Element => (
|
||||
<GroupedStatusCounts items={RESTART_COUNTS} rowId={rowId} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Both tooltips a hovered row carries, held open: the plain sentence explaining
|
||||
* a missing value, and the status breakdown, which is elements rather than text
|
||||
* and grows a row per reason. Neither is rendered until the row is hovered, so
|
||||
* the play hovers the first one and the control holds what it uncovered.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { tooltipsOpen: true },
|
||||
render: (): JSX.Element => (
|
||||
<TanStackTable
|
||||
columns={tooltipColumns}
|
||||
data={rows}
|
||||
disableVirtualScroll
|
||||
getRowKey={(row): string => row.id}
|
||||
testId="tanstack-tooltips-table"
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const table = within(canvasElement).getByTestId('tanstack-tooltips-table');
|
||||
|
||||
await userEvent.hover(table.querySelector('tbody tr') as HTMLElement);
|
||||
// Both tooltips are rendered by the hovered row, so this is what says the
|
||||
// control has something to hold open.
|
||||
await screen.findByText('Restarts in the last 24 hours');
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@ export enum LOCALSTORAGE {
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
|
||||
@@ -29,9 +29,10 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.LIST]:
|
||||
dataSource === DataSource.LOGS ? LogsPanelComponent : TracesTableComponent,
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.AREA]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
|
||||
@@ -336,9 +336,9 @@ export enum PANEL_TYPES {
|
||||
LIST = 'list',
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -527,6 +527,21 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
|
||||
* count already draws. Sum is also what the statement builder forces on a histogram
|
||||
* heatmap whatever is asked for, so it is the only honest option to offer.
|
||||
*/
|
||||
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
},
|
||||
];
|
||||
|
||||
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
|
||||
@@ -109,9 +109,6 @@ export const REACT_QUERY_KEY = {
|
||||
// Field Keys Suggestion Query Keys
|
||||
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
|
||||
|
||||
// Field Values Suggestion Query Keys
|
||||
FIELD_VALUES_SUGGESTION: 'FIELD_VALUES_SUGGESTION',
|
||||
|
||||
// AI Assistant Query Keys
|
||||
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OperatorValues } from 'hooks/useResourceAttribute/types';
|
||||
import { OperatorValues } from 'types/reducer/trace';
|
||||
|
||||
export const OperatorConversions: Array<{
|
||||
label: string;
|
||||
|
||||
@@ -6,8 +6,9 @@ const ROUTES = {
|
||||
SERVICE_METRICS: '/services/:servicename',
|
||||
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',
|
||||
SERVICE_MAP: '/service-map',
|
||||
TRACE_BASE: '/trace',
|
||||
TRACE: '/trace',
|
||||
TRACE_DETAIL: '/trace/:id',
|
||||
TRACE_DETAIL_OLD: '/trace-old/:id',
|
||||
TRACES_EXPLORER: '/traces-explorer',
|
||||
ONBOARDING: '/onboarding',
|
||||
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
/** Trace attribute key -> label shown in quick filters, the options menu and APM "view traces" links. */
|
||||
export const AllTraceFilterKeyValue: Record<string, string> = {
|
||||
durationNanoMin: 'Duration',
|
||||
durationNano: 'Duration',
|
||||
duration_nano: 'Duration',
|
||||
durationNanoMax: 'Duration',
|
||||
'deployment.environment': 'Environment',
|
||||
hasError: 'Status',
|
||||
has_error: 'Status',
|
||||
serviceName: 'Service Name',
|
||||
'service.name': 'service.name',
|
||||
name: 'Operation / Name',
|
||||
rpcMethod: 'RPC Method',
|
||||
'rpc.method': 'RPC Method',
|
||||
responseStatusCode: 'Status Code',
|
||||
response_status_code: 'Status Code',
|
||||
httpHost: 'HTTP Host',
|
||||
http_host: 'HTTP Host',
|
||||
httpMethod: 'HTTP Method',
|
||||
http_method: 'HTTP Method',
|
||||
httpRoute: 'HTTP Route',
|
||||
'http.route': 'HTTP Route',
|
||||
httpUrl: 'HTTP URL',
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: 'HTTP URL',
|
||||
traceID: 'Trace ID',
|
||||
trace_id: 'Trace ID',
|
||||
} as const;
|
||||
|
||||
export type AllTraceFilterKeys = keyof typeof AllTraceFilterKeyValue;
|
||||
|
||||
export const traceFilterKeys: Record<AllTraceFilterKeys, BaseAutocompleteData> =
|
||||
{
|
||||
durationNano: {
|
||||
key: 'durationNano',
|
||||
dataType: DataTypes.Float64,
|
||||
type: 'tag',
|
||||
id: 'durationNano--float64--tag--true',
|
||||
},
|
||||
hasError: {
|
||||
key: 'hasError',
|
||||
dataType: DataTypes.bool,
|
||||
type: 'tag',
|
||||
id: 'hasError--bool--tag--true',
|
||||
},
|
||||
serviceName: {
|
||||
key: 'serviceName',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'serviceName--string--tag--true',
|
||||
},
|
||||
|
||||
'deployment.environment': {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: 'deployment.environment--string--resource--false',
|
||||
},
|
||||
name: {
|
||||
key: 'name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
},
|
||||
rpcMethod: {
|
||||
key: 'rpcMethod',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'rpcMethod--string--tag--true',
|
||||
},
|
||||
responseStatusCode: {
|
||||
key: 'responseStatusCode',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'responseStatusCode--string--tag--true',
|
||||
},
|
||||
httpHost: {
|
||||
key: 'httpHost',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'httpHost--string--tag--true',
|
||||
},
|
||||
httpMethod: {
|
||||
key: 'httpMethod',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'httpMethod--string--tag--true',
|
||||
},
|
||||
httpRoute: {
|
||||
key: 'httpRoute',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'httpRoute--string--tag--true',
|
||||
},
|
||||
httpUrl: {
|
||||
key: 'httpUrl',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'httpUrl--string--tag--true',
|
||||
},
|
||||
traceID: {
|
||||
key: 'traceID',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'traceID--string--tag--true',
|
||||
},
|
||||
durationNanoMin: {
|
||||
key: 'durationNanoMin',
|
||||
dataType: DataTypes.Float64,
|
||||
type: 'tag',
|
||||
id: 'durationNanoMin--float64--tag--true',
|
||||
},
|
||||
durationNanoMax: {
|
||||
key: 'durationNanoMax',
|
||||
dataType: DataTypes.Float64,
|
||||
type: 'tag',
|
||||
id: 'durationNanoMax--float64--tag--true',
|
||||
},
|
||||
} as const;
|
||||
@@ -450,12 +450,6 @@ export default function ChatInput({
|
||||
return;
|
||||
}
|
||||
el.style.height = 'auto';
|
||||
// A hidden composer (a closed drawer, a story swapping in) measures 0.
|
||||
// Leaving the height on `auto` keeps the `rows` fallback until there is
|
||||
// something real to measure, instead of pinning the field shut.
|
||||
if (el.scrollHeight === 0) {
|
||||
return;
|
||||
}
|
||||
el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_HEIGHT_PX)}px`;
|
||||
}, [text]);
|
||||
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('Footer utils', () => {
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
bucketOptions: undefined,
|
||||
disabled: false,
|
||||
filter: {
|
||||
expression: '',
|
||||
|
||||
@@ -207,12 +207,7 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
color="none"
|
||||
data-testid="custom-domain-menu-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Button variant="link" color="none" disabled={isFetchingHosts}>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
|
||||
@@ -71,7 +71,6 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
<DropdownMenuSimple menu={menu}>
|
||||
<Button
|
||||
className="download-button"
|
||||
data-testid="download-menu-trigger"
|
||||
loading={isLoading || isDownloading}
|
||||
size="small"
|
||||
type="link"
|
||||
|
||||
16
frontend/src/container/GantChart/GantChart.styles.scss
Normal file
16
frontend/src/container/GantChart/GantChart.styles.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.span-container {
|
||||
.spanDetails {
|
||||
position: absolute;
|
||||
height: 50px;
|
||||
padding: 8px;
|
||||
min-width: 150px;
|
||||
background: lightcyan;
|
||||
color: black;
|
||||
bottom: 24px;
|
||||
left: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
96
frontend/src/container/GantChart/Span/index.tsx
Normal file
96
frontend/src/container/GantChart/Span/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { SpanBorder, SpanLine, SpanText, SpanWrapper } from './styles';
|
||||
|
||||
import '../GantChart.styles.scss';
|
||||
|
||||
interface SpanLengthProps {
|
||||
globalStart: number;
|
||||
startTime: number;
|
||||
name: string;
|
||||
width: string;
|
||||
leftOffset: string;
|
||||
bgColor: string;
|
||||
inMsCount: number;
|
||||
}
|
||||
|
||||
function Span(props: SpanLengthProps): JSX.Element {
|
||||
const { width, leftOffset, bgColor, inMsCount, startTime, name, globalStart } =
|
||||
props;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(inMsCount);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.scrollTop = document.documentElement.clientHeight;
|
||||
document.documentElement.scrollLeft = document.documentElement.clientWidth;
|
||||
}, []);
|
||||
|
||||
const getContent = (): JSX.Element => {
|
||||
const timeStamp = dayjs(startTime)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.TIME_UTC_MS);
|
||||
const startTimeInMs = startTime - globalStart;
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text style={{ marginBottom: '8px' }}>
|
||||
{' '}
|
||||
Duration : {inMsCount}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text style={{ marginBottom: '8px' }}>
|
||||
Start Time: {startTimeInMs}ms [{timeStamp}]{' '}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SpanWrapper className="span-container">
|
||||
<SpanLine
|
||||
className="spanLine"
|
||||
isDarkMode={isDarkMode}
|
||||
bgColor={bgColor}
|
||||
leftOffset={leftOffset}
|
||||
width={width}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Popover
|
||||
style={{
|
||||
left: `${leftOffset}%`,
|
||||
}}
|
||||
title={name}
|
||||
content={getContent()}
|
||||
trigger="hover"
|
||||
placement="left"
|
||||
autoAdjustOverflow
|
||||
>
|
||||
<SpanBorder
|
||||
className="spanTrack"
|
||||
isDarkMode={isDarkMode}
|
||||
bgColor={bgColor}
|
||||
leftOffset={leftOffset}
|
||||
width={width}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<SpanText isDarkMode={isDarkMode} leftOffset={leftOffset}>{`${toFixed(
|
||||
time,
|
||||
2,
|
||||
)} ${timeUnitName}`}</SpanText>
|
||||
</SpanWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default Span;
|
||||
52
frontend/src/container/GantChart/Span/styles.ts
Normal file
52
frontend/src/container/GantChart/Span/styles.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface Props {
|
||||
width: string;
|
||||
leftOffset: string;
|
||||
bgColor: string;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const SpanLine = styled.div<Props>`
|
||||
width: ${({ leftOffset }): string => `${leftOffset}%`};
|
||||
height: 0px;
|
||||
border-bottom: 0.1px solid
|
||||
${({ isDarkMode }): string => (isDarkMode ? '#303030' : '#c0c0c0')};
|
||||
top: 50%;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export const SpanBorder = styled.div<Props>`
|
||||
background: ${({ bgColor }): string => bgColor};
|
||||
border-radius: 5px;
|
||||
height: 0.625rem;
|
||||
width: ${({ width }): string => `${width}%`};
|
||||
left: ${({ leftOffset }): string => `${leftOffset}%`};
|
||||
top: 35%;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export const SpanWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 2rem;
|
||||
`;
|
||||
interface SpanTextProps extends Pick<Props, 'leftOffset'> {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const SpanText = styled(Typography.Text)<SpanTextProps>`
|
||||
&&& {
|
||||
left: ${({ leftOffset }): string => `${leftOffset}%`};
|
||||
top: 65%;
|
||||
position: absolute;
|
||||
width: max-content;
|
||||
color: ${({ isDarkMode }): string => (isDarkMode ? '#ACACAC' : '#666')};
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
`;
|
||||
22
frontend/src/container/GantChart/SpanName/index.tsx
Normal file
22
frontend/src/container/GantChart/SpanName/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Container, Service, Span, SpanWrapper } from './styles';
|
||||
|
||||
function SpanNameComponent({
|
||||
name,
|
||||
serviceName,
|
||||
}: SpanNameComponentProps): JSX.Element {
|
||||
return (
|
||||
<Container title={`${name} ${serviceName}`}>
|
||||
<SpanWrapper>
|
||||
<Span truncate={1}>{name}</Span>
|
||||
<Service truncate={1}>{serviceName}</Service>
|
||||
</SpanWrapper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanNameComponentProps {
|
||||
name: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
export default SpanNameComponent;
|
||||
41
frontend/src/container/GantChart/SpanName/styles.ts
Normal file
41
frontend/src/container/GantChart/SpanName/styles.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Span = styled(Typography.Text)`
|
||||
&&& {
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
/* border-bottom: 1px solid grey; */
|
||||
}
|
||||
`;
|
||||
|
||||
export const Service = styled(Typography.Text)`
|
||||
&&& {
|
||||
color: #acacac;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SpanWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 0.625rem;
|
||||
width: 10rem;
|
||||
`;
|
||||
|
||||
export const SpanConnector = styled.div`
|
||||
width: 37px;
|
||||
border: 1px solid #303030;
|
||||
height: 0;
|
||||
`;
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
export const SpanName = styled.div`
|
||||
width: fit-content;
|
||||
border-bottom: 1px solid black;
|
||||
`;
|
||||
233
frontend/src/container/GantChart/Trace/index.tsx
Normal file
233
frontend/src/container/GantChart/Trace/index.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEventHandler,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Col } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { StyledCol, StyledRow } from 'components/Styled';
|
||||
import {
|
||||
IIntervalUnit,
|
||||
SPAN_DETAILS_LEFT_COL_WIDTH,
|
||||
} from 'container/TraceDetail/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ITraceTree } from 'types/api/trace/getTraceItem';
|
||||
|
||||
import { ITraceMetaData } from '..';
|
||||
import Span from '../Span';
|
||||
import SpanName from '../SpanName';
|
||||
import { getMetaDataFromSpanTree, getTopLeftFromBody } from '../utils';
|
||||
import {
|
||||
CardComponent,
|
||||
CardContainer,
|
||||
CaretContainer,
|
||||
HoverCard,
|
||||
styles,
|
||||
Wrapper,
|
||||
} from './styles';
|
||||
import { getIconStyles } from './utils';
|
||||
|
||||
function Trace(props: TraceProps): JSX.Element {
|
||||
const {
|
||||
name,
|
||||
activeHoverId,
|
||||
setActiveHoverId,
|
||||
globalSpread,
|
||||
globalStart,
|
||||
serviceName,
|
||||
startTime,
|
||||
value,
|
||||
serviceColour,
|
||||
id,
|
||||
setActiveSelectedId,
|
||||
activeSelectedId,
|
||||
level,
|
||||
activeSpanPath,
|
||||
isExpandAll,
|
||||
intervalUnit,
|
||||
children,
|
||||
isMissing,
|
||||
} = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [isOpen, setOpen] = useState<boolean>(activeSpanPath[level] === id);
|
||||
|
||||
const localTreeExpandInteraction = useRef<boolean | 0>(0); // Boolean is for the state of the expansion whereas the number i.e. 0 is for skipping the user interaction.
|
||||
|
||||
useEffect(() => {
|
||||
if (localTreeExpandInteraction.current !== 0) {
|
||||
setOpen(localTreeExpandInteraction.current);
|
||||
localTreeExpandInteraction.current = 0;
|
||||
} else if (!isOpen) {
|
||||
setOpen(activeSpanPath[level] === id);
|
||||
}
|
||||
}, [activeSpanPath, isOpen, id, level]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpandAll) {
|
||||
setOpen(isExpandAll);
|
||||
} else {
|
||||
setOpen(activeSpanPath[level] === id);
|
||||
}
|
||||
}, [isExpandAll, activeSpanPath, id, level]);
|
||||
|
||||
const isOnlyChild = children.length === 1;
|
||||
const [top, setTop] = useState<number>(0);
|
||||
|
||||
const ref = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSelectedId === id) {
|
||||
ref.current?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'auto',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}, [activeSelectedId, id]);
|
||||
|
||||
const onMouseEnterHandler = (): void => {
|
||||
setActiveHoverId(id);
|
||||
if (ref.current) {
|
||||
const { top } = getTopLeftFromBody(ref.current);
|
||||
setTop(top);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeaveHandler = (): void => {
|
||||
setActiveHoverId('');
|
||||
};
|
||||
|
||||
const onClick = (): void => {
|
||||
setActiveSelectedId(id);
|
||||
};
|
||||
|
||||
const onClickTreeExpansion: MouseEventHandler<HTMLDivElement> = (
|
||||
event,
|
||||
): void => {
|
||||
event.stopPropagation();
|
||||
setOpen((state) => {
|
||||
localTreeExpandInteraction.current = !isOpen;
|
||||
return !state;
|
||||
});
|
||||
};
|
||||
const { totalSpans } = getMetaDataFromSpanTree(props);
|
||||
|
||||
const inMsCount = value;
|
||||
const nodeLeftOffset = ((startTime - globalStart) * 1e2) / globalSpread;
|
||||
const width = (value * 1e2) / (globalSpread * 1e6);
|
||||
const panelWidth = SPAN_DETAILS_LEFT_COL_WIDTH - level * (16 + 1) - 48;
|
||||
|
||||
const iconStyles = useMemo(() => getIconStyles(), []);
|
||||
|
||||
const icon = useMemo(
|
||||
() =>
|
||||
isOpen ? (
|
||||
<ChevronDown size="md" style={iconStyles} />
|
||||
) : (
|
||||
<ChevronRight size="md" style={iconStyles} />
|
||||
),
|
||||
[isOpen, iconStyles],
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
onMouseEnter={onMouseEnterHandler}
|
||||
onMouseLeave={onMouseLeaveHandler}
|
||||
isOnlyChild={isOnlyChild}
|
||||
ref={ref}
|
||||
isDarkMode={isDarkMode}
|
||||
>
|
||||
<HoverCard
|
||||
top={top}
|
||||
isHovered={activeHoverId === id}
|
||||
isSelected={activeSelectedId === id}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
|
||||
<CardContainer isMissing={isMissing} onClick={onClick}>
|
||||
<StyledCol flex={`${panelWidth}px`} styledclass={[styles.overFlowHidden]}>
|
||||
<StyledRow styledclass={[styles.flexNoWrap]}>
|
||||
<Col>
|
||||
{totalSpans !== 1 && (
|
||||
<CardComponent
|
||||
isOnlyChild={isOnlyChild}
|
||||
isDarkMode={isDarkMode}
|
||||
onClick={onClickTreeExpansion}
|
||||
>
|
||||
<Typography style={{ wordBreak: 'normal' }}>{totalSpans}</Typography>
|
||||
<CaretContainer>{icon}</CaretContainer>
|
||||
</CardComponent>
|
||||
)}
|
||||
</Col>
|
||||
<Col>
|
||||
<SpanName name={name} serviceName={serviceName} />
|
||||
</Col>
|
||||
</StyledRow>
|
||||
</StyledCol>
|
||||
<Col flex="1">
|
||||
<Span
|
||||
globalStart={globalStart}
|
||||
startTime={startTime}
|
||||
name={name}
|
||||
leftOffset={nodeLeftOffset.toString()}
|
||||
width={width.toString()}
|
||||
bgColor={serviceColour}
|
||||
inMsCount={inMsCount / 1e6}
|
||||
/>
|
||||
</Col>
|
||||
</CardContainer>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{children.map((child) => (
|
||||
<Trace
|
||||
key={child.id}
|
||||
activeHoverId={activeHoverId}
|
||||
setActiveHoverId={setActiveHoverId}
|
||||
{...child}
|
||||
globalSpread={globalSpread}
|
||||
globalStart={globalStart}
|
||||
setActiveSelectedId={setActiveSelectedId}
|
||||
activeSelectedId={activeSelectedId}
|
||||
level={level + 1}
|
||||
activeSpanPath={activeSpanPath}
|
||||
isExpandAll={isExpandAll}
|
||||
intervalUnit={intervalUnit}
|
||||
isMissing={child.isMissing}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
Trace.defaultProps = {
|
||||
isMissing: false,
|
||||
};
|
||||
|
||||
interface ITraceGlobal {
|
||||
globalSpread: ITraceMetaData['spread'];
|
||||
globalStart: ITraceMetaData['globalStart'];
|
||||
}
|
||||
|
||||
interface TraceProps extends ITraceTree, ITraceGlobal {
|
||||
activeHoverId: string;
|
||||
setActiveHoverId: Dispatch<SetStateAction<string>>;
|
||||
setActiveSelectedId: Dispatch<SetStateAction<string>>;
|
||||
activeSelectedId: string;
|
||||
level: number;
|
||||
activeSpanPath: string[];
|
||||
isExpandAll: boolean;
|
||||
intervalUnit: IIntervalUnit;
|
||||
isMissing?: boolean;
|
||||
}
|
||||
|
||||
export default Trace;
|
||||
113
frontend/src/container/GantChart/Trace/styles.ts
Normal file
113
frontend/src/container/GantChart/Trace/styles.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { volcano } from '@ant-design/colors';
|
||||
import styled, {
|
||||
css,
|
||||
DefaultTheme,
|
||||
ThemedCssFunction,
|
||||
} from 'styled-components';
|
||||
|
||||
interface Props {
|
||||
isOnlyChild: boolean;
|
||||
}
|
||||
|
||||
export const Wrapper = styled.ul<Props>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
ul {
|
||||
border-left: ${({ isOnlyChild }): StyledCSS =>
|
||||
isOnlyChild && 'none'} !important;
|
||||
|
||||
${({ isOnlyChild }): StyledCSS =>
|
||||
isOnlyChild &&
|
||||
css`
|
||||
&:before {
|
||||
border-left: 1px solid #434343;
|
||||
display: inline-block;
|
||||
content: '';
|
||||
height: 54px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -35px;
|
||||
}
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardContainer = styled.li<{ isMissing?: boolean }>`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
z-index: 2;
|
||||
${({ isMissing }): string =>
|
||||
isMissing ? `border: 1px dashed ${volcano[6]} !important;` : ''}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export type StyledCSS =
|
||||
| ReturnType<ThemedCssFunction<DefaultTheme>>
|
||||
| string
|
||||
| false
|
||||
| undefined;
|
||||
|
||||
export const CardComponent = styled.div<Props>`
|
||||
border: 1px solid
|
||||
${({ isDarkMode }): StyledCSS => (isDarkMode ? '#434343' : '#333')};
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
background: ${({ isDarkMode }): StyledCSS =>
|
||||
isDarkMode ? '#1d1d1d' : '#ddd'};
|
||||
height: 22px;
|
||||
`;
|
||||
|
||||
export const CaretContainer = styled.span`
|
||||
margin-left: 0.304rem;
|
||||
`;
|
||||
|
||||
interface HoverCardProps {
|
||||
isHovered: boolean;
|
||||
isSelected: boolean;
|
||||
top: number;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const HoverCard = styled.div<HoverCardProps>`
|
||||
display: ${({ isSelected, isHovered }): string =>
|
||||
isSelected || isHovered ? 'block' : 'none'};
|
||||
width: 200%;
|
||||
background-color: ${({ isHovered, isDarkMode }): string => {
|
||||
if (isHovered) {
|
||||
return isDarkMode ? '#262626' : '#ddd';
|
||||
}
|
||||
return isDarkMode ? '#4f4f4f' : '#bbb';
|
||||
}};
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
right: 0;
|
||||
height: 3rem;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const flexNoWrap = css`
|
||||
flex-wrap: nowrap;
|
||||
`;
|
||||
|
||||
const overFlowHidden = css`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const styles = {
|
||||
flexNoWrap,
|
||||
overFlowHidden,
|
||||
};
|
||||
3
frontend/src/container/GantChart/Trace/utils.ts
Normal file
3
frontend/src/container/GantChart/Trace/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const getIconStyles = (): Record<string, string> => ({
|
||||
color: 'var(--l1-foreground)',
|
||||
});
|
||||
91
frontend/src/container/GantChart/index.tsx
Normal file
91
frontend/src/container/GantChart/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { SquareMinus, SquarePlus } from '@signozhq/icons';
|
||||
import { IIntervalUnit } from 'container/TraceDetail/utils';
|
||||
import { ITraceTree } from 'types/api/trace/getTraceItem';
|
||||
|
||||
import { CardContainer, CardWrapper, CollapseButton } from './styles';
|
||||
import Trace from './Trace';
|
||||
import { getSpanPath } from './utils';
|
||||
|
||||
function GanttChart(props: GanttChartProps): JSX.Element {
|
||||
const {
|
||||
data,
|
||||
traceMetaData,
|
||||
activeHoverId,
|
||||
setActiveHoverId,
|
||||
activeSelectedId,
|
||||
setActiveSelectedId,
|
||||
spanId,
|
||||
intervalUnit,
|
||||
} = props;
|
||||
|
||||
const { globalStart, spread: globalSpread } = traceMetaData;
|
||||
|
||||
const [isExpandAll, setIsExpandAll] = useState<boolean>(false);
|
||||
const [activeSpanPath, setActiveSpanPath] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSpanPath(getSpanPath(data, spanId));
|
||||
}, [spanId, data]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSpanPath(getSpanPath(data, activeSelectedId));
|
||||
}, [activeSelectedId, data]);
|
||||
|
||||
const handleCollapse = (): void => {
|
||||
setIsExpandAll((prev) => !prev);
|
||||
};
|
||||
return (
|
||||
<CardContainer>
|
||||
<CollapseButton
|
||||
onClick={handleCollapse}
|
||||
title={isExpandAll ? 'Collapse All' : 'Expand All'}
|
||||
>
|
||||
{isExpandAll ? (
|
||||
<SquareMinus size={16} style={{ color: 'var(--accent-primary)' }} />
|
||||
) : (
|
||||
<SquarePlus size={16} style={{ color: 'var(--accent-primary)' }} />
|
||||
)}
|
||||
</CollapseButton>
|
||||
<CardWrapper>
|
||||
<Trace
|
||||
activeHoverId={activeHoverId}
|
||||
activeSpanPath={activeSpanPath}
|
||||
setActiveHoverId={setActiveHoverId}
|
||||
key={data.id}
|
||||
{...{
|
||||
...data,
|
||||
globalSpread,
|
||||
globalStart,
|
||||
setActiveSelectedId,
|
||||
activeSelectedId,
|
||||
}}
|
||||
level={0}
|
||||
isExpandAll={isExpandAll}
|
||||
intervalUnit={intervalUnit}
|
||||
/>
|
||||
</CardWrapper>
|
||||
</CardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ITraceMetaData {
|
||||
globalEnd: number;
|
||||
globalStart: number;
|
||||
levels: number;
|
||||
spread: number;
|
||||
totalSpans: number;
|
||||
}
|
||||
|
||||
export interface GanttChartProps {
|
||||
data: ITraceTree;
|
||||
traceMetaData: ITraceMetaData;
|
||||
activeSelectedId: string;
|
||||
activeHoverId: string;
|
||||
setActiveHoverId: Dispatch<SetStateAction<string>>;
|
||||
setActiveSelectedId: Dispatch<SetStateAction<string>>;
|
||||
spanId: string;
|
||||
intervalUnit: IIntervalUnit;
|
||||
}
|
||||
|
||||
export default GanttChart;
|
||||
48
frontend/src/container/GantChart/styles.ts
Normal file
48
frontend/src/container/GantChart/styles.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Wrapper = styled.ul`
|
||||
padding-left: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
border-left: 1px solid #434343;
|
||||
padding-left: 1rem;
|
||||
width: 100%;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
left: -1rem;
|
||||
top: 10px;
|
||||
content: '';
|
||||
height: 1px;
|
||||
width: 1rem;
|
||||
background-color: #434343;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-left: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
`;
|
||||
|
||||
export const CardContainer = styled.li`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const CollapseButton = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
`;
|
||||
203
frontend/src/container/GantChart/utils.ts
Normal file
203
frontend/src/container/GantChart/utils.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { set } from 'lodash-es';
|
||||
import { ITraceForest, ITraceTree } from 'types/api/trace/getTraceItem';
|
||||
|
||||
interface GetTraceMetaData {
|
||||
globalStart: number;
|
||||
globalEnd: number;
|
||||
spread: number;
|
||||
totalSpans: number;
|
||||
levels: number;
|
||||
}
|
||||
export const getMetaDataFromSpanTree = (
|
||||
treeData: ITraceTree,
|
||||
): GetTraceMetaData => {
|
||||
let globalStart = Number.POSITIVE_INFINITY;
|
||||
let globalEnd = Number.NEGATIVE_INFINITY;
|
||||
let totalSpans = 0;
|
||||
let levels = 1;
|
||||
const traverse = (treeNode: ITraceTree, level = 0): void => {
|
||||
if (!treeNode) {
|
||||
return;
|
||||
}
|
||||
totalSpans += 1;
|
||||
levels = Math.max(levels, level);
|
||||
const { startTime } = treeNode;
|
||||
const endTime = startTime + treeNode.value;
|
||||
globalStart = Math.min(globalStart, startTime);
|
||||
globalEnd = Math.max(globalEnd, endTime);
|
||||
|
||||
treeNode.children.forEach((childNode) => {
|
||||
traverse(childNode, level + 1);
|
||||
});
|
||||
};
|
||||
traverse(treeData, 1);
|
||||
|
||||
globalStart *= 1e6;
|
||||
globalEnd *= 1e6;
|
||||
|
||||
return {
|
||||
globalStart,
|
||||
globalEnd,
|
||||
spread: globalEnd - globalStart,
|
||||
totalSpans,
|
||||
levels,
|
||||
};
|
||||
};
|
||||
|
||||
export function getTopLeftFromBody(elem: HTMLElement): {
|
||||
top: number;
|
||||
left: number;
|
||||
} {
|
||||
const box = elem.getBoundingClientRect();
|
||||
|
||||
const { body } = document;
|
||||
const docEl = document.documentElement;
|
||||
|
||||
const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
|
||||
const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;
|
||||
|
||||
const clientTop = docEl.clientTop || body.clientTop || 0;
|
||||
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
|
||||
|
||||
const top = box.top + scrollTop - clientTop;
|
||||
const left = box.left + scrollLeft - clientLeft;
|
||||
|
||||
return { top: Math.round(top), left: Math.round(left) };
|
||||
}
|
||||
|
||||
export const getNodeById = (
|
||||
searchingId: string,
|
||||
treesData: ITraceForest | undefined,
|
||||
): ITraceForest => {
|
||||
const newtreeData: ITraceForest = {} as ITraceForest;
|
||||
|
||||
const traverse = (
|
||||
treeNode: ITraceTree,
|
||||
setCallBack: (arg0: ITraceTree) => void,
|
||||
level = 0,
|
||||
): void => {
|
||||
if (!treeNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchingId === treeNode.id) {
|
||||
setCallBack(treeNode);
|
||||
}
|
||||
|
||||
treeNode.children.forEach((childNode) => {
|
||||
traverse(childNode, setCallBack, level + 1);
|
||||
});
|
||||
};
|
||||
|
||||
const spanTreeSetCallback = (
|
||||
path: (keyof ITraceForest)[],
|
||||
value: ITraceTree,
|
||||
): ITraceForest => set(newtreeData, path, [value]);
|
||||
|
||||
if (treesData?.spanTree) {
|
||||
treesData.spanTree.forEach((tree) => {
|
||||
traverse(tree, (value) => spanTreeSetCallback(['spanTree'], value), 1);
|
||||
});
|
||||
}
|
||||
|
||||
if (treesData?.missingSpanTree) {
|
||||
treesData.missingSpanTree.forEach((tree) => {
|
||||
traverse(
|
||||
tree,
|
||||
(value) => spanTreeSetCallback(['missingSpanTree'], value),
|
||||
1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return newtreeData;
|
||||
};
|
||||
|
||||
const getSpanWithoutChildren = (
|
||||
span: ITraceTree,
|
||||
): Omit<ITraceTree, 'children'> => ({
|
||||
id: span.id,
|
||||
name: span.name,
|
||||
parent: span.parent,
|
||||
serviceColour: span.serviceColour,
|
||||
serviceName: span.serviceName,
|
||||
startTime: span.startTime,
|
||||
tags: span.tags,
|
||||
time: span.time,
|
||||
value: span.value,
|
||||
event: span.event,
|
||||
hasError: span.hasError,
|
||||
spanKind: span.spanKind,
|
||||
statusCodeString: span.statusCodeString,
|
||||
statusMessage: span.statusMessage,
|
||||
});
|
||||
|
||||
export const isSpanPresentInSearchString = (
|
||||
searchedString: string,
|
||||
tree: ITraceTree,
|
||||
): boolean => {
|
||||
const parsedTree = getSpanWithoutChildren(tree);
|
||||
|
||||
const stringifyTree = JSON.stringify(parsedTree);
|
||||
|
||||
return stringifyTree.includes(searchedString);
|
||||
};
|
||||
|
||||
export const isSpanPresent = (
|
||||
tree: ITraceTree,
|
||||
searchedKey: string,
|
||||
): ITraceTree[] => {
|
||||
const foundNode: ITraceTree[] = [];
|
||||
|
||||
const traverse = (
|
||||
treeNode: ITraceTree,
|
||||
level = 0,
|
||||
foundNode: ITraceTree[],
|
||||
): void => {
|
||||
if (!treeNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPresent = isSpanPresentInSearchString(searchedKey, treeNode);
|
||||
|
||||
if (isPresent) {
|
||||
foundNode.push(treeNode);
|
||||
}
|
||||
|
||||
treeNode.children.forEach((childNode) => {
|
||||
traverse(childNode, level + 1, foundNode);
|
||||
});
|
||||
};
|
||||
traverse(tree, 1, foundNode);
|
||||
|
||||
return foundNode;
|
||||
};
|
||||
|
||||
export const getSpanPath = (tree: ITraceTree, spanId: string): string[] => {
|
||||
const spanPath: string[] = [];
|
||||
|
||||
const traverse = (treeNode: ITraceTree): boolean => {
|
||||
if (!treeNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
spanPath.push(treeNode.id);
|
||||
|
||||
if (spanId === treeNode.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let foundInChild = false;
|
||||
treeNode.children.forEach((childNode) => {
|
||||
if (traverse(childNode)) {
|
||||
foundInChild = true;
|
||||
}
|
||||
});
|
||||
if (!foundInChild) {
|
||||
spanPath.pop();
|
||||
}
|
||||
return foundInChild;
|
||||
};
|
||||
traverse(tree);
|
||||
return spanPath;
|
||||
};
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { ServicesList } from 'types/api/metrics/getService';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { Tags } from 'types/reducer/trace';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -82,7 +82,7 @@ export function getHostMetricsQueryPayload(
|
||||
start: number,
|
||||
end: number,
|
||||
): ReturnType<typeof getHostQueryPayload> {
|
||||
return getHostQueryPayload(host.hostName, start, end);
|
||||
return getHostQueryPayload(host.hostName, start, end, true);
|
||||
}
|
||||
|
||||
export { hostWidgetInfo };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { TextNoData } from '../../components/TextNoData';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import {
|
||||
BlockLink,
|
||||
getTraceLink,
|
||||
|
||||
@@ -333,7 +333,6 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority,
|
||||
enabled: true,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import styles from './ConditionsTooltip.module.scss';
|
||||
|
||||
interface ConditionsTooltipProps {
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
}
|
||||
|
||||
function ConditionsTooltip({
|
||||
@@ -35,8 +33,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{attributes.map((key) => (
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
@@ -49,8 +47,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{resource.map((key) => (
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
SpantypesSpanMapperOriginDTO as MapperOrigin,
|
||||
SpantypesSpanMapperTestSpanDTO as TestSpan,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
@@ -22,15 +21,9 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
orgId: 'org-1',
|
||||
name: 'demo',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
version: 0,
|
||||
condition: {
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
@@ -42,7 +35,6 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
groupId: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
@@ -51,16 +43,12 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -97,12 +85,8 @@ export const mockGroups: MapperGroup[] = [
|
||||
id: 'group-1',
|
||||
name: 'demo',
|
||||
condition: {
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
}),
|
||||
makeGroup({
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
ConditionKey,
|
||||
FieldContextValue,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
|
||||
import styles from './ConditionKeyList.module.scss';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: ConditionKey[];
|
||||
keys: string[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: ConditionKey[]) => void;
|
||||
onChange: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
function ConditionKeyList({
|
||||
@@ -31,11 +27,11 @@ function ConditionKeyList({
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, createConditionKey()]);
|
||||
onChange([...keys, '']);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
@@ -57,7 +53,7 @@ function ConditionKeyList({
|
||||
<KeySearchInput
|
||||
className={styles.keyInput}
|
||||
placeholder={placeholder}
|
||||
value={key.value}
|
||||
value={key}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user