Compare commits

...

106 Commits

Author SHA1 Message Date
nikhilmantri0902
fe74d501ad chore(ruler): regenerate openapi spec for clone rule 2026-10-09 14:33:40 +05:30
nikhilmantri0902
49208e7b7e fix(ruler): name the source rule when clone validation fails 2026-10-09 14:33:39 +05:30
nikhilmantri0902
475196503b chore(ruler): trim comments in clone rule code 2026-10-09 14:25:32 +05:30
nikhilmantri0902
bd6a4075c8 chore: clone alert rule API 2026-10-09 13:59:19 +05:30
Vinicius Lourenço
cf3662a7e5 fix(home): crash due to race condition on load motion (#12681)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

The issue seems to be caused by a weird race-condition that makes the
`AnimatePresence` context to be undefined, causing the `usePresence` to
crash.

The `motion/react-client` is meant to be used only by server components
of next.js, so updating the imports is safe and should fix the issue.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5957
2026-10-08 19:29:19 +00:00
Vinicius Lourenço
18527e62c2 chore(storybook): replace story-shots scripts with sbshot (#13075)
#### Description

Dropping the custom scripts to take screenshot of storybook in favor of
sbshot, hosted at http://github.com/sigNoz/sbshot/
2026-10-08 18:30:34 +00:00
Vinicius Lourenço
0e2adf3329 ci(e2e): shard playwright across 4 runners and install browsers in background (#13077)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Two little optimizations:

- run the e2e CI shards since it's getting a little bigger (and slower)
- run parts of the steps in background
- this is something that was introduced recently by
https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/

Also adds a model pattern in `llm-o11y/llm-pricing.spec.ts`: the backend
rejects an empty `modelPattern` since #12995.

#### Additional Information

Latest run
([37721605759](https://github.com/SigNoz/signoz/actions/runs/37721605759))
against the last 10 successful single-job runs (Sep 7 to Oct 1):

| | Before (median) | After (4 shards) |
|---|---|---|
| Wall time | 26.5 min | 12.6 min |
| Playwright tests | 19.7 min | 3.7 to 5.9 min per shard |
| Job start to tests | 7.3 min | 6.3 to 7.8 min |
| Runner time | 26.5 min | 46.9 min |

- Sharding gives almost all of the gain. The previous run
([37717653047](https://github.com/SigNoz/signoz/actions/runs/37717653047))
took 12.3 min wall and 46.6 min runner time.
- Starting `playwright-browsers` after the image pulls cut its download
from 1.7 to 3.1 min down to about 50 s, but it now overlaps
`bring-up-stack`, which got about 30 s slower. Job start to tests stays
at about 7 min in both runs, so the background steps save about 20 s per
job.
2026-10-08 17:39:32 +00:00
Ashwin Bhatkal
04db70bade feat(infra-monitoring): add the overview tab to the kubernetes side drawer (#12948)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds an **Overview** tab to the Kubernetes side drawer. It lists
resources related to the drawer's entity, using the existing v2
infra-monitoring list APIs (`listPods`, `listContainers`, …) and reusing
each category's table columns and fetcher from the entity registry.
- The drawer entity's identity expression (e.g. `k8s.node.name = 'xyz'`)
is locked into every related query as the search's initial expression,
so it cannot be edited away. Anything the user types is added on top.
- A dropdown switches the listed resource (defaults to pods), and a
compass button opens the same list, with the same filter, on the
corresponding k8s list page.
- Clicking a row opens that resource's own drawer, overview tab
included, so you can walk node -> pod -> container.
- Overview is the landing tab for every category that has related
resources; categories without any (volumes, containers) are unchanged
and still land on Metrics.

#### Screen Recording


https://github.com/user-attachments/assets/2bfdf320-0267-4552-b7c2-1eb54451256d

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/512
Closes https://github.com/SigNoz/pulse-pod/issues/513
2026-10-08 08:40:44 +00:00
Abhi kumar
54aaad2cc3 fix(dashboard): heatmap legend search and two-row cap (#13082)
#### Description

- Heatmap legend now shows the search bar and caps at two rows, like
other panels. The layout already reserved the search row, but
`customLegend` only received the item width, so the heatmap never
rendered it and its grid spilled into that space as a third row.
`customLegend` now gets the full `LegendLayout`.
- Legend readouts take an optional `itemLabel` (default `series`);
heatmap passes `groups`, since its entries are summed into one grid
rather than drawn as separate series.

#### Screenshots
Before
<img width="987" height="436" alt="image"
src="https://github.com/user-attachments/assets/f4ec5170-dc11-46c4-8de3-452cbc1fb094"
/>

After
<img width="984" height="429" alt="image"
src="https://github.com/user-attachments/assets/f64aea62-f167-4151-a060-c0e2bf603aa1"
/>


Closes https://github.com/SigNoz/pulse-pod/issues/532
2026-10-08 07:35:06 +00:00
Ashwin Bhatkal
c407456be7 fix(new-select): reveal truncated values and contain dropdown scroll (#13063)
#### Description

- Truncated option labels in `CustomSelect` and `CustomMultiSelect` show
the full value in a tooltip on hover. The tooltip appears only when the
label is actually cut off.
- The selected value in a closed `CustomSelect` shows the same tooltip
when truncated.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/525


#### Screenshots

<img width="242" height="183" alt="Screenshot 2026-10-07 at 2 13 03 PM"
src="https://github.com/user-attachments/assets/e5397b1f-c305-4e9b-902d-fd73af69e3ba"
/>
<img width="375" height="168" alt="Screenshot 2026-10-07 at 3 17 18 PM"
src="https://github.com/user-attachments/assets/8807220a-5822-4198-b31d-787fa22ffb5d"
/>
<img width="616" height="285" alt="Screenshot 2026-10-07 at 2 13 23 PM"
src="https://github.com/user-attachments/assets/fb115f10-8190-4d82-8187-1ba7343ee7a2"
/>
<img width="579" height="481" alt="Screenshot 2026-10-07 at 2 12 49 PM"
src="https://github.com/user-attachments/assets/55c28a24-e3ab-4626-84f5-654c75e3eb96"
/>
2026-10-08 04:25:58 +00:00
Abhi kumar
49a0f7552e feat(dashboard): redesign panel editor config pane (#13010)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Collapsed sections show a one-line summary; a dot marks sections with
non-default settings, and fields get a Reset link. Defaults come from
`buildPluginSpec`, so there's no second source of truth.
- Visual choices (line, interpolation, fill, gaps, scale, legend
position, stacking, decimals) are drawing tiles that reflow with pane
width; on/off settings are cards with a sketch and description.
- Axes show one axis with an inline range and flag a min above max; text
position is a 3×3 alignment grid.
- Threshold rows summarise the rule (`> 500 ms · Red background`); the
editor uses operator tiles, value and unit side by side, an "Apply color
to" preview, and warns on a unit mismatch.
- One `ColorSwatches` control serves threshold colors and text panel
backgrounds; shared controls (`ConfigField`, `ConfigTiles`, …) replace
`ConfigSegmented`.
- Leaving the editor with unsaved changes asks for confirmation.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/506

#### Screenshots / Screen Recordings

<table>
<tr><td valign="top" align="center"><img width="220"
alt="01-overview-time-series"
src="https://github.com/user-attachments/assets/b0866ba1-3b2d-48e0-bdf2-08f68237dde4"
/><br /><sub>Overview</sub></td><td valign="top" align="center"><img
width="220" alt="02-formatting"
src="https://github.com/user-attachments/assets/ea805fe3-5f01-4b8a-86eb-d83481c4328d"
/><br /><sub>Formatting & Units</sub></td><td valign="top"
align="center"><img width="220" alt="03-axes"
src="https://github.com/user-attachments/assets/71c2280f-fd65-4296-9027-d3ebb235c46e"
/><br /><sub>Axes</sub></td><td valign="top" align="center"><img
width="220" alt="04-legend"
src="https://github.com/user-attachments/assets/15983d33-5a51-4523-a068-573929058fcc"
/><br /><sub>Legend</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="05-chart-appearance"
src="https://github.com/user-attachments/assets/b315df9a-2187-40b8-8905-a92ee8bde1b6"
/><br /><sub>Chart appearance</sub></td><td valign="top"
align="center"><img width="220" alt="06-thresholds-time-series"
src="https://github.com/user-attachments/assets/943bf5f3-f956-4142-a138-270055a5e660"
/><br /><sub>Thresholds · line</sub></td><td valign="top"
align="center"><img width="220" alt="07-thresholds-number"
src="https://github.com/user-attachments/assets/9be5956a-971f-4fd3-84fe-70ce0b15f5a8"
/><br /><sub>Thresholds · number</sub></td><td valign="top"
align="center"><img width="220" alt="08-thresholds-table"
src="https://github.com/user-attachments/assets/65aa5342-8cb1-476f-a84a-33a9599069e8"
/><br /><sub>Thresholds · table</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="09-context-links"
src="https://github.com/user-attachments/assets/377dde95-b078-40c7-a5b9-145c28d6ca5f"
/><br /><sub>Context Links</sub></td><td valign="top"
align="center"><img width="220" alt="10-area-visualization"
src="https://github.com/user-attachments/assets/a6ad1862-70a3-4ad2-aca8-811de7ffe9b6"
/><br /><sub>Area · series layout</sub></td><td valign="top"
align="center"><img width="220" alt="11-area-chart-appearance"
src="https://github.com/user-attachments/assets/a288a71f-75d0-4597-bd5d-5824fbb5bcea"
/><br /><sub>Area · fill & opacity</sub></td><td valign="top"
align="center"><img width="220" alt="12-bar-visualization"
src="https://github.com/user-attachments/assets/53f31c1e-045c-4eb9-b54e-aae520c21161"
/><br /><sub>Bar · series layout</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="13-histogram-buckets"
src="https://github.com/user-attachments/assets/5b8770b3-01ce-4e8e-b5d0-13e0973fcba9"
/><br /><sub>Histogram · buckets</sub></td><td valign="top"
align="center"><img width="220" alt="14-text-panel"
src="https://github.com/user-attachments/assets/f543934b-d913-45ba-966e-b45b91f9c7e9"
/><br /><sub>Text panel</sub></td><td valign="top" align="center"><img
width="220" alt="15-heatmap-chart-appearance"
src="https://github.com/user-attachments/assets/6e908e48-de47-4b2f-9a88-867f5bb94c9e"
/><br /><sub>Heatmap · palette</sub></td><td valign="top"
align="center"><img width="220" alt="16-heatmap-opacity"
src="https://github.com/user-attachments/assets/9f665013-1dcc-4af8-abb2-018598b9616d"
/><br /><sub>Heatmap · opacity</sub></td></tr>
<tr><td valign="top" align="center"><img width="220"
alt="17-heatmap-axes"
src="https://github.com/user-attachments/assets/f075f163-2eed-465f-a7ba-0728461bdf1d"
/><br /><sub>Heatmap · Y-axis scale</sub></td></tr>
</table>

#### Diff Screenshots

Previous · current · diff, per panel kind.

<table>
<tr><th>Panel</th><th>Dark</th><th>Light</th></tr>
<tr><td>Area</td><td><img width="420" alt="area-chart dark"
src="https://github.com/user-attachments/assets/6233824c-b67d-4a63-bbb5-6464367bc0e3"
/></td><td><img width="420" alt="area-chart light"
src="https://github.com/user-attachments/assets/0ac30d50-249a-4fb5-8cfc-7a0bd3e7ed83"
/></td></tr>
<tr><td>Bar</td><td><img width="420" alt="bar-chart dark"
src="https://github.com/user-attachments/assets/5f01976c-cc37-45cd-88a8-e1606f11594f"
/></td><td><img width="420" alt="bar-chart light"
src="https://github.com/user-attachments/assets/f39662f2-0347-48f5-a171-1b0bc9a6a7f0"
/></td></tr>
<tr><td>Histogram</td><td><img width="420" alt="histogram dark"
src="https://github.com/user-attachments/assets/6faf02ac-5881-4636-bbac-8de622551df4"
/></td><td><img width="420" alt="histogram light"
src="https://github.com/user-attachments/assets/3584feea-69fb-4f34-aec8-6878f08871be"
/></td></tr>
<tr><td>List</td><td><img width="420" alt="list dark"
src="https://github.com/user-attachments/assets/585290de-d2b3-46d7-b931-f85d04a2a8c6"
/></td><td><img width="420" alt="list light"
src="https://github.com/user-attachments/assets/7f35f708-6a9a-44b0-8f92-72982d946eac"
/></td></tr>
<tr><td>Number</td><td><img width="420" alt="number dark"
src="https://github.com/user-attachments/assets/b123d885-d927-484a-9836-39980f640e68"
/></td><td><img width="420" alt="number light"
src="https://github.com/user-attachments/assets/4e743061-06e0-4999-8746-0f35eae9cdcb"
/></td></tr>
<tr><td>Pie</td><td><img width="420" alt="pie-chart dark"
src="https://github.com/user-attachments/assets/d3ed466d-ca2a-4e65-a1d9-9e90723309ae"
/></td><td><img width="420" alt="pie-chart light"
src="https://github.com/user-attachments/assets/73cf2f7a-5e17-4d51-a42e-f2fc8b6eee28"
/></td></tr>
<tr><td>Table</td><td><img width="420" alt="table dark"
src="https://github.com/user-attachments/assets/abc4872d-fc7e-4454-a2cf-46ccfedb2728"
/></td><td><img width="420" alt="table light"
src="https://github.com/user-attachments/assets/1cad1c70-70b3-486f-b668-00cbfff46808"
/></td></tr>
<tr><td>Text</td><td><img width="420" alt="text dark"
src="https://github.com/user-attachments/assets/55c92675-9d2b-4d7c-af20-e21972e98216"
/></td><td><img width="420" alt="text light"
src="https://github.com/user-attachments/assets/62fd2598-eb9d-4559-bf0f-87b69aeae717"
/></td></tr>
<tr><td>Time series</td><td><img width="420" alt="time-series dark"
src="https://github.com/user-attachments/assets/614e16fa-57c6-403e-be50-6a4c054a9271"
/></td><td><img width="420" alt="time-series light"
src="https://github.com/user-attachments/assets/f9344078-590f-4011-bb59-1990721a827e"
/></td></tr>
</table>

#### Additional Information

- Stacked on #12992.
2026-10-07 17:16:29 +00:00
Abhi kumar
75cfe0b2c7 feat(dashboard): new-panel drawer with section placement and type browser (#12992)
#### Description

- New-panel picker is now a searchable drawer with category filters and
preview tiles.
- The footer picks the target section, can name a new section, or places
the panel in the dashboard root; the main New Panel button defaults to
the root.
- Behind the drawer, the target section is highlighted and a placeholder
shows where the panel will land; a new section is previewed as a draft.
- The panel editor's Panel Type dropdown opens the same type browser,
with a button to revert to the original type.

#### Diff Screenshots

Previous · current · diff, per picker state.

<table>
<tr><th>State</th><th>Dark</th><th>Light</th></tr>
<tr><td>Toolbar</td><td><img width="420" alt="new-panel-picker dark"
src="https://github.com/user-attachments/assets/a7ffb4fa-701e-4a09-a925-3fd3bcbfab44"
/></td><td><img width="420" alt="new-panel-picker light"
src="https://github.com/user-attachments/assets/843102de-656f-4a43-b5b5-45b393a9555a"
/></td></tr>
<tr><td>Kind selected</td><td><img width="420"
alt="new-panel-picker-type-selected dark"
src="https://github.com/user-attachments/assets/b5a7b4b4-576c-4ffe-8308-8447d020de35"
/></td><td><img width="420" alt="new-panel-picker-type-selected light"
src="https://github.com/user-attachments/assets/95084971-8449-4fc7-857d-fb72b40409de"
/></td></tr>
<tr><td>No sections</td><td><img width="420"
alt="new-panel-picker-no-sections dark"
src="https://github.com/user-attachments/assets/82694bec-0322-454e-ac1d-10204994374e"
/></td><td><img width="420" alt="new-panel-picker-no-sections light"
src="https://github.com/user-attachments/assets/00e6f05b-2322-4775-b236-a9930e4ae55e"
/></td></tr>
<tr><td>From a section</td><td><img width="420"
alt="new-panel-picker-from-section dark"
src="https://github.com/user-attachments/assets/05443c73-6fea-4eb3-a771-8f4d1200cfb2"
/></td><td><img width="420" alt="new-panel-picker-from-section light"
src="https://github.com/user-attachments/assets/a39f7e7d-4186-413c-8a08-d1883d68b81d"
/></td></tr>
</table>

#### Additional Information

- A new section or root is created only when the panel is saved, in the
same patch as the panel, so abandoning the editor leaves the dashboard
untouched. On a free-flowing dashboard, loose panels are titled "Section
1, 2…" first, matching the existing first-section migration.
- The View modal keeps its compact type dropdown.

Closes https://github.com/SigNoz/pulse-pod/issues/505
2026-10-07 17:16:28 +00:00
Aditya Singh
db3b7f7c34 fix(logs): log details header shifts when switching to the Context tab (#13070)
#### Description
- The log details header moved when the user switched from Overview to
Context. The drawer body used the native scrollbar, which appeared and
disappeared with the content height. The header grid followed the width
change.
- The drawer body now uses `OverlayScrollbar`, the same as the dashboard
settings drawer. The overlay takes no width, so the header stays in
place on every tab.
- The content box grows with its children, so the arrows hint at the
bottom stays pinned across the whole scroll.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/163

#### Screenshot

Before 


https://github.com/user-attachments/assets/908d761c-d723-40db-8178-1e6ad02b9547

After



https://github.com/user-attachments/assets/65560758-c9d4-4d3f-8303-029b3d9114e6



#### Additional Information
- Most of the tsx diff is formatting from the wrap. The change is three
lines.
- The drawer tab stories (`LogDetails`, `LogDetailsContext`) were not
run locally. The manual check on staging is the verification for this
change.
2026-10-07 15:42:47 +00:00
Nityananda Gohain
bb62c9c060 feat: trace detail thread endpoint (#12999)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Adds `GET /api/v1/traces/{traceID}/thread`, which returns the spans
that carry `gen_ai.input.messages` or `gen_ai.output.messages`, in
timestamp order.
- Pages with `after` / `before` cursors (`nextCursor` / `prevCursor`),
or `spanId` to open the page around a span (404 if absent); the three
are exclusive.
- Message normalisation (`formatted_input` / `formatted_output`) comes
in a stacked follow-up PR.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- The thread reads only the `attributes` JSON column, so spans that have
messages only in the old attribute maps don't appear.
  - The waterfall API is unchanged.
2026-10-07 12:08:32 +00:00
Nityananda Gohain
636c023483 feat: add trace summary endpoint (#12911)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds `GET /api/v1/traces/{traceID}/summary`. It returns the trace
header fields shown with the waterfall, plus AI token and cost totals,
using one query.
- The waterfall loads every span to get these fields. It will stop
returning them once the frontend uses this endpoint.
- Fixes these waterfall bugs:
  - On traces over 10k spans, the entry point is empty.
- If some spans point to a parent that was never received, the root can
change on every reload.



| Trace size | Endpoint | Latency | Server memory | Data sent to server
|
|---|---|---|---|---|
| 2M spans | summary | 0.65 s | 84 MiB | 3 KB |
| 2M spans | waterfall | 12.5 s | 2.6 GiB | 128 MiB |

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/265

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- AI token and cost fields are now typed `number`, the same as in
metadata.

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-07 11:05:13 +00:00
Aditya Singh
1d54a93e02 feat(quick-filters): saved views slots in the sidebar layout (#12994)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- first slice of saved views in the quick filters sidebar..
`QuickFiltersLayout` gets a `savedViewProps` next to `quickFilterProps`,
both optional. views header sits above the filters, `≡` opens an All
views panel in the sidebar's place and the filters slide right over the
content as one drawer. nothing remounts, so expanded sections and an
open settings drawer survive the toggle.
- header and panel are placeholders for now (dummy name, empty list)..
this PR is the layout, the real header / list / actions come on top of
it. behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- metrics has no quick filters, so it gets the layout with only the
views side.. header on top, list always visible, nothing slides.
- logs explorer moved onto `QuickFiltersLayout`.. it was the last one
mounting quick filters on its own. content wrapper now hands a definite
height down so logs can keep sizing its list from the pane instead of
`calc(100vh - ..)`.
- quick filters settings drawer animates with transform instead of
width.. width relaid out the drawer every frame, that was the jitter.
clipped so it comes out from behind the filters.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71


#### Screenshots/ Screen recording 

<img width="1728" height="1001" alt="12994 - metrics dummy section"
src="https://github.com/user-attachments/assets/7a57fd43-a11b-44e7-a8ca-4b197826bd36"
/>


https://github.com/user-attachments/assets/fd6a4ac4-6464-4059-9986-99d42e159a3d




#### Additional Information
- flag off checked on logs / traces / metrics / exceptions.. sidebar,
list scroll and pane scroll same as before.
- borders and l1 background per figma, quick filters width still 280px..
will take that up with design.
- cc. @H4ad since `QuickFiltersLayout` is from the scroll fix PR.
2026-10-07 07:08:31 +00:00
Abhi kumar
ea127308b6 chore(storybook): add new-panel picker and config pane stories (#13058)
#### Description

- Stories for the current new-panel picker and panel editor config pane,
as the visual baseline for #12992 and #13010.
- Dashboard detail: picker opened from the toolbar, with a kind
selected, without sections, and from a section's menu.
- Panel editor: one story per panel kind with every config section
expanded; Area and Text added to the editor's kind list.
- `story-shots.mjs`: round the grown viewport height up, since
Playwright rejects fractional sizes.
2026-10-07 06:22:19 +00:00
Naman Verma
9f4108350a feat: add heatmap support in dashboard schema (#12844)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

To be merged along with UI changes

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/347
Closes https://github.com/SigNoz/pulse-pod/issues/193

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-10-06 18:35:51 +00:00
Nikhil Mantri
32350e61e5 feat(ruler): add saved views CRUD APIs for the alerts list page (#12814)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds saved views for the alerts list page: a view stores the v3 list
params (`query` DSL string, `states`, `sort`, `order`) and the FE
replays them, mirroring dashboard saved views.
- New CRUD APIs: `GET/POST /api/v2/rule_views`, `PUT/DELETE
/api/v2/rule_views/{id}`. Open to all roles including viewer, like
dashboard views; shared org-wide.
- New `rule_view` table (migration 127), same shape as `dashboard_view`
plus an explicit index on `org_id` (neither Postgres nor SQLite
auto-indexes an FK's referencing column).
- Validation on save: name (required, trimmed, max 64), `version` must
be `v1`, query length-capped at 1024 (not compiled, a work-in-progress
query is saveable), `states`/`sort`/`order` enum-checked; zero
sort/order normalize to the list defaults (`updated_at`/`desc`); unknown
body fields rejected.
- Covered by unit tests and a new integration suite
(`alerts/07_rule_views.py`: rejection table, 404s, full lifecycle);
OpenAPI spec regenerated.
- discussion: SigNoz/pulse-pod#304.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#227

#### Additional Information

- Based on #12780 (alerts listing page revamp), which this PR's branch
is cut from; backend only, FE ships separately.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-10-06 09:35:15 +00:00
Gaurav Tewari
2bd0153a51 chore: replace banner in llm with callout (#13037)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Prev. we had a banner with this hardcoded values. now we have replaced
it. the callout component.


#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings


before - 
<img width="1517" height="325" alt="image"
src="https://github.com/user-attachments/assets/257639f0-0e97-4606-b9c9-55ee8cabe53f"
/>

Now - 
<img width="1515" height="339" alt="image"
src="https://github.com/user-attachments/assets/b02a9cae-3fc0-4980-90c9-2bde1c9b2c98"
/>

#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-06 05:57:19 +00:00
dependabot[bot]
23776bbd26 chore(deps): bump urllib3 from 2.7.0 to 2.8.0 in /tests (#13032)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.7.0 to 2.8.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/releases">urllib3's
releases</a>.</em></p>
<blockquote>
<h2>2.8.0</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project. If your company or
organization uses Python and would benefit from HTTP/2 support in
Requests, pip, cloud SDKs, and thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Security</h2>
<p>Fixed the following security issues:</p>
<ul>
<li>The TLS configuration for HTTPS proxies could be ignored or
overridden. (High severity, GHSA-8988-9cw3-xx77)</li>
<li><code>HTTPResponse.stream()</code> and <code>read_chunked()</code>
could buffer a chunk-size line of unbounded length in memory. (High
severity, GHSA-vxq7-64xx-v4gw)</li>
<li>Chunked Deflate streaming could enter an infinite loop. (Medium
severity, GHSA-gh4c-6fx4-qh6g)</li>
</ul>
<blockquote>
<p>[!IMPORTANT]
urllib3 2.8.0 fixes HTTPS proxy TLS configuration being ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.</p>
<p>Configure proxy CA certificates and client certificates in
<code>proxy_ssl_context</code>, and proxy identity checks with
<code>proxy_assert_hostname</code> or
<code>proxy_assert_fingerprint</code>. Destination client certificates
and identity overrides no longer apply to HTTPS forwarding proxy
connections.</p>
</blockquote>
<blockquote>
<p>[!NOTE]
CVE IDs had not yet been assigned to these advisories at the time of
release due to a backlog at GitHub's CNA.</p>
</blockquote>
<h2>Deprecations &amp; Removals</h2>
<ul>
<li>Deprecated using an empty collection as the <code>Retry</code>
option <code>allowed_methods</code> to retry any verb. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5044">#5044</a>)</li>
</ul>
<h2>Features</h2>
<ul>
<li>Added <code>Url.auth_decoded</code> and
<code>Url.auth_decoded_joined</code> convenience properties to the
result of <code>parse_url()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/4945">#4945</a>)</li>
<li>Added <code>basic_auth_encoding</code> and
<code>proxy_basic_auth_encoding</code> parameters to
<code>urllib3.util.make_headers()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5092">#5092</a>)</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>
<p>Fixed response header handling to replace obsolete folded header
lines (<code>obs-fold</code>) with spaces in accordance with RFC 9112,
preventing raw CRLF sequences from appearing in header values such as
<code>Set-Cookie</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/1362">#1362</a>)</p>
</li>
<li>
<p>Fixed usage of <code>proxy_ssl_context</code> with
<code>ProxyManager</code> when
<code>use_forwarding_for_https=True</code>. Passing
<code>ssl_context</code> instead of <code>proxy_ssl_context</code> for
HTTPS proxies in this configuration now emits a
<code>FutureWarning</code> and will raise an error in v3.0. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/2577">#2577</a>)</p>
</li>
<li>
<p>Changed behavior of the default <code>ConnectionPool.pool</code>
initialization. <code>LifoQueue</code> is now resolved from the
<code>queue</code> module after the <code>ConnectionPool</code> is
instantiated instead of using the default cached <code>QueueCls</code>
class property. This is done because sometimes the
<code>queue.LifoQueue</code> is monkey-patched late in the program, such
as by gevent. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3289">#3289</a>)</p>
</li>
<li>
<p>Raised <code>UnrewindableBodyError</code> instead of
<code>ValueError</code> when retrying a request whose body had
<code>tell()</code> but not <code>seek()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3779">#3779</a>)</p>
</li>
<li>
<p>Decoded percent-encoded SOCKS proxy credentials before authenticating
with the proxy server. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3785">#3785</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPResponse.drain_conn()</code> to discard unread
response data in 64 KiB chunks (same as the default <code>amt</code>
when doing <code>HTTPResponse.stream(...)</code>). (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5019">#5019</a>)</p>
</li>
<li>
<p>Fixed <code>is_ipaddress()</code> to detect non-standard IPv4 forms
accepted by <code>socket.connect</code>, such as hex
(<code>0x7f000001</code>), octal (<code>0177.0.0.1</code>), and decimal
integers (<code>2130706433</code>), ensuring SSL certificate
verification uses the correct mode for these addresses. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5029">#5029</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPConnectionPool.urlopen</code> raising a misleading
<code>FullPoolError</code> instead of <code>ValueError</code> when
called with an invalid <code>timeout</code> argument on a pool created
with <code>block=True</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5059">#5059</a>)</p>
</li>
<li>
<p>Fixed port-zero handling to preserve explicit <code>:0</code> values
instead of substituting the default ports 80 or 443 in URL parsing, pool
selection, proxy configuration, <code>connection_from_url()</code>, and
HTTP/2 request authority. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5071">#5071</a>,
<a
href="https://redirect.github.com/urllib3/urllib3/issues/5101">#5101</a>)</p>
</li>
<li>
<p>Fixed a bug where <code>PoolManager</code> passed the
<code>assert_hostname</code> and <code>assert_fingerprint</code>
parameters to HTTP connection pools. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5077">#5077</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPConnectionPool.urlopen()</code> and HTTP proxy
forwarding to strip URL fragments from absolute request targets before
sending requests. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5079">#5079</a>)</p>
</li>
<li>
<p>Added safeguards to the proxy tunneling code to prevent potential
security issues when handling invalid characters in the proxy host and
HTTP headers. This change affects users of Python 3.10, Python 3.11, and
Python 3.12 when the standard library does not contain the fix; those on
newer Python versions should upgrade to 3.13.14+ or 3.14.5+ to get the
same security fixes. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5091">#5091</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPSConnection.connect()</code> overriding
<code>ProxyConfig.ssl_context</code>'s certificate policy and proxy
identity checks with the target connection's TLS settings when
forwarding through an HTTPS proxy.</p>
<p><code>HTTPSConnection</code> no longer applies target SNI,
assertions, or client credentials to forwarding proxy handshakes and
continues to use its <code>ssl_context</code> as a fallback when an
HTTPS proxy forwards an HTTP target. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5093">#5093</a>)</p>
</li>
<li>
<p>Fixed URL parsing to more strictly enforce RFC 3986 host syntax,
rejecting invalid host input such as raw spaces and control characters,
malformed percent-encodings, and percent-encoded control characters in
HTTP(S) hosts and IPv6 zone identifiers, including proxy CONNECT tunnel
targets. Host normalization now also follows RFC 3986 normalization
rules for percent-encoded octets by decoding percent-encoded unreserved
characters and uppercasing the hexadecimal digits of retained
percent-encoded octets. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5095">#5095</a>)</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/blob/main/CHANGES.rst">urllib3's
changelog</a>.</em></p>
<blockquote>
<h1>2.8.0 (2026-09-15)</h1>
<h2>Security</h2>
<p>Fixed the following security issues:</p>
<ul>
<li>The TLS configuration for HTTPS proxies could be ignored or
overridden.
(High severity, <code>GHSA-8988-9cw3-xx77
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-8988-9cw3-xx77&gt;</code>__)</li>
<li><code>HTTPResponse.stream()</code> and <code>read_chunked()</code>
could buffer a chunk-size
line of unbounded length in memory. (High severity,
<code>GHSA-vxq7-64xx-v4gw
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-vxq7-64xx-v4gw&gt;</code>__)</li>
<li>Chunked Deflate streaming could enter an infinite loop. (Medium
severity,
<code>GHSA-gh4c-6fx4-qh6g
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-gh4c-6fx4-qh6g&gt;</code>__)</li>
</ul>
<p>.. caution::</p>
<pre><code>urllib3 2.8.0 fixes HTTPS proxy TLS configuration being
ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.
<p>Configure proxy CA certificates and client certificates in
<code>proxy_ssl_context</code>, and proxy identity checks with
<code>proxy_assert_hostname</code> or
<code>proxy_assert_fingerprint</code>.
Destination client certificates and identity overrides no longer
apply to HTTPS forwarding proxy connections.
</code></pre></p>
<h2>Deprecations &amp; Removals</h2>
<ul>
<li>Deprecated using an empty collection as the <code>Retry</code>
option
<code>allowed_methods</code> to retry any verb.
(<code>[#5044](https://github.com/urllib3/urllib3/issues/5044)
&lt;https://github.com/urllib3/urllib3/issues/5044&gt;</code>__)</li>
</ul>
<h2>Features</h2>
<ul>
<li>Added <code>Url.auth_decoded</code> and
<code>Url.auth_decoded_joined</code> convenience
properties to the result of <code>parse_url()</code>.
(<code>[#4945](https://github.com/urllib3/urllib3/issues/4945)
&lt;https://github.com/urllib3/urllib3/issues/4945&gt;</code>__)</li>
<li>Added <code>basic_auth_encoding</code> and
<code>proxy_basic_auth_encoding</code> parameters to
<code>urllib3.util.make_headers()</code>.
(<code>[#5092](https://github.com/urllib3/urllib3/issues/5092)
&lt;https://github.com/urllib3/urllib3/issues/5092&gt;</code>__)</li>
</ul>
<h2>Bugfixes</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b1d30ab61f"><code>b1d30ab</code></a>
Release 2.8.0</li>
<li><a
href="9016d7e8af"><code>9016d7e</code></a>
Skip <code>test_read_chunked_with_trailing_data_does_not_hang</code> for
brotlicffi (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5258">#5258</a>)</li>
<li><a
href="9101f581a8"><code>9101f58</code></a>
Fix <code>nox -s docs</code> warning (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5256">#5256</a>)</li>
<li><a
href="cd770b059b"><code>cd770b0</code></a>
Merge commit from fork</li>
<li><a
href="ea2ad7b21a"><code>ea2ad7b</code></a>
Merge commit from fork</li>
<li><a
href="0716e31534"><code>0716e31</code></a>
Fix loading unencrypted client keys with a password in pyOpenSSL (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5255">#5255</a>)</li>
<li><a
href="43c68c8b43"><code>43c68c8</code></a>
Test pickling of <code>InvalidChunkLength</code> (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5247">#5247</a>)</li>
<li><a
href="308b279b3f"><code>308b279</code></a>
Share security policy between GitHub and Read the Docs (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5253">#5253</a>)</li>
<li><a
href="53fa0731b2"><code>53fa073</code></a>
Add policy on duplicate pull requests (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5252">#5252</a>)</li>
<li><a
href="5f2a6a843d"><code>5f2a6a8</code></a>
Assert on the ALPN extension in test_tunnel_sets_http_11_alpn (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5232">#5232</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 04:07:41 +00:00
Srikanth Chekuri
f941d1ea11 chore(deps): bump grpc to v1.83.2 and opentelemetry-go to v1.45.0 (#13047)
#### Description

- Bump `google.golang.org/grpc` to v1.83.2 and the opentelemetry-go core
and trace exporter modules to v1.45.0. This fixes the open Dependabot
alerts for these modules.
- Take the resource schema URL from `sdkresource.Default()`. From
v1.44.0, the SDK uses semconv 1.41.0. With the pinned 1.40.0 URL, the
resource merge fails and the server does not start.
- Keep the otel log modules at their current versions. Their v0.21.0 API
breaks `otelzap` v0.13.0 and the log exporters that
`signoz-otel-collector` v0.144.6 uses.
- Replaces #12828, #12900, #12902, #12903 and #12904.
2026-10-06 03:23:37 +00:00
dependabot[bot]
185677634c chore(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 in /scripts/promqltestcorpus (#12804)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.83.1 to 1.83.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.83.2</h2>
<h1>Security</h1>
<ul>
<li>server: Reject requests missing both <code>:authority</code> and
<code>Host</code> headers with HTTP 400 and status
<code>Internal</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/pull/9365">grpc/grpc-go#9365</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/winklemad"><code>@​winklemad</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="030ee8becb"><code>030ee8b</code></a>
Update version to 1.83.2 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9375">#9375</a>)</li>
<li><a
href="8668b69c16"><code>8668b69</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9365">#9365</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9366">#9366</a>)</li>
<li><a
href="a3e952d2b7"><code>a3e952d</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9346">#9346</a> to
v1.83.x and update x/net dependency (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9369">#9369</a>)</li>
<li><a
href="58f8fd9a00"><code>58f8fd9</code></a>
Change version to 1.83.2-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9337">#9337</a>)</li>
<li>See full diff in <a
href="https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 13:04:53 +00:00
Naman Verma
e8c22cb259 fix(promql): serve transpiled series without their synthetic __name__ (#13031)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

`max_over_time` expects a range vector as input, which is `labels ->
[(timestamp,value),...]`. it turns each entry into
`labelsWith__name__removed -> maxOfAllValues`.

However, if two entries have `labelsWith__name__removed` as the same,
then `max_over_time` throws an error. For eg if the input is:
1. {"host":"a", "__name__": "transpiled_1"} -> ....
2. {"host":"a", "__name__": "transpiled_2"} -> ....
then `max_over_time` will break.

Currently, `executeHybrid` in
`pkg/prometheus/clickhouseprometheusv2/transpiler_exec.go` always puts
in the `__name__` label as `signoz_transpiled_*`, which can lead to the
above scenario.

Removing this `__name__` label fixes that issue. Also, nothing ever
needs this label. When the engine asks for `signoz_transpiled_0`, our
storage finds the data with a map lookup on that name and returns it.
The series' own labels play no part in the lookup.

After this change, no synthetic `__name__` exists anymore. So, the code
that stripped it after the engine ran, and `mergeMatrixByLabelset`,
which re-merged the rows those names had split, are deleted. The
engine's own merging handles this now.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/508

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-05 09:56:02 +00:00
Vikrant Gupta
0aeef0dc46 fix(auth): clear stale password errors on the reset password page (#13035)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- Submitting a weak password on the reset page returned the backend
`invalid_password` error, and the pane stayed up after the password was
fixed, replaying its shake while typing.
- The mismatch flag was imperative state set on blur, so it went stale
when a field was cleared; toggling it remounted the API error pane.
- Validation is now derived from watched form values, the same way
SignUp does it, and the mutation is reset on edit so the API error
clears as soon as either field changes.
- Added tests for both cases.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#144

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/48dcfa17-9fbc-47bc-83c5-75b0bb46ced0
2026-10-02 12:52:30 +00:00
Tushar Vats
7663ab02d4 feat(logparsingpipeline): add json_body_dual_ingestion flag for dual body ingestion (#12831)
#### Description

Server counterpart to SigNoz/signoz-otel-collector#891, which makes the
collector write each log body to both the legacy `body` column and
`body_v2` while a `json_body_dual_ingestion` flag is on.

- Adds the `json_body_dual_ingestion` feature flag (experimental, off by
default).
- Normalize is placed by read mode, so user pipelines always see the
body the explorer shows. With `use_json_body` on it stays ahead of user
pipelines. With dual ingestion alone, reads are still on the legacy
`body`, so it runs after them and only feeds `body_v2`.
- Whenever dual ingestion is on, the operator carries
`json_body_dual_ingestion: true` so it stashes the original body for the
exporter to restore. Running last under dual makes that stash the
post-pipeline body, exactly what legacy ingestion stores today.
- The pipeline preview follows the same rule and normalizes only under
`use_json_body`.

Design notes: [Normalize Operator and
Pipelines](https://app.notion.com/p/signoz/Normalize-Operator-and-Pipelines-3d7fcc6bcd19802396b9e8e817e30381),
[Dual JSON body
ingestion](https://app.notion.com/p/signoz/Dual-JSON-body-ingestion-3c6fcc6bcd198049963bce6ce2648af8)

#### Additional Information

- Collectors must run a build containing
SigNoz/signoz-otel-collector#891 before this flag is turned on. Verified
locally against v0.144.9: an older collector does not reject the unknown
operator key, it silently ignores it (operator configs are decoded with
`confmap.WithIgnoreUnused()`), runs normalize, and writes the normalized
body into the legacy `body` column until it is upgraded. No collector
release includes #891 yet.
- The exporter's own `json_body_dual_ingestion` key is collector deploy
config and is flipped together with this flag; the server does not set
it.
- Toggling either flag does not bump the pipeline config version, so
connected agents need a new pipeline save to pick up the operator or its
position. Same caveat as `use_json_body` today.
- Under dual, normalize is last among the SigNoz pipelines; custom
collector processors placed after them still see the normalized map.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-02 10:56:38 +00:00
Srikanth Chekuri
1643df620b feat: resolve semconv families across logs and metrics (#12870)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Phase 2 of #6143: semantic-convention families resolve on logs and
metrics, behind the `resolve_semconv_families` flag (default off), on
the storage contract of #12802.

- Registry: each member carries the scope of its own rename edges, so a
fan-out keeps one membership per target and an ambiguous name stays
literal.
- Logs and metrics families need no family code of their own. The gate
applies to every signal, and `LogicalRead` merges members through each
storage's `Read`.
- Metric-name families union the storage names in every `metric_name`
filter, and the querier reads type, temporality, and the reduced flag
across the family.
- Span-metrics labels: the metrics the processor emits, listed by name,
also read each family member with the `resource_` prefix. Requested
names are never rewritten.
- Values suggestions and related values cover every spelling of the
family.
- `deployment.environment.name` resolves on all three signals.
`db.system.name` stays off until a value-mapping reader exists.

#### Additional Information

- `pkg/semconv.Family` fields are now unexported, and `transition.go` is
removed. #12446 reads the old API and needs an update when stacked.
- A target that emits both names of a metric-name family double-counts
in `sum()` during the overlap window. Reading both names is the feature.
Pinned by a test.
- A family of metrics labels keeps the keyless contract of a single
label: no guard, no NULL group.
2026-10-01 20:28:15 +00:00
Aditya Singh
fd21f8b760 feat(bottom-strip): show the page count on the left for noz, alerts and home (#12963)
#### Description

- noz page, alert rules, home, exceptions and services now push their
count to the left of the strip. each page has its own `useXStripInfo`
hook that builds the config and publishes it.. same pattern trace
details already uses, five more times.
- the hook does the whole thing, so the page call is one line. noz and
home fetch or subscribe inside the hook so the page does not re-render
just to keep the strip current.. the rest pass values they already hold.
- alert rules and exceptions show "N of M".. first number is what is on
the page right now. both read the same two values the table hands its
own pagination, so the strip cannot disagree with the table. services,
home and noz are a plain count.
- services renders one of two tables on `use_span_metrics`, so the hook
is called from both.. the count text lives in one place either way.
- dashboards is left out for now, it already shows this on its own
strip.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/53
Part of https://github.com/SigNoz/events-pod/issues/55


#### Screenshots

Ai Assistant  Bottom strip

<img width="3456" height="1968" alt="image"
src="https://github.com/user-attachments/assets/0042f71b-1b56-417a-8283-af9ba9596351"
/>


Alert rules

<img width="3452" height="1992" alt="image"
src="https://github.com/user-attachments/assets/a674de13-9841-4717-89ce-a656c9df646c"
/>

Exceptions 

<img width="3456" height="1970" alt="image"
src="https://github.com/user-attachments/assets/6333ea6b-2342-46dc-985d-2855bebe4003"
/>



Services

<img width="3456" height="1996" alt="image"
src="https://github.com/user-attachments/assets/abe0c5cb-cfe3-48b6-9591-ee71e677a1a6"
/>




#### Additional Information
2026-10-01 19:30:00 +00:00
Vinicius Lourenço
434b282549 chore(dashboard-story): answer variable endpoints outside the Data state control (#13000)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

To try prevent flakyness of
https://github.com/SigNoz/signoz/actions/runs/36460317287/job/109056950899#step:7:9349
2026-10-01 14:07:41 +00:00
Aditya Singh
ecdc57836c feat(bottom-strip): add right side actions and the per page left slot (#12961)
#### Description

- adds ask noz and support to the right side of the strip. support is
one button covering both floating bubbles.. only one of them ever showed
at a time, so it is pylon for users who have it and the add credit card
modal for trial users without a card. only the bubbles are hidden, noz
in the header and side nav stays for now.
- the support gating was spread across AppRoutes and two components,
each rebuilding the same feature flag + license + trial checks. pulled
into `useChatSupport`, and the add credit card modal into one shared
component.. support page still has its own copy, that goes with cleanup.
- left side is a store the strip subscribes to. pages push a node with
`useBottomStripLeft` and it clears on unmount.. strip knows nothing
about pages. falls back to the build version when nothing is set.
- the node comes from the page, so it is wrapped in an error boundary
that falls back to the version. the strip sits outside the app layout
boundary on purpose so it survives a page crash.. without this a bad
node would reach the top level one and blank the whole app.
- trace details is the first consumer, spans and errors. page passes the
values instead of the node fetching them.. the trace query key includes
the selected span so a self fetching node would refetch on every span
click. header counts stay as is.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/51
Part of https://github.com/SigNoz/events-pod/issues/52

#### Screen recording


https://github.com/user-attachments/assets/12d476e5-a486-4903-974e-e0964ffa3e3c



#### Additional Information

- no loading state or error handling on the count yet and the numbers
are raw.. doing all three in one pass once the rest of the pages are
wired.
- no analytics here, that comes with the analytics ticket.
- pylon and the add card flow were tested locally with temp code. the
pylon chat window offset and bubble hiding still need a pylon enabled
tenant to verify.
- the conversation history popover for the right side is still to do, so
6075 stays open.
2026-10-01 13:39:35 +00:00
Vikrant Gupta
572345be63 feat(authz): enable FGA for users and reset password tokens (#13020)
#### Description

- Moves the users API off the legacy `AdminAccess` gate onto
`CheckResources` + `ResourceDef`s; `me` and anonymous password flows
stay `OpenAccess`.
- Invite checks `role:attach` per requested role; an empty role list
resolves to no link, so the sibling def skips the check.
- Migration `131_add_user_tuples` backfills admin `user` and
`factor-password` tuples for existing orgs.

#### Issues closed by this PR

Closes: SigNoz/keystone-pod#38
2026-10-01 11:26:18 +00:00
Nikhil Soni
bcf96f2816 feat(traces-qb): gate JSON span attribute reads behind a feature flag (#12966)
#### Description

- The new feature flag `use_trace_attributes_json` (default off) now
gates the JSON columns in the traces `getColumn`, alongside the
evolution entry.
- `SelectEvolutionsForColumns` now ignores evolutions of columns the
mapper didn't return instead of erroring, so a flag-off attribute
resolves to its map even though the key carries the JSON evolution.

Part of https://github.com/SigNoz/signoz/pull/12966

#### Additional Information

- Evolution entry migration: SigNoz/signoz-otel-collector#928
- Original QB PR: #4781
2026-10-01 10:21:10 +00:00
Yunus M
a707ed6928 feat: implement error handling and retry mechanism for assistant messages (#11913)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

The AI Assistant previously surfaced streaming failures with minimal
structure: a plain error string, no distinction between transient vs
permanent failures, and no way for users to recover without retyping or
refreshing. Backend SSE errors now carry `retryAction` (`auto` /
`manual` / `none`) and structured error codes, but the frontend was not
honoring that contract end-to-end.

This PR wires full error handling and retry into the assistant:

- **Centralized error resolution** — `resolveAssistantErrorMessage` is
replaced by `resolveAssistantError`, which maps backend codes to
user-friendly copy, classifies rate-limit vs non-retryable errors, and
derives the correct `retryAction`.
- **Dual retry budgets in the store** — `streamWithAuthRetry` becomes
`streamWithRetry`, handling auth expiry (one silent re-attempt) and
backend-flagged transient errors (up to 2 auto-retries with 500ms /
1500ms backoff). When auto-retries are exhausted, the error is
downgraded to `manual` so the user can still retry.
- **Manual retry UX** — failed turns commit as styled error bubbles
(`isError`, `errorCode`, `retryAction`). Manual errors show an inline
**Retry** button that replays the originating action (send, approve,
clarify, regenerate) without duplicating the user message. A transient
`retryRegistry` holds the replay thunk for the latest failed turn.
- **Analytics** — `RetryClicked` event fired when the user clicks Retry.

This aligns the UI with the backend error contract and gives users a
clear, actionable path to recover from transient failures.

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

| Before | After |
|--------|-------|
| Generic/unstructured error text in assistant bubble | Error callout
with warning icon, code-specific copy, and **Retry** button for manual
errors |
| No retry affordance | Retry replays the failed action; auto-retries
happen silently for transient errors |
| Feedback bar shown on errors | Feedback/regenerate hidden on error
bubbles; rate-limit errors still suppress retry |

_Add screenshots of: (1) `thread_busy` manual error with Retry, (2)
rate-limit error with no Retry, (3) successful recovery after Retry._

#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

Fixes:  https://github.com/SigNoz/nerve-pod/issues/92

---

### ✅ Change Type
_Select all that apply_

- [x] ✨ Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [x] 🧪 Test-only

---

### 🐛 Bug Context
> Required if this PR fixes a bug

N/A — this is primarily a feature/enhancement to error handling UX, not
a targeted bug fix.

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated:
- `resolveAssistantError.test.ts` — error code copy, rate-limit
classification, non-retryable codes, HTTP/SSE error shapes,
`retryAction` derivation
- `useAIAssistantStore.test.ts` — manual error bubbles, manual retry
replay (send + approve), auto-retry with backoff and downgrade to
manual, silent recovery on auto-retry success, rate-limit errors with no
retry
- `MessageBubble.test.tsx` — error styling, Retry button visibility
(`manual` vs `none`), `onRetry` callback, feedback bar suppressed on
errors
- Removed `resolveAssistantErrorMessage.test.ts` (superseded by
`resolveAssistantError`)

- Manual verification:
- Trigger `thread_busy` during an active execution → error bubble with
Retry → click Retry → successful response without duplicate user message
- Trigger rate-limit error → error bubble, no Retry button, no feedback
bar
- Trigger transient `internal_error` with `retryAction: auto` → silent
retries; if all fail, manual Retry appears

- Edge cases covered:
- Auth expiry mid-stream (`invalid_token`) — one auth retry via
`streamWithRetry`
  - Auto-retry budget exhaustion (2 attempts) → manual Retry affordance
- Retry replays originating action for approve/clarify/regenerate, not
just send
  - Retry no-op when `retryAction: none` or no registry entry
  - Retry disabled while streaming is active

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: AI Assistant chat only — `useAIAssistantStore`,
`MessageBubble`, `VirtualizedMessages`, error utils
- Potential regressions:
- Error copy regressions if a new backend code is not in
`ERROR_CODE_COPY` (falls back to backend message)
- Auto-retry backoff may add latency (up to ~2s) before surfacing a
manual error for transient failures
- `retryRegistry` is in-memory only — page reload drops retry capability
(acceptable; user can resend)
- Rollback plan: Revert PR; no schema/migration changes

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature |
| Description | AI Assistant now shows clearer error messages with
inline Retry for recoverable failures, and silently retries transient
backend errors before asking the user to retry. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [x] Backward compatibility considered

---

## 👀 Notes for Reviewers

<!-- Anything reviewers should keep in mind while reviewing -->

- **`resolveAssistantErrorMessage.ts` → `resolveAssistantError.ts`**:
The new util returns a full `AssistantErrorResolution` object instead of
just a string. All call sites in the store were updated accordingly.
- **`streamWithRetry`**: Auth retry (1×) and auto retry (2× with
backoff) are independent budgets. When auto retries are spent,
`retryAction` is forced to `manual` before the error propagates to
`finalizeStreamingError`.
- **`retryRegistry`**: Transient map keyed by `conversationId`; not
persisted. Pairs with the error bubble's lifetime.
- **Error bubble UI**: Uses `@signozhq/ui` `Button` and
`@signozhq/icons` (`TriangleAlert`, `RotateCw`). Feedback/regenerate bar
is hidden on `isError` messages (same as rate-limit).
- **Files touched (12)**: store, error util, MessageBubble (+ styles),
VirtualizedMessages, types, events, 3 new test files, 1 removed test
file.

---
2026-10-01 05:46:02 +00:00
Vikrant Gupta
e374d03e54 revert(authz): restore gjson-based body extraction in resource middleware (#13024)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Reverts #13014 and #13015. The resource middleware goes back to
reading body-derived resource ids with `BodyJSONPath` / `BodyJSONArray`
over the raw body, and handlers decode their own request bodies again.
- Authz should not own request decoding; that ownership stays with the
handlers.

#### Additional Information

- Contributes to: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-30 13:53:21 +00:00
Swapnil Nakade
d445b6c296 chore: bumping cloud integration agent version to v0.0.15 (#13023)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Bumping the cloud integration agent's version to latest v0.0.15

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/101
2026-09-30 12:38:24 +00:00
Swapnil Nakade
fd8aaac300 feat: adding sync state in cloud integration (#12991)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
The agent only saw the current list of enabled regions, so it couldn’t
tell which regions had been removed. To find stacks to clean up, it
checked unrelated AWS regions, causing unnecessary calls and permission
errors. Sync state keeps track of regions sent to the agent and pending
removals until the agent acknowledges cleanup.

Please check
[comment](https://github.com/SigNoz/keystone-pod/issues/101#issuecomment-5832865898)
for approach

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/101

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
This PR should be merged before changes for cloud-integration repo.

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-30 11:39:46 +00:00
Naman Verma
e4cd8dbf10 chore: store v2 config for notification channels in db (#12984)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

1. add a config column to notification channels table where the channel
config goes without dealing with receiver at all. this helps in cleaning
up all round trip issues caused by dealing with receiver in the storage
layer. v2 apis treat receiver as a side effect now
1a. for applicable fields, defaults are filled in create/update api if
fields are omitted
1b. explicit [] and {} are no longer dropped, and omitted [] and {} are
returned as explicit null
2. add integration tests for all notification channel round trip issues
3. code cleanup of v2 channels types 
4. migration to fill the config column from receiver column, which logs
results like dashboards migration did
4a. it fails for receivers that cannot be modeled in v2. repair api can
be used for them
4b. for types that v2 supports, any fields that v1 supports but v2
doesn't, this migration drops those fields
5. make v1 API reject anything that v2 apis do not support, and also
fill the new config column added
6. add integration tests for v1<>v2 interaction to ensure that alert
manager doesn't break because of new changes added

What breaks/changes for v1:
1. types that v2 does not support can no longer be created
2. channels with multiple receivers cannot be created
3. channels with fields that v2 does not support cannot be created
4. existing channels of types that v2 does not support can no longer be
edited via v1 API. They can be deleted though. Also, they keep on
sending notifications as before (sigh).

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/376
Closes https://github.com/SigNoz/pulse-pod/issues/378
2026-09-30 11:30:33 +00:00
praneeth-signoz
81d024dfc8 chore(channel-receivers): relax channel validations (#13008)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Relax all the strict validations like https, host name...for all the
channels. And why this is needed ?

1. Channel URLs were pinned to the vendor's own host —
`chat.googleapis.com`, `*.atlassian.net`— which blocked deployments that
send notifications through a proxy or relay.
2. Provider's contract is not ours to hardcode — so we check the field
is there and let the provider reject what it doesn't accept

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

closes https://github.com/SigNoz/pulse-pod/issues/374
2026-09-30 10:20:41 +00:00
Vikrant Gupta
8dba9a13ea fix(authz): read every body-derived resource id from the decoded request (#13015)
#### Description

- Follows #13014. Moves the remaining body-derived resource ids (gateway
limits, zeus hosts, cloud integration check-ins, auth domains, query
range) off gjson and onto the decoded request, with the handlers reading
the same value. Part of SigNoz/keystone-pod#37.
- Removes `BodyJSONPath`, `BodyJSONArray`, and
`ExtractorContext.RequestBody`.

#### Issues closed by this PR 

- Closes: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-30 09:34:32 +00:00
Vikrant Gupta
8d80f98710 fix(authz): decode the request body once in the resource middleware (#13014)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
#### Description

- The resource middleware now decodes the body once into the route's
declared `OpenAPIDef.Request` type, rejects a malformed body before any
check, and carries the decoded value on `ExtractorContext.Body`.
`BodyField` / `BodyFields` read ids off that value, and handlers read
the same value via `coretypes.BodyFromContext`.
- `handler.Handler` exposes `Request()`, and `handler.New` panics when a
route with resource defs declares a non-pointer request, since the
middleware instantiates it.
- Only `POST /api/v1/service_account_roles` is wired to the new
extractors in this PR to keep the review small. The remaining body
routes still use the gjson extractors and decode again in their
handlers.

#### Issues closed by this PR
- Contributes to: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-29 21:32:25 +00:00
Aditya Singh
adfcebf855 feat(explorer): create alert and add to dashboard actions in the explorer views (#12981)
#### Description
- Create alert and Add to dashboard now live in the view that owns the
row.. logs list row, logs / traces time series and table headers, traces
list and trace rows, metrics per chart. Same line as download
everywhere. First slice of pulling the actions out of the bottom bar,
the bar keeps its own two till it goes so they show twice for now.
- new `ExplorerActions` renders the pair.. the view hands over its
export query, same query the bar gets so nothing changes in what reaches
the alert / dashboard. `TimeSeriesView` got a `headerActions` slot for
it.
- metrics one chart per query: each chart carries its own alert /
dashboard / download for that chart's query, icon only in the split
layout. no per query picker needed.
- alert shaping is source agnostic now.. every noop becomes count and
list / trace panels drop `orderBy` whatever the source. bar only checked
the first query and only stripped for logs. neutral today, traces
already sends `orderBy: []` for those views.
- `DownloadOptionsMenu` moved to the design system buttons.. list
download was the antd primary tinted icon, did not match the rest of the
row.
- llm explorer and meter untouched.. llm gets it later, meter never had
these buttons in the bar.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/56




https://github.com/user-attachments/assets/a3b7f08c-2c62-4243-8cd7-4dd25cb315fd



#### Additional Information
- no flag.. buttons are live from merge, the bar stays till saved views
lands in the sidebar.
- logs controls row is list only now.. everything in it was list gated
once the buttons moved into the view headers, it was an empty strip on
time series / table.
- analytics is one generic event per action with `sourcepage` in the
payload, not the per explorer names the bar used.
- verified bar == button (alert url and dashboard link) in the browser
for all logs / traces views and metrics single, split and unsplit. tests
pin the query pushed to the url per view.
2026-09-29 16:43:05 +00:00
Swapnil Nakade
254758942e fix: using allLogs categoryGroups for azure container apps (#13013)
#### Description

- The Container Apps logs pipeline failed because the definition passed
log category names (`ContainerAppConsoleLogs`, `ContainerAppSystemLogs`)
as `categoryGroups`. Azure accepts only `allLogs` or `audit` there, so
it rejected the diagnostic setting.
- Switched to `allLogs`, matching the other Azure services. The agent
picks this up on its next config sync, so no agent release is needed.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#45

#### Additional Information
- Not tested on live Azure. Microsoft's built-in policy for
`Microsoft.App/managedEnvironments` sends the same `allLogs` setting to
Event Hub.
2026-09-29 14:24:00 +00:00
Aditya Singh
8a5d98f467 chore(logs): remove the old log details drawer (#12938)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `useIsLogDetailsV2` was a route test, not a feature flag.. v2 rendered
on the logs explorer, infra monitoring and dashboards and everything
else fell back to v1. made v2 the only drawer, so the pipelines preview
gets it too. that's the one behaviour change here.
- deleted the v1 code that leaves behind.. the attribute table and its
json processing, the two HOCs, the `ActionItem` component and the
separate JSON tab. `Overview` is down to its DataViewer path, which also
drops monaco from the logs bundle. `DataType` and the two props types
move out first since MetricsExplorer and infra monitoring read them.
- dropped the standalone `/logs/logs-explorer/live` route. nothing
navigated to it, the time picker's Live option is in-page state on the
explorer. the components stay, that in-page mode still renders them.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5933

#### Additional Information

- `onAddToQuery` stays for now. it is v1-only inside the drawer but five
call sites still thread it through, a couple of them doubling it as
`onClickActionItem`.. untangling that is a refactor rather than a
deletion.
- `useLogAttributeActions` gated group-by and replace-filter on "old
explorer or live logs". with both routes gone the guard is always false,
so it collapses.
2026-09-29 13:30:34 +00:00
praneeth-signoz
47dd1fabf3 chore(channel-specs): Move channel specs to separate files (#12989)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Moved channel specs to separate files under alert manager types
- Channel receivers are also moved the same file

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes
https://github.com/orgs/SigNoz/projects/34/views/26?pane=issue&itemId=252814150&issue=SigNoz%7Cpulse-pod%7C374
2026-09-28 17:44:35 +00:00
Vikrant Gupta
270988fb48 fix(tokenizer): persist last_observed_at on postgres (#12982)
#### Description

- The flush CTE rendered `last_observed_at` as an untyped literal, which
postgres resolves to `text` and refuses to assign to the `timestamptz`
column. The column never populated, so the idle expiry never applied.
- Build the CTE from the token model with only `id`, `last_observed_at`
and `updated_at`, so bun casts per dialect and no token secrets land in
the statement.
- Flush now applies cached times through `Token.UpdateLastObservedAt`,
which also skips rows with a newer stored value.
- Integration test in `passwordauthn` runs with a short GC interval and
asserts the column populates on both sql stores.
2026-09-28 14:23:01 +00:00
Naman Verma
39badeb591 fix: remove rules types package import from migration #049 (#12998)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Migration package ideally should have have things from types package
imported. Given that rules v1->v2 will (most probably) update/remove
some of the types, such as removing `PreferredChannels` from
`PostableRule`, better not to have this type imported in migrations
package.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Part of https://github.com/SigNoz/pulse-pod/issues/225
2026-09-28 11:39:07 +00:00
Nityananda Gohain
ec05bfe755 fix: empty patterns in pricing are rejected (#12995)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Empty patterns were not rejected because of which corrupt config was
created, rejecting them at the handler layer.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/282
2026-09-28 08:09:38 +00:00
Aditya Singh
ed1bf7ab89 fix(bottom-strip): size pages from the layout instead of the viewport (#12940)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- pages that hardcoded `100vh` minus a guess at what sits above them
came out taller
than the pane they live in, which showed up as scroll that should not be
there. they
  now take what the layout gives them.
- most of the `100vh` in the app turned out to be harmless.. either
flex-shrink absorbs
it or the pane scrolls anyway. those are left alone, only the ones with
a real symptom
  are changed here.
- alert rules and triggered alerts also needed the AlertList tabs chain
to hand height
  down, that page uses antd `Tabs` directly instead of `RouteTab`.
- licenses, status and support pages had `max-height: 100vh` with
`overflow: hidden` and
no inner scroller, so anything past a viewport was clipped with no way
to reach it.
  removed the cap on all three.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6074


#### Screenshots/ Screen recording

Home page


https://github.com/user-attachments/assets/8ba3e3c9-1959-4393-b503-ddf579e9d139

Without bottom strip


https://github.com/user-attachments/assets/2ee08181-710a-4c34-a4e7-893c4a320453



Status page

<img width="1728" height="1000" alt="status"
src="https://github.com/user-attachments/assets/f44ec7d7-4ee0-4866-9a90-fe25622fe25b"
/>

Without bottom strip

<img width="1728" height="997" alt="status2"
src="https://github.com/user-attachments/assets/be57dce6-65ca-4450-8f0d-0337625a1d64"
/>

Alert rules


https://github.com/user-attachments/assets/7cc25f56-54e4-4489-bdb4-453409151bac

Triggered alerts


https://github.com/user-attachments/assets/2d8e1885-7cad-4659-90f6-59960f4afce8

Without bottom strip



https://github.com/user-attachments/assets/5247f6c0-3675-4910-b1e4-4076bf93c16e



Support
<img width="1728" height="997" alt="support"
src="https://github.com/user-attachments/assets/c2b3c0b2-2abd-4538-bb52-2660e75817b3"
/>

Trace funnel



https://github.com/user-attachments/assets/c4872135-3cb5-4348-b6c6-2d3e4dabdc1b

Without bottom strip


https://github.com/user-attachments/assets/aef94d5d-02e5-4154-995b-76819d55c2d8


Trace details



https://github.com/user-attachments/assets/24d701b0-296f-4fb4-ba9d-615a47b33285

Without bottom strip


https://github.com/user-attachments/assets/23083f20-67bf-4b06-aaec-aba390a6f594



#### Additional Information

- every page here was checked on screen before and after. the ones left
untouched
(infra hosts/k8s, traces + llm explorer list views, llm settings tables,
the k8s logs
  drawer) were checked too and are fine.
2026-09-25 15:14:44 +00:00
Aditya Singh
6e979c8318 feat(bottom-strip): add the layout shell behind a feature flag (#12936)
#### Description

- adds the bottom strip to the app layout behind a localStorage flag.
shows the build
version on the left for now.. right side actions and the per page count
come in the
  next tickets.
- `.app-content` is a column flex now and `LayoutContent` takes the
height left over
instead of `height: 100%`, so the strip has a stable box to sit under.
this is the
  only bit not behind the flag.
- fixed bottom elements read `--bottom-strip-height`. the var only
exists while the
strip is mounted, so with the flag off everything falls back to where it
is today.
- hides nothing. each later ticket hides the piece it replaces.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6074

<img width="3084" height="1566" alt="image"
src="https://github.com/user-attachments/assets/b1821fda-5c33-40e7-926a-5d91fedb797e"
/>


#### Additional Information

- pages that still hardcode `100vh` (infra hosts/k8s, trace details,
traces and llm
list views) push the strip off screen. that is the next PR on this
ticket.
- pylon chat window offset is not here.. needs a pylon enabled tenant to
verify so it
  goes with the right side actions ticket.
2026-09-25 12:04:21 +00:00
praneeth-signoz
3b6becff7a chore(tests): split alerts test suite as per domain boundaries (#12983)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Segregated alert-related test suite in to alert manager and ruler as per
their domain boundaries.

---------

Co-authored-by: Praneeth Lingam <praneethlingam@Ollys-MacBook-Pro.local>
2026-09-25 07:30:53 +00:00
Aditya Singh
ab715533b9 feat(saved-views): read saved views from the v2 api on home, noz and column sync (#12970)
#### Description
- moved the home saved views widget, noz open saved view and the saved
view column/format sync (`usePreferenceSync`) from
`/api/v1/explorer/views` to `/api/v2/saved_views`.. generated client and
DTOs used as is, no adapter. labels read `spec.displayName`, columns
`spec.selectedFields`, formatting `spec.display`.
- small `container/SavedViews/utils.ts` for the two things every v2
consumer needs.. shaping the v2 spec for the existing v5 reverse mapper,
and the `DataSource` → api source map. rest of the saved views hooks
come with the sidebar work.
- explorer bottom bar, the `/saved-views` pages and `ExplorerCard` stay
on v1 on purpose.. they get deleted with the bottom strip work, no point
migrating something with a death date. v1 and v2 run in parallel till
then.
- home widget drops the tags badges (nothing ever wrote tags) and the
extra lookup on click. functionalities kept same.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/6095
Part of https://github.com/SigNoz/engineering-pod/issues/5918

#### Additional Information
- traces view with no saved columns now falls back to the typed
`defaultTraceSelectedColumns` (what the loader uses) instead of the
string list from `ListView/configs`.. old one was strings in a
`TelemetryFieldKey[]` hidden by `JSON.parse`.
- `viewName` is still written to the url on open so the old bar shows
the view as selected.. goes away when the bar does.
- noz open view could not be tested locally, covered by unit tests only.
- home storybook mocks regenerated for the v2 endpoint.
2026-09-25 04:37:54 +00:00
Vinicius Lourenço
8e2da68fc6 test(api-monitoring): mock /fields/keys for quick filters settings stories (#12980)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Add missing mocks for stories on api monitoring after
https://github.com/SigNoz/signoz/pull/12968
2026-09-24 14:24:56 +00:00
Nityananda Gohain
8371a70801 perf(querybuilder): compare materialized exists columns explicitly (#12978)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Materialized existence checks now render as an explicit comparison
instead of a bare bool column. Results are unchanged; only skip-index
usage improves.

  ```sql
  -- before
  WHERE `attribute_string_gen_ai$$request$$model_exists`
     OR `attribute_string_gen_ai$$provider$$name` = 'anthropic'

  -- after
  WHERE `attribute_string_gen_ai$$request$$model_exists` = true
     OR `attribute_string_gen_ai$$provider$$name` = 'anthropic'
  ```

  <details>
<summary>EXPLAIN indexes = 1 (trace-matching phase, 123M
spans)</summary>

  Before: bare `col_exists`
  ```
  Name: idx_gen_ai_span_exists
  Granules: 15193/15193
  Name: <Combined skip indexes>
  Granules: 15193/15193
  ```

  After: `col_exists = true`
  ```
  Name: idx_gen_ai_span_exists
  Granules: 15193/15193
  Name: <Combined skip indexes>
  Granules: 488/15193
  ```
  </details>

----
- ClickHouse can use a different skip index for each side of an OR and
union the results, but it can't when one side is a bare bool column.
Comparing with `= true` fixes that.
- This shape comes from the AI explorer trace list with a span filter: a
trace qualifies when it has a gen_ai span *and* a span matching the
filter (possibly different spans), so the WHERE is `(gen_ai gate) OR
<filter>` followed by a HAVING.
- Needs the gen_ai materialized columns and `idx_gen_ai_span_exists`
from SigNoz/signoz-otel-collector#929; without them there's no index to
combine.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/282

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- Benchmarked the AI trace list filtered on `gen_ai.provider.name`
against a 123M-span table (direct I/O, caches off): from ~30M spans in
the window, latency drops 16–17% and CPU 35–38%, with ~25x fewer rows
read (123M spans: 510 → 427 ms, 1.5 → 0.9 sCPU). The saved time and CPU
keep growing with span count, so larger windows save more.
- Single-condition filters (`gen_ai.request.model EXISTS` in dashboard
panels, the AND-ed gate in AI aggregations) already pruned with the bare
form; no change there.
2026-09-24 13:15:32 +00:00
Naman Verma
9d9b0e194a chore: add ability to mark API stability as beta/alpha (#12957)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

If an API that is already deployed is currently being tested via UI
integration or any other means, we should mark such APIs as under
development so that other external clients know that these APIs aren't
fully stable. This is especially required if we are working on v2
versions of APIs for any entity.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Part of https://github.com/SigNoz/pulse-pod/issues/369

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

This PR adds the development flag on the v2 notification channel APIs

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-24 12:19:02 +00:00
Aditya Singh
2a7f4fd603 test(quick-filters): add settings-with-banner stories for every filters page (#12968)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- the quick filters settings panel is sized from the filters pane, not
the viewport. the case that broke was a banner shortening the layout,
which pushed the Save changes footer off screen.. so every page with
settings now has a story for exactly that.
- each new story opens settings and removes a filter first, that is what
puts the footer on screen. same play sequence as the existing dirty
story so the two are comparable side by side.
- external apis and cost meter had no settings story at all, they get
the plain and dirty ones too. cost meter's settings live on the explorer
tab so its stories start there.
- `banner` is already a global control on the app shell mocks, so no
mock changes anywhere.. the stories just turn it on.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5978

#### Additional Information

- covers logs, traces, exceptions, ai observability, external apis and
cost meter.
- the play functions have not been run here, playwright's browser is not
installed on my machine. external apis and cost meter are the ones worth
checking first since they never had a settings story.
- cc. @H4ad
2026-09-24 08:54:54 +00:00
Nikhil Mantri
ee35fc351f feat(alerts): New list API for alert rules (powers filters, sorting, pagination) (#12780)
#### Description

- New `GET /api/v3/rules` list API for alert rules: filter query DSL,
`states` filter, sort, and offset pagination (design discussion:
SigNoz/pulse-pod#324).
- Based on #12806, which extracts the shared list filter SQL compiler;
this PR adds only the rules key-policy resolver
(`sqlrulestore/filterquery_resolver.go`) on top of it.
- Rule state lives only in the rule manager's memory, so state
filtering, total, sort and pagination run in code after the SQL fetch;
total always equals what is pageable.
- Sorting is deterministic on ties: equal rows break on name then id,
always ascending, so pages never overlap or drop rows between requests.
- Response rows carry only list-page fields, deliberately excluding
`condition`, `annotations` and `notificationSettings`. The envelope also
returns the org's distinct label pairs and the reserved filter keys for
suggestions.
- Also guards previously unlocked reads of the rules map
(`ListRuleStates`, `GetRule`, `TriggeredAlerts`).

**Filter keys and operators**

| Key | Operators | Notes |
|---|---|---|
| `name`, `created_by`, `updated_by` | `=`, `!=`, `CONTAINS`, `LIKE`,
`ILIKE`, `IN` and negations | string search |
| `labels.<key>` | string operators plus `EXISTS`, `NOT EXISTS` |
missing label evaluates as empty string; keys are case-sensitive |
| `severity` | same as `labels.<key>` | alias for `labels.severity` |
| `created_at`, `updated_at` | `=`, `!=`, `<`, `<=`, `>`, `>=`,
`BETWEEN`, `NOT BETWEEN` | quoted RFC3339 values |
| `alert_type` | `=`, `!=`, `IN`, `NOT IN` | enum: `METRIC_BASED_ALERT`,
`TRACES_BASED_ALERT`, `LOGS_BASED_ALERT`, `EXCEPTIONS_BASED_ALERT` |
| `rule_type` | `=`, `!=`, `IN`, `NOT IN` | enum: `threshold_rule`,
`promql_rule`, `anomaly_rule` |

- A bare word is free text: a case-insensitive substring match over
name, description and labels.
- `state` is not a DSL key. It is the repeated `states=` query param:
`firing`, `pending`, `recovering`, `inactive`, `nodata`, `disabled`.
- An unknown key or `REGEXP` returns a 400.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#226

#### Additional Information

- A missing label evaluates as the empty string for every value
operator, one uniform rule instead of the querier's per-operator split
([`AddDefaultExistsFilter`](https://github.com/SigNoz/signoz/blob/e0da06f76d/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go#L160));
presence is asked with `EXISTS` / `NOT EXISTS`.
- Integration tests
(`tests/integration/tests/alerts/06_list_rules_v3.py`) cover filters,
states, sorting, pagination, totals and the error contract, run against
both sqlite and postgres.
- Found while testing: the stock `create_notification_channel` fixture
teardown silently fails and leaks channels; follow-up fix needed.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-09-24 07:16:12 +00:00
Aditya Singh
720810d424 fix(quick-filters): filter sidebar scrolls the whole page (#12915)
#### Description

- Quick filters sidebar scrolls the whole page instead of scrolling
itself, so the top nav, module tabs and the table scroll away with it.
Happens on traces, llm observability, api monitoring, exceptions and
meter.. logs is the only page behaving today.
- Cause is antd Tabs.. it never passes height down to the tab pane, and
every module page wrapped `RouteTab` in a plain div, so the page inside
was never bounded. Pages that wanted their own scroll each kept a
private copy of the same `.ant-tabs` override, traces and meter never
had one.
- `RouteTab` now owns the height chain and scrolls each pane's content,
so the tab bar stays put on every tabbed page. Module pages pass their
class to `RouteTab` instead of wrapping it, and the six copied overrides
are gone.
- New `QuickFiltersLayout` gives the explorer pages a bounded two pane
layout.. 280px sidebar that scrolls itself, content that scrolls itself.
Traces, llm, api monitoring, exceptions and meter are on it now.
- Logs and infra are unchanged here, both move to the shared layout in
follow ups.
- Also drops the per page viewport heights on the quick filter settings
drawer.. with the pane bounded, `height: 100%` is enough, and the
save/discard footer stays reachable with the banners on.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/6088
Closes https://github.com/SigNoz/engineering-pod/issues/6104
Part of https://github.com/SigNoz/engineering-pod/issues/5946


#### Screenshots/ recordings



Banner fix [BEFORE]


https://github.com/user-attachments/assets/44bc98f8-7ce9-45a7-9c45-e86648c40f69

Banner fix [AFTER]



https://github.com/user-attachments/assets/c1140a2a-f00e-4685-87e4-4a0f5d72623a



Quick Filter Whole Page scroll Fix
[BEFORE]



https://github.com/user-attachments/assets/07bd42a0-db80-4d14-bf8a-bcea01fb70b1




[AFTER]




https://github.com/user-attachments/assets/7bd14951-2595-43cd-8eab-dcb57cdce011





#### Additional Information

- The `RouteTab` change touches every tabbed page, not just the quick
filter ones. Checked traces, logs, exceptions, api monitoring, meter,
infra hosts, metrics summary, funnels, saved views, pipelines, mq, logs
settings, settings (org + members) and alert details, with the trial
banner on and off. llm observability is feature gated on my instance so
it is not checked in the browser.
- Behaviour change to call out: top nav and tab bar are now fixed on all
tabbed pages. Pages that mount `RouteTab` inside a block wrapper (alert
details, the exceptions inner tabs) are unaffected, the scroller is
inert there.
- Sidebar is 280px everywhere now, was a 260/280 mix.
- Pane content that needs a bounded box should size with `height:
100%`.. `flex: 1` does nothing inside the scroller viewport (documented
on `RouteTab`).
- cc. @H4ad
2026-09-24 06:01:23 +00:00
Ashwin Bhatkal
7424885a14 chore(codeowners): add alert code ownership to pulse-frontend (#12977)
#### Description

Paths that belong to alerts and notification channels added to
CODEOWNERS

- `container/FormAlertChannels/`
- `hooks/notificationChannels/`
- `container/RoutingPolicies/`
- `components/AlertBreadcrumb/`
- `container/EditRules/`
- `components/AlertDetailsFilters/`
- `components/Alerts/`
- `hooks/routingPolicies/`
- `types/api/alerts/`
- `providers/Alert.tsx`
- `constants/alerts.ts`

All go to `@SigNoz/pulse-frontend`, matching the rest of those blocks.
2026-09-24 04:29:31 +00:00
Abhi kumar
2c09fedde1 fix(dashboard): panel UX fixes — bar gap, tooltip date, palette, legend search (#12959)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Four independent panel-UX fixes from the dashboards epic, one commit
each.

- **Bar gap.** The gap between bars was wider than half a bar. The bar
width factor goes 0.6 → 0.85, leaving just enough to separate them.
Shared by bar and histogram panels.
- **Tooltip date.** A tooltip on a point from today now shows only the
time; the date still appears for any other day.
- **Red in the palette.** Roughly one series in six was being coloured
red — 18 of 117 entries in `chartcolors`, 15 of 74 in `lightModeColor` —
which spends the one colour that should mean "something is wrong". Each
red entry is rotated onto a free hue with its original lightness
preserved and saturation clamped, and its key renamed to match. Entries
are replaced, never removed: `generateColor` indexes by `hash %
Object.keys(...).length`, so changing the count would recolour every
existing chart. Pinks and magentas are left alone.
- **Bottom legend search.** A bottom legend that overflows the rows the
panel reserves for it now gets a search box and a "Showing N of M
series" readout, on a single row above the series. Whether that row
exists is the chart layout's call, since it is the layout that reserves
the height — a row nobody reserved would eat a row of series. One
`LegendToolbar` serves both placements, with position driving the layout
only, so the right column is unchanged. The readout counts the rows the
search left listed, against the whole series set.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/3959
Closes https://github.com/SigNoz/engineering-pod/issues/3967
Closes https://github.com/SigNoz/engineering-pod/issues/3976
Closes https://github.com/SigNoz/engineering-pod/issues/3977

#### Additional Information

- The palette replacements are computed (even spread across the non-red
arc), not hand-designed — worth a visual pass across light and dark
before merging.
- Two tests asserted a palette hex for a given series label and are
updated to the new value.
- The search row's 24px height and 4px gap are pinned as
`LEGEND_TOOLBAR_HEIGHT` / `LEGEND_TOOLBAR_GAP` beside the existing row
constants; they must match the stylesheet or the reserved rectangle
clips a row.

<img width="1611" height="634" alt="image"
src="https://github.com/user-attachments/assets/2c21a458-e142-4e4f-baff-5bbcbb072140"
/>
2026-09-23 18:58:55 +00:00
Nityananda Gohain
5a1be60745 fix(ai-o11y): scope overview dashboard to gen_ai spans and move message attributes (#12967)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Top span names and Cost by service use `builder_ai_query`, so non-AI
spans no longer show up.
- LLM cost/token panels filter on `gen_ai.request.model EXISTS`; with
variables on "All" they scanned every span.
- Default span mappers now move (not copy) vendor message keys into
`gen_ai.input.messages` / `gen_ai.output.messages`, as documented.
- Bumped versions: dashboard to 3, `gen_ai.llm` mapper to 3,
`gen_ai.agent` mapper to 2.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 14:56:04 +00:00
Gaurav Tewari
cad93a8063 chore: remove beta tag class (#12965)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Removed beta tag class from AI o11y . 
since it remove margin and breaks alignment. don't see any use of it .
it also shifts the pin icon a bit

before - 
<img width="149" height="160" alt="image"
src="https://github.com/user-attachments/assets/26770802-b5b0-452b-b66a-fecda0d49c38"
/>
 
 
<img width="331" height="152" alt="image"
src="https://github.com/user-attachments/assets/fba73ebe-2655-4de5-8488-eb6bb0bfebca"
/>


 now - 
 
<img width="158" height="192" alt="image"
src="https://github.com/user-attachments/assets/fb0ca483-e8db-4ff0-bb36-e0186c2e9458"
/>

<img width="272" height="63" alt="image"
src="https://github.com/user-attachments/assets/68486c3f-a704-4077-82e5-089452abf773"
/>

 



it was added for long text .
https://github.com/SigNoz/signoz/pull/5801/changes#r1736497688
for now in side nav we don't have any long text. and maybe we could have
handled this better
 

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 11:45:33 +00:00
Abhi kumar
aed096bf27 refactor(query-builder): compose the panel-type field map instead of listing it (#12781)
#### Description

`panelTypeDataSourceFormValuesMap` — the map deciding which builder
fields survive a panel-type switch — spelled out all 21 panel-type ×
data-source combinations as literal field lists, 435 lines of them.

They reduce to seven distinct sets:

- logs and traces carry **identical** fields for every panel type
- metrics adds its two aggregation steps (`timeAggregation`,
`spaceAggregation`)
- every panel type is one of four query shapes: series, scalar table,
single value, raw rows

Much of the apparent variation was ordering noise — a bar chart and a
table on logs have the *same* field set, listed in a different order.

Composed from those rules it's 84 lines, and the policy is legible at a
glance: charts, table and pie share a surface; table and pie differ only
by `reduceTo` on metrics; a single value has nothing to group, limit or
order; raw rows carry no aggregation. Two asymmetries that were buried
in the literals are now called out where they're decided, rather than
silently reproduced.

**No behaviour change.** Adding a panel type becomes one line — "which
shape is it?"

#### Additional Information

- Equivalence was checked cell by cell against the previous literal
table before it was removed; all 21 cells matched as sets. The old table
is in git history at `main:frontend/src/lib/query/panelQuery.ts` if you
want to re-run that comparison.
- The specs pin the **rules**, not the values, so they fail when a rule
changes — the moment to stop and decide — rather than whenever a field
moves. Two are worth reading:
- *"gives bar / histogram / table / pie the same non-metrics fields as a
time series"* states the hazard composing introduces: the aggregating
types share one field list, so an edit meant for charts reaches table
and pie too. A failure there names the reason.
- *"gives every cell its own array instance"* — the `QueryBuilder`
provider does `propsRequired?.push('dataSource')` on the list it reads
from this map, so cells sharing an instance would leak fields into each
other. My first draft shared one array across 10 cells; this test is
what guards it.
- Order is not asserted anywhere: `handleQueryChange` and the provider
both assign each field independently via `set()`, so sequence carries no
meaning.
- Three consumers, all exercised: `handleQueryChange` (dashboards v2's
kind switcher and V1's `PanelTypeSelector`) and the shared
`QueryBuilder` provider every explorer uses. Verified with `tsgo`,
`oxlint`, and the `lib` / `providers` / `WidgetCard` / Logs+Traces
explorer / `DashboardPage` suites: 243 suites, 2056 tests.
- Pre-existing and deliberately left alone: that `push` mutates module
state, so the arrays grow by one `'dataSource'` entry on every
query-builder change. Harmless today only because the assignment is
idempotent; `[...propsRequired, 'dataSource']` would fix it, but that's
the provider's bug, not this map's.
2026-09-23 11:24:51 +00:00
Nityananda Gohain
6b66ab64c8 fix: add ai-o11y quick filter migration (#12964)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Added migration to update old instances where quick filters for ai-o11y
is not present.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 11:15:42 +00:00
Naman Verma
362d3a4fdf fix: backfill notification channel tuples (#12960)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
cacheci / tests (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Same migration logic as number 128. Needed for enterprise servers as
these tuples decide whether a role may create, list, read, update or
delete channels at all. An existing org with zero channels still needs
them, otherwise its admin can't create the first one or even list the
empty page.
2026-09-23 09:48:11 +00:00
Jugal Kishore
ff80758e1c feat(onboarding): add Cursor, Cline, vLLM, SGLang, Karpenter datasources (#12944)
#### Description

- Adds 20 data sources to the onboarding picker: Cursor, Antigravity
CLI, Qwen Code, DeepSeek Harness, Meta Muse Code, Meta Muse Spark,
OpenAI Agents SDK, Cline, E2B Sandbox, Daytona Sandbox, Vercel Sandbox,
Modal, vLLM, SGLang, Karpenter, Podman, EMQX, AWS RDS Aurora, GCP Cloud
Storage, and AWS Lambda MicroVMs.
- Go now asks for an instrumentation method (SDK / compile-time `otelc`
/ eBPF) before the environment question, so the new zero-code guides are
reachable. The card links to the new comparison overview.
- The Lambda → Traces question now asks how the function is packaged
instead of which runtime it uses, and gains a Container Image option.
Layers do not attach to container images, so runtime alone did not pick
the right guide.
- GCP Cloud Storage points at `/integrations/gcp?service=cloudstorage`
rather than a doc, matching the Cloud SQL and Memorystore cards.
`cloudstorage` is a first-class cloud integration with its own
dashboard, so the in-app flow is the one users want.
- New logos: `antigravity`, `aurora`, `cline`, `cursor`, `daytona`,
`e2b`, `emqx`, `karpenter`, `meta`, `modal`, `podman`, `sglang`, `vllm`.
Qwen Code, OpenAI Agents SDK and Lambda MicroVMs reuse existing marks.
- Recolours 15 pre-existing logos that were pure white or near-black and
disappeared against one of the two card backgrounds (`--l2-background`
is `#121317` dark, `#F9F9FB` light): `anthropic-api-monitoring`,
`clickhouse`, `confluent-kafka`, `datadog`, `deno`, `document-load`,
`from-log-file`, `haystack`, `kafka`, `langchain`, `ollama`, `openai`,
`openrouter`, `vercel`, `zap`. Brand hue kept where the brand has one,
adjusted to clear 3:1 against both; black-or-white marks use a neutral
grey.

#### Issues closed by this PR

Closes SigNoz/signoz.io#4205
Closes SigNoz/signoz.io#4197
Closes SigNoz/signoz.io#4178
Closes SigNoz/signoz.io#4171
Closes SigNoz/signoz.io#4167
Closes SigNoz/signoz.io#4153
Closes SigNoz/signoz.io#4144
Closes SigNoz/signoz.io#4133
Closes SigNoz/signoz.io#4131
Closes SigNoz/signoz.io#4129
Closes SigNoz/signoz.io#4125
Closes SigNoz/signoz.io#4108
Closes SigNoz/signoz.io#4092
Closes SigNoz/signoz.io#4088
Closes SigNoz/signoz.io#4082
Closes SigNoz/signoz.io#4074
Closes SigNoz/signoz.io#4063
Closes SigNoz/signoz.io#4032
Closes SigNoz/signoz.io#4024
2026-09-23 07:54:02 +00:00
Vinicius Lourenço
f9d425bf5c fix(storybook): remove timing races from story tests (#12954)
#### Description

A few tweaks to improve the resiliency of the storybook tests under CI
stress.
2026-09-23 07:46:27 +00:00
Abhi kumar
7ce73f3470 fix(dashboard): don't re-run an errored panel query on scroll back into view (#12958)
#### Description

- Lazy-loaded panels toggle `enabled` on viewport visibility.
react-query treats a key with no data as stale regardless of
`staleTime`, so an errored panel refetched (with retries on 5xx) every
time it scrolled back into view.
- `useGetQueryRangeV5` now keeps an errored key enabled, so only a key
change (time, variables, query) or the Retry button re-runs it. A new
key still stays gated while off-screen.
- Adds a `useGetQueryRangeV5` test suite covering the gating paths.
2026-09-23 07:04:51 +00:00
Nityananda Gohain
5aca8b0d3c chore: remove ai-o11y ff (#12947)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Remove ai-o11y FF and enable it by default

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 06:39:16 +00:00
Gaurav Tewari
f1c9e0f1d0 feat(llm-observability): add ai o11y analytics events (#12952)
#### Description

- Renames AI Observability explorer events from `Traces Explorer: *` to
`AI Observability Explorer: *`, so they no longer mix with the regular
Traces Explorer events.
- Adds page-visit events for Overview, Attribute Mapping and Model
Pricing.
- Adds action events: attribute mapping saved and test run; model cost
saved and deleted; unpriced model mapped.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 05:05:06 +00:00
Gaurav Tewari
10c0af327b fix: failing e2e for llm (#12953)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Attribute-mapping e2e now adds a condition key when creating its
group.
- Since #12809 the backend rejects groups without conditions (`400
condition must list at least one attribute or resource substring`), so
the spec failed on save.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Follow-up: we should add check on frontend as well for #12809 ( we
have already decided to add this later )

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 04:43:36 +00:00
Abhi kumar
370b278f28 fix(dashboard): reserve legend rows the grid actually lays out (#12951)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Bottom legends could silently drop series. The legend box reserved
fewer rows than the grid actually laid out, and the surplus row was
clipped away by the wrapper's `overflow: hidden` — nothing indicated the
series were still there apart from a scrollbar.
- The cause is two different formulas for the same quantity: how many
legend items fit on one row. The height reservation in
`calculateChartDimensions` used `floor((containerWidth - padding) /
itemWidth)`. The grid resolves `auto-fill` over `--legend-item-width`,
which is `itemWidth + LEGEND_ITEM_EXTRA_WIDTH`, separated by a column
gap, inside a scroller with its own gutter. Ignoring the extra width,
the gap and the gutter, the reservation over-counts and reserves one row
where the grid needs two.
- The two formulas only diverge over a narrow band of widths, so the
failure is width-dependent and its boundary is a single pixel. A panel
sitting near that boundary flips between states as the layout reflows,
which is seen as flickering rather than as a fixed layout bug.
- Fix: `legendItemsPerRow` now mirrors the `auto-fill` track count. The
two CSS values it depends on are pinned as constants beside the existing
`LEGEND_ROW_HEIGHT` / `LEGEND_ROW_GAP`, which already carry the same
"must match the stylesheet" caveat.

#### Additional Information

- Adds a regression test at a width where the two formulas diverge; it
fails on `main`.
- Two pre-existing gaps left out of scope and unchanged by this PR:
- `MAX_SHORT_PANEL_LEGEND_RATIO` deliberately reserves a single row on
very short panels while the grid still lays out two, so the clip remains
there. Closing it needs somewhere for the dropped row's series to go —
an overflow affordance, which is a design decision.
2026-09-23 02:22:18 +00:00
Pandey
f2229a1064 fix(analytics): format segment logger messages before passing to slog (#12950)
#### Description

- segment's `Logger` interface is printf-style, but the adapter passed
`format` as the slog message and `args` as key-value pairs.
- slog never substituted the `%d` placeholders and rendered each
positional arg as a `!BADKEY` attr.
- `Logf` and `Errorf` now `fmt.Sprintf` the message first, matching the
opamp logger adapter.
2026-09-22 19:18:36 +00:00
Vinicius Lourenço
099832b26b chore(codeowners): change ownership of storybook (#12949)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

Add myself as owner of storybook structural files, the stories still
belongs to each pod.
2026-09-22 18:04:23 +00:00
Ashwin Bhatkal
057571cf6d fix(dashboard): restore related values and API search in dynamic variable dropdowns (#12935)
#### Description

The V1 to V2 dashboard rewrite carried over the *request* for a dynamic
variable's values but not the *response* handling — `relatedValues` and
`complete` were fetched and then thrown away. Both issues below are that
single regression.

- **Related values.** The dropdown now splits a dynamic variable's
values into "Related Values" (scoped by the sibling dynamic variables'
selections) and "All Values", as V1 did. The `existingQuery` that scopes
them was already being sent; only the response was ignored. Worth
knowing while reviewing: the backend never narrows the main list by
`existingQuery` — `GetAllValues` doesn't see it, and `GetRelatedValues`
returns nothing when it is empty — so the scoping is only ever visible
as the second section.
- **Value search.** A variable whose list the backend truncated
(`complete: false`) could only be filtered against the values already
fetched, so typing anything outside that first batch found nothing.
Search now goes to the API. It runs on its own react-query, deliberately
not the fetch engine's, so a keystroke cannot settle the variable's
fetch cycle and re-cascade its dependent variables and panels.
- **Retry action.** Restores V1's gating: the shared select defaults
`showRetryButton` to `true`, so a 4xx offered a retry that could only
fail again.

Commits are split by concern in that order.

#### Screen Recording


https://github.com/user-attachments/assets/ef51f481-de66-4334-9a59-dc98a7c7e50f

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/352
Closes https://github.com/SigNoz/pulse-pod/issues/249
2026-09-22 17:31:51 +00:00
Vinicius Lourenço
ccb6ef68c1 feat(storybook): add stories for each existing page (#12734)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### How to review this PR

You can pretty much ignore anything inside `<parent>/stories` folder
since this was generated by AI and should be maintained by AI. If you
want to suggest a change in a specific story, let's have a follow-up for
it.

You should focus to review the non-essential files that were changed,
such as the skills or storybook files.

#### Description

This adds stories for all reachable pages in the app, with few
variations in the state of the page.

This can be used as ground-work to later each team add more
customizations/states for their controlled pages, to ensure we are
covering all the states available and having a good coverage during
visual testing.

Closes https://github.com/SigNoz/engineering-pod/issues/6093

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<img width="1867" height="1268" alt="image"
src="https://github.com/user-attachments/assets/78f2f10e-e8bb-4353-a78b-1a88df4bb545"
/>
2026-09-22 16:50:00 +00:00
Gaurav Tewari
c6f448409a fix: AI o11y ui testing bug bash (#12933)
## Description

this PR fixes multiple things we wanted to polish on llm o11y. 

- **fix: test json being send** — have fixed a bug. where if we add an
extra key other then attribute / resource we were sending the entire
json. we should still breakdown the json and send them in attribute and
resources key and skip extra feilds.


https://github.com/user-attachments/assets/5e45883a-9d01-49cc-b996-43628df0cb2c




- **fix: create new mapping color** — "Create pricing for X" becomes a
styled sticky footer item labelled "Create a new pricing model" in the
map-to-billing-model combobox.

prev - 

<img width="387" height="225" alt="image"
src="https://github.com/user-attachments/assets/e608c8b9-092e-44eb-b286-5fa81b304eef"
/>

now -
<img width="447" height="406" alt="image"
src="https://github.com/user-attachments/assets/830a213a-285e-4f02-9871-a040e5c1731a"
/>

- **chore: make label of add model cost look same** — Shared label
styles extracted into `shared.module.scss` so every field in the model
cost drawer renders its label identically.

- **chore: migrate tabs in model pricing & attribute mapping to antD
tabs** — Attribute mapping and model pricing now use antD tabs;
`AttributeMappingHeader` is replaced by `AttributeMappingActions`
rendered as tab bar extras.
before - 
<img width="1679" height="493" alt="image"
src="https://github.com/user-attachments/assets/af446ef4-ff25-4f5a-8c81-fb05ba8f2114"
/>

now - 
<img width="1675" height="563" alt="image"
src="https://github.com/user-attachments/assets/4e3a3382-8bcd-4e95-ada5-e04c93765cf1"
/>



- **chore: removed unused padding in mapping tabel** — Drops a stray
padding rule from the mappings table.
- **chore: update colors for drawers inputs** — Aligns input/border
colors across the group, mapper and model cost drawers.
before - 

new - 
<img width="566" height="1001" alt="image"
src="https://github.com/user-attachments/assets/7b5b614b-68bc-4699-9251-1e7c08f527ff"
/>
filled  & disabled - 
<img width="681" height="1032" alt="image"
src="https://github.com/user-attachments/assets/811a5dfe-ee46-4ef3-b94b-e604bc976ca1"
/>

new group - 
<img width="732" height="1008" alt="image"
src="https://github.com/user-attachments/assets/26544ce6-17d1-4502-8b78-b97c4b377be9"
/>

filled group - 
<img width="725" height="1000" alt="image"
src="https://github.com/user-attachments/assets/148125e8-230d-454d-a383-10289726e9cf"
/>

now - 

new - 
<img width="692" height="1019" alt="image"
src="https://github.com/user-attachments/assets/fcd4b817-7b3a-4ed2-8702-3f1e651570f8"
/>
filled & disabled - 
<img width="664" height="989" alt="image"
src="https://github.com/user-attachments/assets/7152a918-73d1-45a0-a5d0-f61ba9dcf560"
/>

video would be better - 


https://github.com/user-attachments/assets/0c72f829-71bb-4c19-a806-c61a98f625c3



- **chore: allow users to move trace id** — Trace ID column stays
non-removable and non-hideable but is now draggable.
prev - 
<img width="1490" height="975" alt="image"
src="https://github.com/user-attachments/assets/b0c26483-bf75-4db3-aca5-3be05ca1341a"
/>

now - 
<img width="1654" height="951" alt="image"
src="https://github.com/user-attachments/assets/c26dfbab-0c2f-4fc6-bad4-5595cb67d6ab"
/>


## Issues closed by this PR


https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=252008677&issue=SigNoz%7Cengineering-pod%7C6111

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-22 14:53:22 +00:00
Yunus M
6820fbd091 chore: add scaffolding tool for feature structure in frontend (#12669)
#### Description

Script to help scaffold the co-located feature structure in
frontend/src. Use when creating a new page, feature, view (tab), or
component folder, when a feature needs a shell with tabs, or when moving
existing code out of src/container into src/pages. Generates the full
folder tree
(components/hooks/store/types/utils/constants/__tests__/README) with one
command.

```
pnpm scaffold page ApiMonitoring                                    # leaf page, no shell
pnpm scaffold page Traces --views Explorer,Funnels,Views            # shell + 3 views
pnpm scaffold page Traces/Explorer                                  # one more view under an existing shell
pnpm scaffold component DataTable                                   # global, src/components/DataTable
pnpm scaffold component QueryBar --parent pages/Traces/Explorer     # feature-local component
```


https://app.notion.com/p/signoz/TDD-Feature-First-Frontend-Architecture-3aefcc6bcd198016a2a1fede8ba17715?source=copy_link#3aefcc6bcd1981b5aa91e579b8835bf5

Ex:
<img width="1359" height="759" alt="Screenshot 2026-08-24 at 15 59 32"
src="https://github.com/user-attachments/assets/33de7728-0363-4c2b-89ac-065184476561"
/>




https://github.com/user-attachments/assets/694dcf70-fac6-424e-a00f-e3a0af2f7875
2026-09-22 13:27:57 +00:00
Vikrant Gupta
e2e9173986 fix(user): revoke sessions when a user is deleted (#12943)
#### Description

- `DeleteUser` never told the tokenizer about the deletion.
`SoftDeleteUser` removed the `auth_token` rows with raw SQL, so the
opaque tokenizer kept serving the deleted user's session from cache
until rotation forced a DB read, up to `rotation.interval` later.
- The tokenizer eviction now runs before the soft delete, inside one
transaction; `SoftDeleteUser` joins the caller's transaction instead of
opening its own.
- The hourly last-observed-at flush returned an error for any org with
nothing to flush because bun rejects an empty `VALUES` slice. It now
returns early.
- Adds an integration test asserting a deleted user's held token is
rejected on the next request.

#### Additional Information

Only affects the opaque tokenizer; under the JWT tokenizer
`DeleteTokensByUserID` is a no-op.
2026-09-22 12:14:51 +00:00
Nityananda Gohain
905e935658 fix: use db upsert for model pricing (#12942)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Each pricing rule ran a SELECT then an INSERT or UPDATE. Rules are now
written with `INSERT ... ON CONFLICT DO UPDATE`, two statements per
request at most.
- Rules without `isOverride` match on `source_id` and skip rows the user
has overridden. Rules with it match on `id`.
- Dropped the `default:` bun tags. bun turns zero values into SQL
`DEFAULT` on insert, so a rule created disabled was stored as enabled.
- Added an integration suite for sync, override, hand-back and bulk
writes.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-22 11:15:20 +00:00
Vikrant Gupta
e8324581b3 fix(tokenizer): accept the previous token pair only within the rotation duration (#12941)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `Token.Rotate` accepted the previous token pair only when the rotation
was older than `rotation.duration` and rejected it inside the window,
the inverse of the documented intent.
- With the opaque tokenizer, two holders of the same pair (browser tabs,
the axios interceptor and the SSE wrapper) racing at the rotation
boundary meant the loser got 401 on `/sessions/rotate` and the frontend
logged the user out. It also left a stale pair exchangeable for the live
session until the next rotation.
- `RotateToken` now detects that `Rotate` left the stored row untouched
and returns the current pair without rewriting it; the previous check
compared against the input and could never match.
- Adds a unit test for the previous-pair path inside and outside the
window.

#### Additional Information

The JWT tokenizer is unaffected since its rotation is stateless.
2026-09-22 09:58:52 +00:00
Naman Verma
13a57ebb9c chore: remove v1 dashboards code from backend (#12932)
<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/321
2026-09-22 08:46:28 +00:00
Aditya Singh
d39467f5ef chore(logs): delete the old logs explorer (#12930)
#### Description

- `/logs/old-logs-explorer` has been rendering nothing since #8299
gutted its two endpoints. deleted the page along with its containers,
the logs redux slice, the legacy `api/logs` clients and the logql
parser. `LogViewMode` and the restricted field constants move out first
since they have consumers elsewhere.
- deleted what the removal orphans.. the logql test fixture,
`CategoryHeading` and the antd table behind `components/Logs/TableView`.
that folder does not go entirely, `useLogsTableColumns` and
`ColumnTypeRender` still have consumers.
- `ROUTES.LOGS` and `ROUTES.LOGS_EXPLORER` are the same path and both
were registered exact. Switch takes the first so the second entry never
ran and its chunk just duplicated the first. dropped the entry, both
constants stay.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6103

#### Additional Information

- frontend only. `GET /api/v1/logs` and `/api/v1/logs/aggregate` stay
registered and still return empty payloads, so this can go in without a
backend change.
- overlaps #12919 (old trace explorer) on the `NewExplorerCTA` cleanup,
and overlaps the log details v1 removal on a few files it deletes
outright. whichever merges second needs a rebase.
- cc. @therealpandey
2026-09-22 04:44:25 +00:00
Gaurav Tewari
d457ce6144 feat(llm-observability): move module tabs to RouteTab , add tab icons & other ui changes (#12762)
#### Description

- Replaces the bespoke LLM Observability tab shell
(`container/LLMObservability/LLMObservability.tsx` +
`useLLMObservabilityTabs`) with the shared `RouteTab` component that
every other module page already uses. Tabs are now declared as
`TabRoutes` in `pages/LLMObservability/constants.tsx`.
- Adds icons to the tab labels across LLM Observability, Infrastructure
Monitoring, All Errors, Messaging Queues, Meter Explorer and Logs
Settings, following the existing `.tab-item` pattern.
- Fixes tab-panel padding and aligns the source-filter select height
with the adjacent search input.
- Removed the dashboardInfoWithActions


#### Additional Information

`AllErrors/config.ts` is renamed to `.tsx` since the tab labels now
contain JSX.

Visual Diff - 
1. Look into the wrapper  - 
 a. Before 
<img width="1474" height="997" alt="image"
src="https://github.com/user-attachments/assets/39fe854b-fa79-4e24-9227-4da77825f726"
/>
b. Now 
<img width="1489" height="991" alt="image"
src="https://github.com/user-attachments/assets/4c02e991-3764-4f66-9efc-bef934ca5e2a"
/>



2. Removed the dashboardInfoWithActions 
a. Before 
<img width="1392" height="304" alt="image"
src="https://github.com/user-attachments/assets/bb2efef8-5126-4728-b221-b2cc2b3da18f"
/>
b. now
<img width="1420" height="290" alt="image"
src="https://github.com/user-attachments/assets/50412047-5eff-4035-ac6c-ed79393070ac"
/>


3. After feedback from nitya we have to hide add variable and the
actions button as well.
a. Before
<img width="1368" height="597" alt="image"
src="https://github.com/user-attachments/assets/669de39b-bbc2-4b29-a16e-139457fbb483"
/>

b. Now
<img width="1382" height="728" alt="image"
src="https://github.com/user-attachments/assets/7973dc3a-9914-42ed-a334-887ea73b1ba0"
/>

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-22 04:44:00 +00:00
Nityananda Gohain
64fff60d7e chore: update dashboard for ai observability (#12875)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
Adds the actual dashboard to the backend.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/4501

### Additional information
- Draft until the dashboard DTO changes merge; the query fixes below
land on top of them.
- will update the dashboard based on ai query builder
2026-09-21 17:57:58 +00:00
Nityananda Gohain
dd3b99f19c fix: update attribute mappings for ai (#12925)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Version 2 of the `gen_ai.llm` and `gen_ai.tool` defaults, fixes only:

- Tool condition narrowed to `tool.name`, `ai.toolCall.`,
`tool_call_args`, `tool_response`; the bare `tool` substring was firing
on LLM spans.
- Dropped the `gen_ai.operation.name` mapper; `llm.request.type`
overwrote native values with non-semconv ones.
- Added `ai.response.toolCalls` as an output messages source for Vercel
tool-call turns.
- Renamed `gen_ai.response.finish_reason` to
`gen_ai.response.finish_reasons`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-21 17:14:18 +00:00
Aditya Singh
2ff7e7d3af test(traces-explorer): flaky "select a view" test on CI (#12931)
#### Description

- `select a view options` in the traces explorer test flakes on CI..
`findByRole` defaults to a 1s timeout and the saved views call has to
land and re-render the dropdown inside that window. locally the option
only shows up at ~550-680ms, so there is barely any headroom and a
loaded runner tips it over.
- bumped just that one query to 5s. the wait sits on the assertion that
actually times out, so nothing else in the test moves and it still fails
loudly if the option stops rendering.
- costs nothing when things are fast.. findByRole resolves the moment
the option appears, the timeout is only a cap. test stays ~1.2s before
and after.

#### Additional Information

- verified with a repro.. delaying the views handler by 1500ms fails
without the change with the same error CI gives, and passes with it.
- opening the dropdown before the views land is not the problem, antd
re-renders the options once the data arrives. so no reordering needed
here.
- the real driver is the `test / js` job at 7-10 min on a single
unsharded runner. a repo-wide `asyncUtilTimeout` default plus sharding
that job is the proper fix for this class of flake, not doing it here.
2026-09-21 16:43:41 +00:00
Aditya Singh
3ecc21377d refactor(traces): delete the old trace explorer code (#12919)
#### Description

- deleted the old trace explorer at `/trace`. its apis
(`/getSpanFilters`, `/getFilteredSpans`, `/getTagFilters`,
`/getTagValues`) were removed from the backend in #6464 so the page has
been 404ing on every request since then. takes `container/Trace`,
`store/actions/trace`, the `traces` redux slice and the dead `api/trace`
clients with it.
- deleted the dead trees that came along with it.. the pre quick filters
`Filter` panel in traces explorer, `GantChart`, `TraceFlameGraph`,
`TraceDetail` and the `/trace-old` redirect. none of these had a real
consumer, they were only compiling because of a stray type import here
and there.
- moved the shared bits out before deleting anything around them..
`Tags`/`OperatorValues`, `getMs`, `StyledCSS` and the live trace detail
helpers. `DurationSection` now sits next to the quick filters duration
renderer since that is its only consumer. `filterUtils` is split, key
catalogue to constants since three unrelated places read it, rest kept
local to the renderer.
- dropped `ROUTES.TRACE`. four places were using it as a string prefix
to build trace detail links, which only worked because `/trace/:id`
shares the prefix. moved them to `generatePath(ROUTES.TRACE_DETAIL, { id
})` like `FieldCell` already does.
- removed `NewExplorerCTA`. with old logs explorer (#12914) and this one
gone it has no route left to render on.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6103

#### Additional Information

- `/trace` and `/trace-old/:id` now fall through to home like any
unknown route. did not add a redirect since that means keeping
`ROUTES.TRACE` around along with its permission and authz test entries.
can add it back if we think bookmarks matter.
- `NEW_ROUTES_MENU_ITEM_KEY_MAP` still has a `'/trace'` key on purpose,
sidebar strips the url to its first segment and that is what highlights
Traces on detail pages.
- could not test four paths on staging.. llm explorer list view, kafka
drop rate links, linked spans and the apm view traces popup. all one
line `generatePath` changes with unit coverage.
- both this and #12914 touch `NewExplorerCTA`, whichever merges second
needs a rebase.
2026-09-21 16:34:29 +00:00
Gaurav Tewari
b071610a27 chore: use endpoint for fetching data (#12926)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Previously we had hardcoded the api data in the Frontend End. now we
have removed it. it's a api from where we fetch data in frontend.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/1b348d4c-c2c1-47c3-b338-2e5b352ea73c



https://github.com/user-attachments/assets/71009d56-0dc8-44b2-9946-0850086ac397





<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-21 14:49:41 +00:00
Swapnil Nakade
41d4818030 feat(authz): enable FGA for cloud integration (#12895)
#### Description
- enabling FGA support for cloud integration end points

#### Issues closed by this PR
Closes: https://github.com/SigNoz/platform-pod/issues/2946

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-09-21 14:20:11 +00:00
Abhi kumar
a2e42df790 fix(charts): stop tooltip labels breaking mid-word (#12894)
#### Description

- Chart tooltip labels were breaking mid-word (`Metric` / `s`) whenever
a row's value was wide. `overflow-wrap: anywhere` drops a flex item's
min-content width to one character, so the label was the only thing in
the row that could give way — the value and the dashed leader kept their
width and squeezed it to nothing.
- The label now uses `overflow-wrap: break-word`, so it breaks a token
only when a line can't hold it whole, and wraps at 3 lines before
ellipsizing. `title` carries the full text.
- Tooltip width floor goes 300 → 360. The width comes from the *legend*
label length, so charts with short series names (billing) got the
narrowest box even though their values are the longest. 360 is already
the upper bound that formula produces, so no tooltip is wider than the
widest one today, and the width still never changes between hovers.

#### Screenshots / Screen Recordings

| Before | After |
| --- | --- |
| Label crushed to ~40px, broken mid-word | Label intact on one line,
value pinned at the row end |

#### Additional Information

Verified in a headless browser at the new width: short labels stay on
one line even with a longer value than the reported case, long series
names wrap at token boundaries with the value still at the end of the
row, and no row overflows horizontally.

Closes https://github.com/SigNoz/pulse-pod/issues/365
2026-09-21 11:57:13 +00:00
Naman Verma
f9928aa4db feat: add plugin schema for area chart panel (#12635)
#### Description

Add a new plugin schema for area chart. This also adds a new enum for
fill mode specific to area charts cuz the default is different wrt time
series chart.

Frontend changes to be built on top of this

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/294
Closes https://github.com/SigNoz/pulse-pod/issues/214

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-09-21 11:14:29 +00:00
Aditya Singh
1c7811c414 refactor(log-details): move infra metrics charts to v5 query_range (#12916)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- moves the metrics tab in log details + span details (same InfraMetrics
component) from v4 to v5 query_range.. pod, node and host charts
- no payload rewrite, the v5 preparer already handles the legacy builder
shape so constants stay as is
- having helper now always emits the v5 expression form and the
`useV5HavingFormat` flag is gone. host payload is shared with Hosts V2
which was already on v5, so both callers line up now
- fixed legends while here.. on v5 the response has per-query metadata
and getLegend was resolving against the explorer's currentQuery, so
CPU/Memory usage showed `count()` instead of the pod name. charts now
pass their own payload query, same as K8s V2 EntityMetrics
- node/pod toggle: re-clicking the pressed pill deselects (radix single
toggle-group emits '') and blanked the tab. ignoring empty values now.
pre-existing, component fix tracked in
https://github.com/SigNoz/components/issues/402

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5807




https://github.com/user-attachments/assets/62826754-e1eb-44b3-af60-50a2b614048d



#### Additional Information

- staging has no bare-vm logs (host.name without k8s.node.name), so the
host chart path from the drawer wasn't hit directly.. verified via Hosts
details which uses the same getHostQueryPayload
- cc. @H4ad
2026-09-21 09:01:08 +00:00
Abhi kumar
1839648e75 fix(legend): isolate legend stacking context (#12924)
#### Description

- Legend row markers carry `z-index: 4`, and nothing between them and
the page root established a stacking context, so they competed with (and
beat) the logs explorer quick filter panel at `z-index: 2` — the colored
markers painted on top of the panel.
- `isolation: isolate` on the legend container keeps the row-level
z-indexes local. The legend is a flex sibling of the plot and its
tooltips portal to body, so nothing relied on them escaping.

Before
<img width="2032" height="1048" alt="image"
src="https://github.com/user-attachments/assets/e2c792b7-930c-477f-aeb5-4867434b46cd"
/>


After
<img width="1336" height="493" alt="image"
src="https://github.com/user-attachments/assets/35a67f89-ea97-4773-b050-2f39321e9468"
/>
2026-09-21 08:07:50 +00:00
Nityananda Gohain
ea8f95ee08 chore: enable ai 011y processors by default (#12912)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Enable the processors by default so that metadata is populated

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
No issue
2026-09-19 13:02:43 +00:00
Nityananda Gohain
b64116d67d feat: support for default attribute mapping (#12809)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Until now every org started with no span mapper groups and we had to
create `llm`, `agent` and `tool` by hand. This PR ships them as
defaults.

- The three groups live as JSON in the binary. On startup and when a new
org is created, they get seeded. When we change a definition and bump
its version, the next release updates the org's copy in place.
- Anything SigNoz ships is marked `origin: system` and can only be
switched on or off. Users can't rename or delete these groups and
mappers, and can't take their names. Anything the user adds is theirs to
edit or remove, including new mappers in a shipped group or new sources
on a shipped mapper.
  - Upgrades keep the user's on/off choices and never touch their items.
- Condition substrings and sources now carry `enabled` and `origin`, so
a substring is an object instead of a plain string. Disabled ones are
left out of the collector config.
- - Migration 127 only adds the `origin` and `version` columns. Stored
JSON is not rewritten because these tables are empty on every instance.
- Fixes creating a group or mapper with `enabled: false` being saved as
true (the bun `default:true` tag turned false into SQL `DEFAULT`)
  
Frontend: no UI changes. Generated client regenerated; drafts carry
`enabled` and `origin` so saves from the existing screens round-trip
shipped items intact.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5329


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
* Frontend follow-up: toggles for sources and substrings, "Default"
badge and read-only rows for shipped items, hide rename/delete on system
groups and mappers.
* Deferred: per-group upgrade changelog ( will come back to this later)

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-19 11:22:46 +00:00
Naman Verma
2068482f66 feat: add api to repair malformed channels created via v1 (#12910)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

No need to write a db migration, notification channels can be repaired
if user asks to repair.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Part of https://github.com/SigNoz/pulse-pod/issues/342
2026-09-18 16:05:41 +00:00
Gaurav Tewari
35973efd65 feat(quick-filters): AI o11y quick filters (#12788)
#### Description

- The AI o11y explorer was reusing the traces explorer's quick filters.
It now uses its own `ai_observability` source and signal, so it loads
the gen_ai filter set.
- Values and settings keys go to
`/api/v1/ai_observability/fields/{values,keys}`, which scope suggestions
to gen_ai spans.


#### Issues closed by this PR
Close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108147&issue=SigNoz%7Cengineering-pod%7C5844

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/f7124648-9919-46e5-9e6d-90bd72f91a50


#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 09:49:13 +00:00
Naman Verma
c65845e525 feat: add more slack configuration opts in notification channels (#12907)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Some common configuration options for a slack notification have been
added.

Also, webhook notification channels can now run without a username,
without a password, or without any auth.
2026-09-18 09:20:19 +00:00
Gaurav Tewari
f6f41df237 feat(ai-observability): Trace view changes (#12796)
#### Description

- Trace is now the explorer's default view (`DEFAULT_PANEL_TYPE`) and
the first toolbar tab.
- `LeftToolbarActions` becomes config-driven: buttons render in the
order the caller declares its views, from a `TOOLBAR_VIEW_CONFIG`
lookup, instead of five hardcoded per-view blocks. That also replaces
the `items: any` prop with a typed `Record<string, ToolbarViewItem>`.
- Fixes a column-init race in the trace view. Rows can land before the
field keys, and mounting then persisted a partial column set as if the
user had chosen it. `useTraceViewColumns` now hands out the column
storage key only once the keys fetch succeeds — every write path in
`useColumnState` no-ops without one, so the key is the write barrier.
Until then the table stays unmounted, the Options control is hidden, and
a render falls back to the default-visible columns with no key attached.
- Removes the saved-views / export-to-dashboard bar from the explorer
and the download menu from the list view — its export path only handles
`PANEL_TYPES.LIST`, so it could not reflect the selected columns.
`getQueryByPanelType` and `getExportQueryData` go with them. Table and
Time Series keep their exports under AI-specific filenames, via a new
opt-in `exportFileName` on the shared `TimeSeriesView` (defaulted, so
existing callers are unchanged).
- List view drops `useOptionsMenu`: columns are the static
`defaultSelectedColumns` (now typed `TelemetryFieldKey[]`) until the
preferences framework lands, and the table keeps its own column order
under `AI_OBSERVABILITY_LIST_COLUMNS` instead of sharing the traces
explorer's.
- `start_time`/`end_time`/`last_activity_time` join
`TIMESTAMP_FIELD_NAMES` and
`trace_duration_nano`/`max_llm_duration_nano` join
`DURATION_FIELD_NAMES`, so the shared `FieldCell` formats the
trace-level columns instead of a separate component.
- The explorer now imports its own forked `aiActions`, `Controls`,
`TracesTable` and list utils rather than reaching into `TracesExplorer`.
- Trims the forked `ListView/utils.tsx` to the two helpers the AI
explorer uses. `getListColumns`, `BlockLink` and `transformDataWithDate`
are antd-era machinery whose only consumer is `TracesTableComponent`,
which still imports them from the untouched original.
- Tests for the trace view, its column hook, and the table's column-init
race.
- Unrelated one-liner: `FieldKeysConfig`/`FieldValuesConfig` now point
at the generic endpoint's param types instead of being a union with the
AI ones. `Omit` over a union keeps only the keys both members share, so
`FieldKeysConfigProp` was silently losing `source`, `metricName` and
`metricNamespace`, and metrics/meter could not have used the picker. The
AI params are a subset of the generic ones and the endpoint ignores
extras.

#### Issues closed by this PR

close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108466&issue=SigNoz%7Cengineering-pod%7C5845

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/b593e160-f81e-49d4-a092-20d86bc46515

#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 08:36:26 +00:00
Abhi kumar
6360da7d7c refactor(query-builder): make panel field config actually drive the builder (#12793)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `queryBuilderFields` on a panel definition had no effect.
`QueryBuilderV2` discarded the prop for list panels (the only kind that
declared anything), nothing downstream read `isHidden`/`isDisabled`, and
the `filters` / `whereClauseConfig` entry had no consumer anywhere in
the repo. The behaviour it appeared to configure came entirely from
`isListViewPanel`.
- Replaces it with a config in the builder's own vocabulary — a
per-field `hidden` / `disabled` / `pinned` rule over
`QueryBuilderField`, covering per-query controls plus `Formula` and
`AdditionalQueries`. A config can only narrow what the builder already
supports for the current data source and panel type, so definitions
never restate the builder's rules. `reason` is required on `disabled` so
an inert control always explains itself.
- `isListViewPanel` becomes `isRawQuery`: it was named for a dashboard
panel type but lives in a component three explorers use. It supplies the
defaults for `fieldsConfig` and the new `allowedDataSources`, which
callers override per field. On the dashboards side it is read from the
`requestType` a kind already declares, replacing a hardcoded
`signoz/ListPanel` check.
- Deletes the dead plumbing this uncovered:
`FilterConfigs`/`WhereClauseConfig`, the
`queryComponents`/`renderOrderBy` prop, Formula's
`isAdditionalFilterEnable` block and the four modules only it reached.

Net -900 lines. No behaviour change intended.

#### Additional Information

- Reviewing by commit is easier than by file; the four are split by
concern.
- **Formula-level HAVING is gone for real.** It only rendered behind
`isAdditionalFilterEnable`, whose sole call site passed `false`, and
QBv2 never reimplemented it — so this removes the only implementation
rather than one of two. Shout if that was on someone's roadmap.
- **`renderOrderBy` was already dead**, which means Logs and Traces
Explorer silently lost their `ExplorerOrderBy` control when QBv2 landed.
I removed the prop but left the component on disk, since that looks like
an unintended regression rather than intended cleanup.
- **Known gap:** the metrics aggregation section is outside the config.
`MetricsAggregateSection` renders its own group-by, space aggregation
and step interval, so `{ groupBy: { state: 'hidden' } }` looks like it
works on a metrics query and does not. Worth closing separately.
- `disabled` is implemented, not just declared — greyed control,
`reason` in the tooltip, refuses activation — but nothing declares it
yet; ListPanel still hides. Switching any field over is a one-key edit.
2026-09-18 07:30:50 +00:00
Nikhil Mantri
d5a9f5f022 Feat: How to use doc for sqlcompiler (#12869)
#### Description

1. Adds `docs/contributing/go/sqlcompiler.md`, a contributing doc for
`package sqlcompiler` (the shared list filter DSL to SQL compiler
extracted from dashboards in #12806).
2. Covers, in order: the DSL itself (grammar, boolean structure,
comparisons, free text), what the framework already handles (parsing,
tree walking, operator extraction, predicate builders, error
accumulation, arg binding), and what a module must supply (a
`FieldResolver`, with the dashboards resolver as the reference
implementation).
3. Documents the wiring pattern: a thin module-level `Compile` wrapper
mapping compiler errors to the module's error code, keys and allowed
operators declared in `pkg/types/<domain>` and advertised as
`reservedKeywords`.
4. Adds the doc to the index in `docs/contributing/go/readme.md`.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#349
2026-09-18 07:23:09 +00:00
Nityananda Gohain
c04d76ddb6 chore: use genai semconv for ai-o11y (#12905)
#### Description
Updates the key names based on
https://github.com/SigNoz/signoz-semantic-conventions/pull/7/changes
2026-09-18 05:56:28 +00:00
Pandey
67895d366d fix(tokenizer): require a jwt secret when the provider is jwt (#12899)
#### Description

- `tokenizer.Config.Validate()` now rejects an empty
`tokenizer::jwt::secret` when `tokenizer::provider` is `jwt`. An empty
secret signs and verifies tokens with an empty key, so anyone can mint a
valid token.
- Drops the startup log in `jwttokenizer` that flagged the missing
secret and carried on, config validation now fails the boot instead.

#### Additional Information

Breaking change: a deployment running `tokenizer.provider: jwt` without
`SIGNOZ_TOKENIZER_JWT_SECRET` (or the deprecated `SIGNOZ_JWT_SECRET`)
will fail to start until a secret is set. The default provider is
`opaque`, which is unaffected.
2026-09-18 04:06:28 +00:00
Pandey
b02aae2db3 feat(tokenizer): default to the opaque provider (#12896)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Switch the default tokenizer provider from `jwt` to `opaque`, so new
deployments issue revocable, server-side tokens out of the box.
- Update `conf/example.yaml` to match the new default.

#### Additional Information

Breaking change for deployments relying on the implicit default:
sessions issued by the JWT tokenizer are not valid for the opaque
tokenizer, so users will be logged out unless `tokenizer.provider: jwt`
is set explicitly.
2026-09-17 19:44:09 +00:00
Srikanth Chekuri
66b02d40c3 refactor(prometheus)!: remove the v1 provider and let the provider ow… (#12840)
…n evaluation

Assisted-by: Claude Fable 5
2026-09-17 18:25:58 +00:00
Nikhil Soni
9f03fea0f3 fix(querybuilder): prefer resource over any context for ambiguous filter keys (#12888)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- A logs filter on a bare key that lives in **both** resource and
another context (body or scope) ANDed the two: the resource candidate
built the `__resource_filter` fingerprint CTE while the other candidate
landed as a required main-query term, so the query matched almost
nothing.
- `ResolveLogicalFields` only preferred resource over `attribute`.
Generalized it to prefer resource over **any** other context (attribute,
body, scope, …); other contexts stay reachable via their qualified names
(e.g. `body.service.name`).

#### Issues closed by this PR

Closes SigNoz/engineering-pod#6086
Part of https://github.com/SigNoz/platform-pod/issues/3158

#### Additional Information

Generalized rather than special-casing body/scope, since any future
context would hit the same fingerprint-CTE trap.
2026-09-17 14:45:45 +00:00
1726 changed files with 95215 additions and 36289 deletions

View File

@@ -26,6 +26,135 @@ 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,
@@ -50,7 +179,29 @@ process on top of it.
- **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/`.
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).
- **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:
@@ -74,6 +225,13 @@ process on top of it.
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
@@ -85,6 +243,16 @@ process on top of it.
## 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

View File

@@ -128,20 +128,83 @@ 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',
title: 'Pages/Services/List',
component: Services,
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION, layout: 'app' }),
...pageStory,
parameters: { ...pageStory.parameters },
} 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, permissions, check state.
access preset, granted permissions, revoked 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`,
@@ -155,9 +218,12 @@ const meta = {
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.

View File

@@ -12,11 +12,12 @@ cd frontend && pnpm storybook --ci --quiet # :6006, background it
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
already-running server; an empty first poll is not a broken `stories` glob.
Story ids come from the meta title: `Pages/Services` → `pages-services`, plus the
story export in kebab-case. Render one story on its own:
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:
```
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
```
## Flip controls from the URL
@@ -57,8 +58,10 @@ console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
await browser.close();
```
Screenshots are worth taking for `Default` in both themes
(`&globals=theme:light`): text extraction does not catch an unstyled page.
Screenshots are worth taking for `Default` in both themes: text extraction does
not catch an unstyled page. Take them with
[sbshot](https://github.com/SigNoz/sbshot), e.g.
`sbshot capture http://localhost:6006 --title <story title> --theme dark,light`.
## Gates

View File

@@ -1,294 +0,0 @@
---
name: storybook-visual-diff
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
---
# Storybook visual diff
Two scripts under `frontend/scripts`:
- `story-shots.mjs` — screenshots stories off a running Storybook dev server.
- `story-shots-diff.mjs` — pixel-diffs two runs and paints what moved.
Output goes to `frontend/.story-shots/` (gitignored), one directory per run.
## 0. Settle what is being compared, first
A diff is only worth taking when the two runs straddle something. Run twice over
the same tree and the answer is zero, or the noise floor: true, and useless.
So before starting a server, pin down four things. Whatever the prompt already
says, take it and do not ask again; ask only for what is genuinely missing, in
**one** `AskUserQuestion` call.
| To settle | Ask | Options |
| --- | --- | --- |
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
| Themes | "Which themes?" | dark · dark + light |
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
Skip a row when the prompt answers it, and skip the whole call when the prompt
answers all of it ("shoot the pods tooltips in both themes" needs no question).
Skip Read-out too whenever the job is *shoot only*, and take `none` for what it
says: shoot both sides, report both paths, run no comparison. When the prompt
says nothing at all, ask; a silent guess here burns ~6 min per sweep on the
wrong stories.
The job decides which loop below to run:
| Job | Loop |
| --- | --- |
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
| **noise floor** | two runs, same tree, diff. The number is the harness's floor, not a finding. |
| **config vs config** | same tree, two runs that differ only in flags: `--args`, `--clock`, `--width`, `--theme`, `--motion`. Filenames stay identical, so the pairs line up and the caption names what changed. |
## The loop
1. Capture the baseline **before touching anything**.
2. Capture it a second time and diff the two. That is the noise floor: anything
it reports is what the harness cannot hold still, and no conclusion about the
change may rest on those stories. Cheap on a handful of stories, ~6 min per
32, so on a wide sweep run it over the two or three stories the change is
aimed at instead of all of them.
3. Make the change.
4. Capture again into a third directory.
5. Diff, then read the tinted shot of the largest movers to judge the change.
## 1. One dev server, on a free port
`storybook dev` keys its Vite dep cache off the config dir, so two servers on the
same `-c` serve mismatched prebundles and every story dies with `Invalid hook
call`. Check what is already up first — port 6006 is often another repo's
Storybook, and its `index.json` then indexes the wrong stories:
```bash
for port in 6006 6007; do
curl -s -m 2 "http://localhost:$port/index.json" | head -c 60 && echo " <- $port"
done
```
Start the SigNoz one on a free port, from the repo's own binary so no package
manager shim is in the way:
```bash
cd frontend
nohup ./node_modules/.bin/storybook dev -p 6007 --no-open --quiet \
> "${TMPDIR:-/tmp}/signoz-storybook.log" 2>&1 &
```
It is ready when `curl -s localhost:6007/index.json` returns JSON whose
`entries` hold SigNoz story ids.
## 2. Capture
Playwright is not a frontend dependency. The script finds it in `tests/e2e`
(`pnpm -C tests/e2e install`, `@playwright/test` is enough) or in a global
install, and launches Playwright's own chromium, falling back to an installed
Chrome. Two escape hatches when that is not what a machine has:
```bash
export PLAYWRIGHT_MODULE=/path/to/playwright # a different install
export CHROME_PATH=/path/to/chrome # a specific browser binary
```
Then pick the stories. `--list` prints the selection without shooting anything:
```bash
# every tooltip story of every page
node scripts/story-shots.mjs .story-shots/baseline \
--port 6007 --title Pages/ --name tooltip --theme dark
# a handful of stories by id or by title/name substring, both themes
node scripts/story-shots.mjs .story-shots/baseline \
--port 6007 --stories pages-noz,dashboards/detail --theme dark,light
```
| Flag | Meaning |
| --- | --- |
| `--stories <match>` | id or `Title/Name` substring, repeatable or comma-separated. Omit for every story. |
| `--title <prefix>` | only titles starting with the prefix (`Pages/`, `Components/`) |
| `--name <match>` | only story names containing the match |
| `--theme dark,light` | one pass per theme; omit for the story's own default (dark) |
| `--args <k:v;k2:v2>` | arg overrides, Storybook's own `?args=` syntax, repeatable. A dotted value is dropped by Storybook itself, so map it to a slug inside the story's mocks |
| `--port` | dev server port, or `$SB_PORT` |
| `--width <px>` | the only fixed dimension, default 1680 |
| `--height <px>` | shortest the viewport may be, default 1200 |
| `--max-height <px>` | tallest it may grow to, default 8000 |
| `--grow <what>` | `scrollers` (default) grows the viewport until the page's own scrollers fit, `document` only follows the document height, `none` keeps `--height` |
| `--settle <ms>` | wait after the page goes quiet, default 1500 |
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
| `--flat` | write `<out>/<id>.png`, no theme directory |
| `--no-caption` | leave the caption band off the shots |
| `--list` | print the matched stories and exit |
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
rows) and how the run was configured (args, clock, width, height, grow, motion,
settle, ignore). Keep the flags identical between the two runs or the diff pairs
nothing.
Every shot carries the caption band described below, so a single screenshot says
what it is on its own. `--no-caption` leaves it off, and so does a machine
without ImageMagick (with a warning). The band never changes the shot's width
(long text wraps rather than widening the canvas) and its height is recorded, so
the diff crops it back off and never reports one caption against another. Two
runs whose captions are different heights still diff to zero. A story that never held still for two
identical frames is logged `busy` instead of `ok` — treat its diff as suspect.
Dark alone is enough while iterating on the harness; add `light` for the run you
report.
## 3. Diff
```bash
node scripts/story-shots-diff.mjs .story-shots/baseline .story-shots/capped .story-shots/diff
```
Prints `<changed pixels> <theme>/<story>.png`, largest first, and writes one
image per pair. Needs ImageMagick for PNG encode/decode (7's `magick`, or 6's
`convert`/`identify`/`montage`); the comparison itself is in the script.
| Flag | Meaning |
| --- | --- |
| `--mode green` | default. The after shot with the changed pixels painted over it, exactly the pixels that changed. What Chromatic shows. |
| `--mode green-parallel` | `previous \| current \| diff` in one image, each tile labelled above it, on a gutter inverted from the theme. The diff tile is the `green` one, so the after shot stays readable underneath. |
| `--mode red` | the after shot faded to 10%, changed pixels in red. A pixelmatch-style diff, easiest to read when the change is a thin edge. |
| `--mode red-parallel` | the same three tiles, with the `red` diff. Best when the change is a thin edge that the unfaded shot would swallow. |
| `--threshold <0..1>` | how far a pixel must move to count. Default 0.063, Chromatic's `diffThreshold`. |
| `--include-aa` | count antialiasing changes too. Off by default, as in Chromatic. |
| `--tint <#rrggbb>` | override the mode's colour. |
| `--no-caption` | drop the caption band. |
### The caption
Both scripts stamp a band on top of what they write: `story-shots.mjs` on each
shot, from the story and the run's own settings; `story-shots-diff.mjs` on each
diff, read out of the two runs' `shots.json`. It carries the story's
`Title/Name`, then its id, theme and `busy` flag, then the settings both runs
shared, each reading `key:value`. Whatever the two runs did **differently** goes
on the side it belongs to: under `previous` and `current` on the parallel tiles,
on two lines of the band otherwise. So a pair that differs only in `--args` says
so on its face, which is what makes several shots of one story tellable apart.
The shots' own bands are cropped off before comparing and before going into the
tiles, so nothing in the output is a diff of a caption. Type size follows the
image width, so it stays readable with the whole image viewed at fit-to-width;
the heading is set in an installed sans and the detail lines in a mono, falling
back to ImageMagick's default when neither is on the machine. Without a manifest
the band falls back to the file path, and a directory of captioned shots whose
`shots.json` is missing has nothing to crop by, so its captions do land in the
diff. Keep `shots.json` next to the shots.
### How the comparison works
Chromatic's own capture and diff run server-side — `chromatic-cli` uploads a
built Storybook and contains no capture or comparison code at all. What is public
is the parameter contract, and the numbers in it say what the comparison is:
`diffThreshold` defaults to `0.063` on a 0-1 scale, which is pixelmatch's
`threshold`, and `diffIncludeAntiAliasing` defaults to false, which is
pixelmatch's `includeAA: false`. So the script implements that comparison:
1. Both PNGs are read as raw RGBA through `magick … RGBA:-`.
2. Per pixel, the squared YIQ distance between the two colours (weights
`0.5053 / 0.299 / 0.1957`), compared against `35215 * threshold²` — 35215 is
the largest distance two 8-bit colours can have. Chroma is included, so a
colour swap at equal brightness still counts.
3. A pixel over the threshold is dropped when it is only antialiasing: it is the
darkest or lightest of its eight neighbours, and the other image has a pixel
around there doing the same job. This is what keeps a subpixel glyph edge from
reading as a change.
4. What survives is painted at full opacity, one output pixel per changed input
pixel. No dilation, no blobs — a one-pixel shift shows as a one-pixel line.
A pair whose shots are different sizes is compared over the overlap, and every
row and column that exists in only one of them counts as changed.
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
(new on the feature branch, renamed, retitled) has nothing to pair with and is
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
before reading the numbers.
## What makes a shot reproducible
Most of it is in the preview, not in the script, so a Chromatic build in the
cloud shoots the same page: `.storybook/preview-head.html` freezes the clock,
and `settleForCapture` (the preview's `afterEach`, which runs after `play`)
parks the animations and snaps the bottom-pinned lists. The script drives the
rest:
- **Storybook's own render phase is the readiness signal.** It waits for
`window.__STORYBOOK_PREVIEW__.storyRenders[].phase === 'finished'`, which is
reached only after the loaders, the decorators and the story's `play` are done.
A DOM check cannot see a `play` still running. (Storybook 10 spells the final
phase `finished`, not `completed`.)
- **Network quiescence, not `networkidle`.** react-query retries and msw keep
requests going after load, and a few stories hang a request by design, so the
wait is "no request for 600ms", capped at 15s.
- **The clock is frozen** (`2026-06-15T12:00:00Z`), by the preview itself. Chart windows, `4 mins ago`
labels and trial countdowns all derive from `now`; a live clock alone moved
8000 pixels on the dashboards list and redrew every chart axis.
- **Animations are parked on their last frame** by `html.sb-still`, a
zero-length single iteration with `forwards` fill, plus `prefers-reduced-
motion`. The Motion toolbar item (`still` by default) turns it off. An infinite
spinner is otherwise caught at a random angle.
- **`document.fonts.ready`**, because text reflows when a face lands late.
- **Lists pinned to their bottom are snapped onto it**, once by the preview and
again by the script after the page goes quiet. A virtuoso list settles a
few pixels short of the end depending on the order its items were measured in.
- **Two identical frames in a row**, because what a page is still waiting on is
often not observable from outside it.
- **`[data-shot-ignore]`, `[data-chromatic="ignore"]` and `--ignore <selector>`**
hide a region that cannot be held still; Chromatic excludes the same attribute
from its comparison.
- **The width is the only fixed dimension.** Chromatic's `viewports` are widths;
the height follows the page. `src/styles.scss` pins `html, body, #root` to
`height: 100%; overflow: hidden`, so the document never outgrows the viewport
and its height says nothing: what overflows are the shell's inner scrollers.
`--grow scrollers`, the default, grows the viewport until the tallest in-flow
scroller fits, so nothing is cut off and no scrollbar is left in the shot (the
dashboards list goes to 2226px in one round). Popups are skipped — they are out
of the flow, and a tall dropdown would otherwise drag the shot to a height
nothing on the page needs. A page that sizes a panel in `vh` grows its own
content as the viewport grows, so no height ever fits it and the rounds only
chase — `.alert-chart-container` is `57vh`, which puts Create Alert's fixed
point at 4344px with an empty band on top. Those pages are shot at `--height`
with their own scrollbar, which is what they look like in a browser, and the
log says `(viewport-sized content, stopped chasing Npx)`.
With all of that, 29 of the 32 page tooltip stories are byte-identical across
runs. The three that are not, and why:
| Story | Residual | Cause |
| --- | --- | --- |
| `kubernetes-pods--tooltips-in-options-panel` | ~13k px | 24 tooltips held open in an overlapping cluster; they portal to `body` in mount order, and the drawer's own tooltips mount before or after the list's depending on when their data lands, so overlapping tooltips stack differently. Panel geometry itself is stable. |
| `settings-role-editor--tooltips-in-json-editor` | ~2.5k px | monaco re-measures and lands one pixel off. |
| `traces-trace-details--tooltips` | ~800 px | same class, one row of the waterfall. |
Each is bimodal — two stable arrangements — so the same number reappears run
after run. Diff a story against itself before believing its number, and reach
for `--ignore` when a region cannot be settled.
## Gotchas
- **Zero pixels is a real answer.** A story whose tooltips are all short is
unaffected by a tooltip rule; it is not a broken capture.
- **The selector matters more than the rule.** A global rule on
`[data-slot='…']` only reaches design-system components. antd's own tooltips
(`.ant-tooltip-inner`, e.g. the Create Alert help popups) are untouched, which
is why some stories show no diff at all.
- **Global style overrides need `!important`.** `src/styles.scss` loads before
the design system injects its CSS-module styles at runtime, so a plain rule on
a `[data-slot='…']` element loses. A component-level `!important` of the same
specificity still wins over it — `PanelStatusPopover.module.scss` keeps its own
`max-width: 520px !important`.
- **A fresh context per story** is why a full sweep takes ~6 min for 32 stories.
Reusing one page loses the msw service worker re-registration race and stories
start failing after a few navigations.
- **Stories behind a hover, drawer or modal** only render what their `play`
reaches. If a state is missing from the shot, the story needs the `play`, not
the script.

16
.github/CODEOWNERS vendored
View File

@@ -200,6 +200,15 @@ go.mod @therealpandey
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
/frontend/src/container/RoutingPolicies/ @SigNoz/pulse-frontend
/frontend/src/components/AlertBreadcrumb/ @SigNoz/pulse-frontend
/frontend/src/container/EditRules/ @SigNoz/pulse-frontend
/frontend/src/components/AlertDetailsFilters/ @SigNoz/pulse-frontend
/frontend/src/components/Alerts/ @SigNoz/pulse-frontend
/frontend/src/hooks/routingPolicies/ @SigNoz/pulse-frontend
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
@@ -207,6 +216,8 @@ go.mod @therealpandey
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
@@ -280,3 +291,8 @@ go.mod @therealpandey
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
## Storybook
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad

View File

@@ -40,6 +40,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4]
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
@@ -48,12 +49,33 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@v4
# Images bring-up-stack would otherwise pull, at the tests/conftest.py
# defaults. A stale tag only loses the head start.
- name: pull-images
id: pull-images
background: true
run: |
printf '%s\n' \
node:22-bookworm \
golang:1.25-bookworm \
python:3.13-slim \
postgres:15 \
clickhouse/clickhouse-server:25.12.5 \
clickhouse/clickhouse-keeper:25.12.5 \
signoz/signoz-schema-migrator:v0.144.9 \
wiremock/wiremock:2.35.1-1 \
| xargs -P 0 -n 1 docker pull -q
- name: python
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: uv
uses: astral-sh/setup-uv@v4
- name: python-install
id: python-install
background: true
run: |
cd tests && uv sync
- name: node
uses: actions/setup-node@v4
with:
@@ -62,19 +84,9 @@ jobs:
uses: pnpm/action-setup@v6
with:
version: 10
- name: python-install
run: |
cd tests && uv sync
- name: pnpm-install
run: |
cd tests/e2e && pnpm install --frozen-lockfile
- name: playwright-browsers
run: |
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
# Restore-only: the cacheci workflow owns cache saves. Seeds the
# BuildKit cache mounts so the in-test image build is incremental.
- name: restore
@@ -99,6 +111,19 @@ jobs:
tar -xf /restored/pnpm-store.tar -C /pnpm/store
EOF
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
- name: wait-stack-deps
wait: [pull-images, python-install]
# Started after the pulls so its download overlaps the CPU-bound
# image build instead of competing for the network.
- name: playwright-browsers
id: playwright-browsers
background: true
run: |
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
- name: bring-up-stack
run: |
cd tests && \
@@ -106,10 +131,12 @@ jobs:
--basetemp=./tmp/ \
-vv --reuse --with-web \
e2e/bootstrap/setup.py::test_setup
- name: wait-playwright-browsers
wait: playwright-browsers
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }}
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
- name: teardown-stack
if: always()
run: |
@@ -122,6 +149,6 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ matrix.project }}
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
path: tests/e2e/artifacts/
retention-days: 5

View File

@@ -67,7 +67,7 @@ jobs:
with:
go-version: "1.24"
- name: check-semconv-generated-files
run: go run ./scripts/semconv -check
run: make semconv-check
build:
if: |
github.event_name == 'merge_group' ||

View File

@@ -38,7 +38,6 @@ jobs:
fail-fast: false
matrix:
suite:
- alerts
- alertmanager
- alertmanagerrotation
- basepath
@@ -47,6 +46,7 @@ jobs:
- dashboard
- ingestionkeys
- inframonitoring
- llmpricingrules
- logspipelines
- passwordauthn
- preference
@@ -63,10 +63,12 @@ jobs:
- querierauthz
- role
- rootuser
- ruler
- savedview
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

2
.gitignore vendored
View File

@@ -231,5 +231,7 @@ pyrightconfig.json
# dev
.dev/
.deepsec/
.deepsec.zip
.claude/worktrees/
.claude/settings.local.json

View File

@@ -262,6 +262,10 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
@go run ./scripts/semconv
.PHONY: semconv-check
semconv-check: ## Fail if the generated semantic-convention files are stale
@go run ./scripts/semconv -check
.PHONY: gen-mocks
gen-mocks:
@echo ">> Generating mocks"

View File

@@ -341,7 +341,7 @@ gateway:
##################### Tokenizer #####################
tokenizer:
# Specifies the tokenizer provider to use.
provider: jwt
provider: opaque
lifetime:
# The duration for which a user can be idle before being required to authenticate.
idle: 168h

File diff suppressed because it is too large Load Diff

View File

@@ -121,7 +121,7 @@ The pieces:
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). When a side's ids come from a list extractor (`BodyJSONArray` or a custom `ResourceIDsExtractor`) and that list resolves to nothing at request time, there is nothing to link: the def resolves to no resources, so no check runs and no audit event is emitted (e.g. inviting a user with an empty `userRoles`). A single-id side (`OneID`) always resolves to exactly one id and fails closed when it is empty. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.

View File

@@ -0,0 +1,181 @@
# DSL Filtering to SQL
To support search on any entity's list page (dashboards, alert rules, ...), use [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go). It compiles a filter DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. This doc explains what the compiler already does and what an adopting module supplies: a `FieldResolver` that says which keys exist and what each maps to.
The dashboards list is the adopter today; the alert rules list revamp is adopting it next.
## What is the DSL?
A few queries, from simple to full:
```
payment
status = active AND name CONTAINS cpu
(labels.team IN ('infra', 'platform') OR labels.env EXISTS) AND created_at > '2025-01-01T00:00:00Z'
"name = something"
```
- `payment` is free text: a bare token with no key, matched as a substring wherever the module decides (name, description, ...).
- `status = active AND name CONTAINS cpu` is two comparisons of the shape `key OP value`. The `AND` is optional; adjacent terms are an implicit `AND`.
- The third query shows grouping and precedence: parentheses > `NOT` > `AND` > `OR`. Values are bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
- `"name = something"` is quoted, so it is free text for that exact phrase instead of a `name = something` comparison. Quoting is the escape hatch for a phrase that looks like DSL.
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4) (see its `comparison` rule for the full operator list), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere.
## What does the framework already cover?
```go
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
type Compiled struct {
SQL string
Args []any
}
```
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. `Compiled.SQL` is the WHERE clause with `?` placeholders and `Compiled.Args` holds the bind arguments in placeholder order; the store passes both to bun. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. The package handles:
- Parsing, with syntax errors collected at line/column positions instead of failing on the first one.
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
- Typed value extraction with accumulated errors: the user sees every problem in the query at once.
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
The resolver is called once per term and builds each predicate with helpers the compiler provides (next section).
## When do I write a FieldResolver?
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
```go
type FieldResolver interface {
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
ResolveFreeText(v *Visitor, value string) string
}
```
- `ResolveComparison` is called once per `key OP value` term. It decides whether the key exists and which column expression it maps to, and returns the SQL predicate for the term.
- `ResolveFreeText` is called for a bare or quoted keyless token. It returns a predicate matching the token across whatever the module considers searchable (name, description, tags, ...).
- Both report a bad key, operator or value with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all accumulated errors.
The `*Visitor` passed in provides everything needed to build predicates. Use these instead of hand-building SQL or managing arguments yourself:
| On the `Visitor` | Use |
| --- | --- |
| `Sb` | the compile's root `SelectBuilder`; predicates and their arguments attach to it |
| `Formatter` | dialect-portable column expressions (`JSONExtractString`, `LowerExpression`) valid on both SQLite and Postgres |
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
| `BuildBoolComparison` | `= true/false` |
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
| `ExtractSingleStringValue`, `ExtractStringValueList` | typed value extraction when building a custom predicate |
| `AddError` | report a problem; errors accumulate |
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
```go
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
switch key {
case "created_by":
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)
case "created_at":
return v.BuildTimestampComparison(ctx, operation, "sample_entity.created_at")
case "locked":
return v.BuildBoolComparison(ctx, operation, "sample_entity.locked")
}
v.AddError("unknown key %q", key)
return ""
}
func (sampleEntityFieldResolver) ResolveFreeText(v *sqlcompiler.Visitor, value string) string {
return v.BuildFreeTextContains(v.Sb, "sample_entity.name", value)
}
```
### Special cases
Each entity decides its own key policy. The sections below grow the `sample_entity` resolver; the full real-world adopter to read alongside is dashboards' resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go).
#### Reserved and non-reserved keys
A resolver splits the key space in two:
- Reserved keys are properties the entity defines for all its instances: every `sample_entity` has a `name`, `created_by`, `created_at` and `locked`, so those keys are claimed up front and always mean that property. The list API can advertise the set (dashboards and rules return `reservedKeywords`) so frontend suggestions never go stale.
- Every other key is non-reserved: things users attach to individual instances as they want. For `sample_entity` those are labels, so `team = infra` matches only the instances a user labeled `team: infra` (built out under [Relation tables](#relation-tables)). Dashboards exposes tags the same way, and an entity is free to back this with any other per-instance construct. An entity with nothing user-attached rejects unknown keys with `v.AddError`, as the resolver above does.
So the first thing `ResolveComparison` does is route the key:
```go
if allowedOperations, isReserved := ReservedOps[key]; isReserved {
return r.resolveReservedKey(v, ctx, operation, key, allowedOperations)
}
return r.buildLabelComparison(v, ctx, operation, key)
```
#### Operator allowlists
Not every operator makes sense on every key, reserved or not (`name BETWEEN ...` does not). Declare what each accepts and check before building. `sample_entity` pairs each reserved key with its allowed operators:
```go
var ReservedOps = map[string]map[qbtypesv5.FilterOperator]struct{}{
"name": stringSearchOps(),
"created_at": numericRangeOps(),
"locked": boolOps(),
}
if _, allowed := allowedOperations[operation]; !allowed {
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
return ""
}
```
Non-reserved keys get allowlists too, usually one shared list since they are all shaped alike: a label lookup is a string match, so `created_at > '2025-01-01T00:00:00Z'` is fine but `team > infra` is rejected with an `AddError`. Dashboards' real instances of both are `ReservedOps` and `TagKeyOps` in [pkg/types/dashboardtypes](/pkg/types/dashboardtypes/list_filter.go).
#### JSON columns
Suppose `sample_entity` keeps `name` inside a `data` JSON column instead of a plain column. The resolver then builds the column expression with `v.Formatter.JSONExtractString`, which renders correctly on both dialects, and `name CONTAINS cpu` compiles (SQLite flavor) to:
```sql
json_extract("sample_entity"."data", '$.name') LIKE ? ESCAPE '\'
-- args: ["%cpu%"]
```
Dashboards stores name and description this way inside `dashboard.data`.
#### Relation tables
The label policy from above: say `sample_entity` labels live in `label`/`label_relation` join tables, so a label term becomes an `EXISTS` subquery. Build it on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. `team = infra` compiles to:
```sql
EXISTS (SELECT 1 FROM label_relation lr JOIN label l ON l.id = lr.label_id
WHERE lr.entity_id = sample_entity.id
AND LOWER(l.key) = LOWER(?) AND l.value = ?)
-- args: ["team", "infra"]
```
For a negative operator (`team != infra`), build the positive predicate and toggle `NotExists` on the outer builder, so rows without the label at all also match. Dashboards' tags follow this exact pattern over the shared `tag`/`tag_relation` tables.
## How to wire it in?
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code:
```go
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, sampleEntityFieldResolver{})
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(sampleentitytypes.ErrCodeSampleEntityListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
}
```
Dashboards' real wrapper is [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go).
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
## Caveats
- This compiler is for the relational store only. Telemetry filters are a different pipeline; they stay on querybuilder's ClickHouse visitor.
- A `key REGEXP value` term parses, but no predicate builder implements it: `BuildStringOperation` rejects it with an error, since SQLite has no portable `REGEXP` (Postgres spells it `~`). A resolver may implement it itself for a dialect it controls.
- `has(...)` function calls and `search(...)` from the telemetry grammar are not implemented; they fall through to `ResolveFreeText` as literal text.

View File

@@ -179,6 +179,7 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
- **SuccessStatusCode**: The HTTP status for successful responses (for example, `http.StatusOK`, `http.StatusCreated`, `http.StatusNoContent`).
- **ErrorStatusCodes**: Additional error status codes beyond the standard ones automatically added by `handler.New`.
- **SecuritySchemes**: Auth mechanisms and scopes required by the operation.
- **Stability**: Maturity marker (`handler.StabilityDevelopment`, `handler.StabilityAlpha`, `handler.StabilityBeta`, `handler.StabilityStable`, the OpenTelemetry Collector levels) emitted as the `x-signoz-stability` extension on every operation. Unset is emitted as `alpha`.
The generic handler:

View File

@@ -17,7 +17,7 @@ For example, the [prometheus](/pkg/prometheus) provider delivers a prometheus en
- `pkg/prometheus/prometheus.go` - Interface definition
- `pkg/prometheus/config.go` - Configuration
- `pkg/prometheus/clickhouseprometheus/provider.go` - Clickhouse-powered implementation
- `pkg/prometheus/clickhouseprometheusv2/provider.go` - Clickhouse-powered implementation
- `pkg/prometheus/prometheustest/provider.go` - Mock implementation
## How to wire it up?

View File

@@ -21,4 +21,5 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
- [Service](service.md) - Managed service lifecycle with `factory.Service`
- [SQL](sql.md) - Database and SQL patterns
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`

View File

@@ -9,15 +9,16 @@ change breaks an invariant, flag it and discuss it first.
---
## Why a second provider
## Why the provider looks like this
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql
engine through the remote-read protobuf adapter. It fetches every raw sample
of a query's union window. It serializes all of them and gives them to the
engine. The cost follows the ingested data, not the question. This is how a
dashboard of PromQL panels can take an instance down.
The removed v1 provider served the promql engine through the remote-read
protobuf adapter. It fetched every raw sample of a query's union window,
serialized all of them, and gave them to the engine. The cost followed the
ingested data, not the question. This is how a dashboard of PromQL panels
could take an instance down. v2 replaced it after a byte-level parity
rollout, and v1 was then deleted.
In v2, each query runs in one of two ways. The classifier decides per query:
Each query runs in one of two ways. The classifier decides per query:
- **Transpiled**: ClickHouse evaluates the query. Only final (or near-final)
per-group grid arrays come back. The statements use the
@@ -30,7 +31,7 @@ In v2, each query runs in one of two ways. The classifier decides per query:
lost user. A construct that cannot reproduce engine semantics exactly falls
back. It does not approximate.** The conformance suite
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
corpus against both providers. It is the arbiter. The classification golden
corpus against the provider. It is the arbiter. The classification golden
(`testdata/classification_golden.json`) freezes the route of each corpus
expression. The rest of this document is the PromQL-to-SQL story. That
mapping is where correctness is won or lost.
@@ -263,7 +264,8 @@ per-thread partials scaled memory with the thread count. The slide then
combines each slot's at-most-W bucket partials by direct aggregation
(`arraySum(arraySlice(...))`). Window sums are added the way the engine adds
them. There is no prefix-sum differencing: its large-minus-large
cancellation would drift past the shadow tolerance on counter-sized values.
cancellation would drift past the conformance tolerance on counter-sized
values.
This is correct per slot because the bucket union is the exact window
multiset, and avg/min/max/sum/count are order-insensitive on a multiset
(sum/avg up to summation order; see the float caveat above). A slot with
@@ -333,7 +335,7 @@ can carry them.
## The engine path
Queries that do not transpile run in the stock engine over this package's
`storage.Querier`. This is still not the v1 path. Samples are fetched per
`storage.Querier`. Samples are fetched per
selector with the engine's per-selector hints, not the query-wide union
window. So `foo / foo offset 1d` reads two narrow windows, not the widest
one twice. Instant selectors of subquery-free queries fetch only the last
@@ -367,9 +369,9 @@ same predicates as a shard-local semi-join, not a GLOBAL broadcast of the
matched set. The temporality filter on every samples statement is a
semantic no-op: the matched fingerprints already come from those
temporalities. It engages the leading samples primary-key column.
Delta-temporality series stay invisible to PromQL here, exactly as in v1.
The rollout gate is parity with v1. To make Delta visible is its own change
with its own semantics to design. A Delta stream fed to `rate()`
Delta-temporality series stay invisible to PromQL here, as they were before
v2. To make Delta visible is its own change with its own semantics to
design. A Delta stream fed to `rate()`
as-if-cumulative would be wrong, not just new.
## Observability

View File

@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|---|---|---|---|---|---|
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
### The generic layer
@@ -109,7 +109,7 @@ The functions, from the outside in:
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
### A resolved key

View File

@@ -183,32 +183,52 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
}
account, err := cloudintegrationtypes.NewAccountFromStorable(storableAccount)
if err != nil {
return nil, err
}
syncState := account.NextSyncState(req.SyncedVersion)
// If account has been removed (disconnected), return a minimal response with empty integration config.
// The agent uses this response to clean up resources
if account.RemovedAt != nil {
// Heartbeat stays frozen after removal, only the sync state is updated.
if account.AgentReport != nil && syncState != nil {
account.UpdateSyncState(syncState)
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
), nil
}
// update account with cloud provider account id and agent report (heartbeat)
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
err = module.store.UpdateAccount(ctx, account)
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
// Get account as domain object for config access (enabled regions, etc.)
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
@@ -223,8 +243,7 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, err
}
// Delegate integration config building entirely to the provider module
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, account, storedServices)
if err != nil {
return nil, err
}
@@ -234,6 +253,7 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
account.ID.StringValue(),
integrationConfig,
account.RemovedAt,
syncState,
), nil
}

View File

@@ -52,7 +52,7 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
dashboard, err := module.GetV2(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
@@ -90,15 +90,6 @@ func (module *module) GetPublic(ctx context.Context, orgID valuer.UUID, dashboar
return dashboardtypes.NewPublicDashboardFromStorablePublicDashboard(storablePublicDashboard), nil
}
func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
}
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
orgIDs := make([]string, len(orgs))
for idx, org := range orgs {
@@ -116,24 +107,6 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
}, storableDashboard.OrgID, nil
}
func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.UUID, widgetIdx, startTime, endTime uint64) (*querybuildertypesv5.QueryRangeResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "dashboard",
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRange",
})
dashboard, err := module.GetDashboardByPublicID(ctx, id)
if err != nil {
return nil, err
}
query, err := dashboard.GetWidgetQuery(startTime, endTime, widgetIdx, module.settings.Logger())
if err != nil {
return nil, err
}
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
@@ -189,7 +162,7 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
dashboard, err := module.GetV2(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
@@ -200,34 +173,13 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
return module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
}
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
dashboard, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
if err := dashboard.ErrIfNotDeletable(); err != nil {
return err
}
if dashboard.Locked {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
}
return module.delete(ctx, orgID, id)
}
func (module *module) DeleteUnsafe(ctx context.Context, orgID, id valuer.UUID) error {
return module.delete(ctx, orgID, id)
}
func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, dashboardID)
dashboard, err := module.GetV2(ctx, orgID, dashboardID)
if err != nil {
return err
}
@@ -260,10 +212,6 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, data dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, source, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
}
@@ -346,30 +294,10 @@ func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}
func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
}
@@ -377,12 +305,3 @@ func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID value
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
}
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.store.Delete(ctx, orgID, id)
})
}

View File

@@ -462,7 +462,7 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
droppedSet[label] = struct{}{}
}
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
if dashboards, err := m.dashboard.GetByMetricNamesV2(ctx, orgID, []string{metricName}); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {

View File

@@ -80,15 +80,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
Active: aiObservability,
Usage: 0,
UsageLimit: -1,
Route: "",
})
metricsReduction := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),

View File

@@ -160,7 +160,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
// Variable to store promProvider for cleanup
var promProvider *prometheustest.Provider
var promProvider prometheus.Prometheus
// Create manager using test factory with hooks
mgr := rules.NewTestManager(t, &rules.TestManagerOptions{
@@ -185,76 +185,29 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
TelemetryStoreHook: func(store telemetrystore.TelemetryStore) {
mockStore := store.(*telemetrystoretest.Provider)
// Set up Prometheus-specific mock data
// Fingerprint columns for Prometheus queries
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// Samples columns for Prometheus queries
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// Calculate query time range similar to Prometheus rule tests
// TestNotification uses time.Now().UTC() for evaluation
// We calculate the query window based on current time to match what the actual evaluation will use
// Grid the TestNotification eval computes over (see
// Timestamps on base_rule); nil args match any window.
evalTime := baseTime
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
gridEnd := (evalTime.UnixMilli() / 60000) * 60000
gridStart := gridEnd - evalWindowMs
// Create fingerprint data
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]interface{}{
{fingerprint, labelsJSON},
}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// Create samples data from test case values, calculating timestamps relative to baseTime
validSamplesData := make([][]interface{}, 0)
tsList := make([]int64, 0, len(tc.Values))
vList := make([]float64, 0, len(tc.Values))
for _, v := range tc.Values {
// Skip NaN and Inf values in the samples data
if math.IsNaN(v.Value) || math.IsInf(v.Value, 0) {
continue
}
// Calculate timestamp relative to baseTime
sampleTimestamp := baseTime.Add(v.Offset).UnixMilli()
validSamplesData = append(validSamplesData, []interface{}{
"test_metric",
fingerprint,
sampleTimestamp,
v.Value,
uint32(0), // flags - 0 means normal value
})
tsList = append(tsList, baseTime.Add(v.Offset).UnixMilli())
vList = append(vList, v.Value)
}
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
grid := prometheustest.LastSampleGrid(tsList, vList, gridStart, gridEnd, 60_000, 300_000)
mock := mockStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
// args: $1=metric_name (the __name__ matcher maps onto the column)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
// args: metric_name IN (discovered names), subquery metric_name, start, end
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
mock.ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(prometheustest.GridCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
@@ -289,7 +242,6 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
assert.Empty(t, triggeredTestAlerts)
}
promProvider.Close()
})
}
}

View File

@@ -23,6 +23,15 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
return append(f.TextToJsonColumn(column), ops...)
}
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
sql := f.TextToJsonColumn(column)
sql = append(sql, "->"...)
sql = schema.Append(f.bunf, sql, mapField)
sql = append(sql, "->>"...)
sql = schema.Append(f.bunf, sql, key)
return sql
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "jsonb_typeof("...)

View File

@@ -55,6 +55,67 @@ func TestJSONExtractString(t *testing.T) {
}
}
func TestJSONExtractMapValue(t *testing.T) {
tests := []struct {
name string
column string
mapField string
key string
expected string
}{
{
name: "PlainKey",
column: "data",
mapField: "labels",
key: "team",
expected: `"data"::jsonb->'labels'->>'team'`,
},
{
name: "DottedKey_OneMapEntry",
column: "data",
mapField: "labels",
key: "k8s.cluster",
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
},
{
name: "SingleQuoteInKey_Doubled",
column: "data",
mapField: "labels",
key: "o'brien",
expected: `"data"::jsonb->'labels'->>'o''brien'`,
},
{
name: "BackslashInKey_Literal",
column: "data",
mapField: "labels",
key: `a\b`,
expected: `"data"::jsonb->'labels'->>'a\b'`,
},
{
name: "DoubleQuoteInKey_Literal",
column: "data",
mapField: "labels",
key: `a"b`,
expected: `"data"::jsonb->'labels'->>'a"b'`,
},
{
name: "QualifiedColumn",
column: "rule.data",
mapField: "labels",
key: "severity",
expected: `"rule"."data"::jsonb->'labels'->>'severity'`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONType(t *testing.T) {
tests := []struct {
name string

View File

@@ -0,0 +1,127 @@
---
name: scaffold-feature
description: Scaffold the co-located feature structure in frontend/src. Use when creating a new page, feature, view (tab), or component folder, when a feature needs a shell with tabs, or when moving existing code out of src/container into src/pages. Generates the full folder tree (components/hooks/store/types/utils/constants/__tests__/README) and registers the page's routes with one command.
---
# Scaffold a feature
The frontend is moving to a co-located layout (Bulletproof React / FSD): everything a
feature owns lives in the feature's folder. Read `references/layout.md` for the full
target structure and the rules about what may live where.
**Never hand-create these folders.** Run the generator so every feature comes out
identical, then fill it in.
## Command
```bash
pnpm scaffold page <Name> [options] # a page/feature under src/pages
pnpm scaffold component <Name> [options] # a component folder
```
| Option | Applies to | Effect |
| --- | --- | --- |
| `--views A,B,C` | `page` | Makes the page a shell with tab switching and generates one view folder per name. |
| `--parent <path>` | `component` | Parent, relative to `src` (default `components`). A feature path like `pages/Traces/Explorer` nests the component under that feature's `components/`. |
| `--full` | `component` | Also adds `components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `README.md` for a component that owns children. |
| `--no-tests` | both | Skips `__tests__/`. |
| `--dry-run` | both | Prints what would be written, writes nothing. |
| `--force` | both | Overwrites files that already exist (off by default; existing entries are reported as skipped). |
Folder names keep the casing you type, with the first letter forced up, so
`LLMObservability` stays `LLMObservability` rather than being re-cased. Separated names
collapse to PascalCase: `api-monitoring` and `api monitoring` both give
`pages/ApiMonitoring`. Test ids, headings, tab paths and constants are all derived from
that folder name — `TracesFunnels` gives `traces-funnels-page`, `Traces Funnels` and
`TRACES_FUNNELS_TABS`.
## What you get
```
pages/ApiMonitoring/
index.tsx # the page component
ApiMonitoring.module.scss
components/ hooks/ store/ # empty, ready for the first file
types.ts utils.ts constants.ts
__tests__/ApiMonitoring.test.tsx
README.md
```
With `--views`, the root becomes a `RouteTab` shell and each view gets the tree above. The
shell mirrors the Logs and Traces root pages: `constants.tsx` exports one `TabRoutes` per
view (icon from `@signozhq/icons`, label, `ROUTES` key, view component), `index.tsx` composes
them into the tab bar, the SCSS module carries the tab-bar overrides, and the test asserts one
tab per view plus the active view. Tab icons come from a small name map in `scaffold.mjs`
(`Explorer`, `Funnels`, `Pipelines`, `Views`, `SavedViews`); other names get a neutral icon
to replace.
## Examples
```bash
pnpm scaffold page ApiMonitoring # leaf page, no shell
pnpm scaffold page Traces --views Explorer,Funnels,Views # shell + 3 views
pnpm scaffold page Traces/Explorer # one more view under an existing shell
pnpm scaffold component DataTable # global, src/components/DataTable
pnpm scaffold component QueryBar --parent pages/Traces/Explorer # feature-local component
```
## Route registration
`page` also registers the routes, so the page is reachable as soon as it is generated:
| File | What is added |
| --- | --- |
| `src/constants/routes.ts` | One key per path: `API_MONITORING: '/api-monitoring'` for a leaf page; `TRACES_BASE` plus `TRACES_EXPLORER`, `TRACES_FUNNELS`, … for a shell. |
| `src/utils/permission/index.ts` | A `routePermission` entry per new key, open to `ADMIN`, `EDITOR` and `VIEWER`. Tighten it if the page is admin-only. |
| `src/AppRoutes/pageComponents.ts` | A `Loadable` export named `<Page>Page` pointing at `pages/<Page>`. |
| `src/AppRoutes/routes.ts` | The import plus one private, exact route per path. For a shell the base path and every tab path render the shell; the shell redirects the base path to its first tab and `RouteTab` picks the tab otherwise. |
| `src/container/TopNav/DateTimeSelectionV2/constants.ts` | Every new path in `routesToSkip`, so the global time-range picker stays hidden until the page opts in. |
Existing keys, exports and entries are left alone, so re-running is safe. An existing key or
export that points somewhere else is a naming collision and the run stops before writing
anything. `--dry-run` lists
the edits without making them. `page Traces/Explorer` registers `TRACES_EXPLORER` pointing
at the `Traces` shell; wiring the new tab into the shell's `constants.tsx` and `index.tsx`
is still by hand. The generator never adds a SideNav item; do that in
`src/container/SideNav/menuItems.tsx` when the page needs one.
## After generating
1. **Review the route registration** (pages only) and add the SideNav entry if the page
needs one. For a view added under an existing shell, add its `TabRoutes` export to the
shell's `constants.tsx` and include it in the `routes` array in the shell's `index.tsx`.
2. **Delete the placeholders you don't need** — empty `types.ts` / `utils.ts` /
`constants.ts`, and any of `components/`, `hooks/`, `store/` the feature won't use.
Those three folders are created empty; git only picks them up once they hold a file.
3. **Fill the README** — the generated file has the prompts; a feature folder without a
filled-in README is not done.
4. **Follow the repo rules while filling it in**: `@signozhq/ui` + `@signozhq/icons` only,
CSS Modules (`docs/css-modules-guide.md`), React Query for server state (prefer
`api/generated` hooks), nuqs for URL state, Zustand for client state, `data-testid` on
every interactive element.
5. **Verify** before reporting done:
```bash
pnpm tsgo --noEmit
pnpm oxlint src/pages/<Feature>
pnpm jest src/pages/<Feature>
```
`pnpm tsgo --noEmit` is the authority. A running dev server can show errors such as
`Property 'X_BASE' does not exist` or `has no exported member 'XPage'` right after
generation. Its type-checker notices new files but, on some machines, not in-place edits
to existing ones, and the generator edits the shared files in place. If tsgo is clean,
restart `pnpm dev`.
## Editing the templates
Templates live in `templates/` — `feature/`, `shell/`, `component/` and
`component-extras/` (the `--full` additions). Every template file ends in `.tmpl`, which
keeps TypeScript, lint and your editor from reading them as source; the generator strips
that suffix on the way out, so `index.tsx.tmpl` becomes `index.tsx`. Tokens are
substituted in both file names and contents: `__Pascal__`, `__kebab__`, `__camel__`,
`__CONST__`, `__Title__`. The shell templates additionally take tokens the generator builds
from `--views`: `__ICON_IMPORTS__`, `__VIEW_IMPORTS__`, `__TAB_EXPORTS__`, `__TAB_NAMES__`,
`__BASE_ROUTE__`, `__FIRST_TAB__`, `__FIRST_VIEW_TESTID__` and `__TAB_ASSERTIONS__`. Tab icons come from
`TAB_ICONS` and the empty folders from `FEATURE_DIRS`, both in `scaffold.mjs`. Name and
route derivations live in `lib.mjs`; run `node --test .claude/skills/scaffold-feature/scaffold.test.mjs`
after changing them. Change these, not the generated
output, when the team's conventions move.

View File

@@ -0,0 +1,77 @@
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
// Folder names keep the casing the author typed — only the first letter is forced
// up — so acronyms like `LLMObservability` survive. Separated names
// (`api-monitoring`, `api monitoring`) collapse to PascalCase.
export function toDirName(value) {
const name = value.trim().replace(/[^a-zA-Z0-9\-_ ]/g, '');
if (!name) {
throw new Error(`"${value}" has no usable name characters`);
}
return /[-_\s]/.test(name)
? name
.split(/[-_\s]+/)
.filter(Boolean)
.map(capitalize)
.join('')
: capitalize(name);
}
const splitHumps = (name, separator) =>
name
.replace(/([a-z0-9])([A-Z])/g, `$1${separator}$2`)
.replace(/([A-Z]+)([A-Z][a-z])/g, `$1${separator}$2`);
export const toKebab = (value) => splitHumps(toDirName(value), '-').toLowerCase();
export const toTitle = (value) => splitHumps(toDirName(value), ' ');
export const toConst = (value) => toKebab(value).replace(/-/g, '_').toUpperCase();
export const toCamel = (value) => {
const dir = toDirName(value);
return dir.charAt(0).toLowerCase() + dir.slice(1);
};
export function tokensFor(name) {
return {
__Pascal__: toDirName(name),
__kebab__: toKebab(name),
__camel__: toCamel(name),
__CONST__: toConst(name),
__Title__: toTitle(name),
};
}
export function substitute(text, tokens) {
return Object.entries(tokens).reduce(
(acc, [token, value]) => acc.split(token).join(value),
text,
);
}
export const routeKey = (segments, view) =>
[...segments, ...(view ? [view] : [])].map(toConst).join('_');
export const routePath = (segments, view) =>
`/${[...segments, ...(view ? [view] : [])].map(toKebab).join('/')}`;
// Every path under a shell renders the shell itself (RouteTab picks the tab, the base path
// redirects to the first tab), so the page component is always the first segment.
export function routeSpec(segments, views) {
const shell = segments[0];
const component = {
name: `${shell}Page`,
importPath: `pages/${shell}`,
chunk: `${toTitle(shell)} Page`,
};
if (views.length) {
const tabs = views.map((view) => ({
key: routeKey(segments, view),
path: routePath(segments, view),
}));
const keys = [
{ key: `${routeKey(segments)}_BASE`, path: routePath(segments) },
...tabs,
];
return { component, keys, routed: keys.map(({ key }) => key) };
}
const key = routeKey(segments);
return { component, keys: [{ key, path: routePath(segments) }], routed: [key] };
}

View File

@@ -0,0 +1,102 @@
# Frontend layout
Target structure for `frontend/src`. Inspired by Bulletproof React and Feature-Sliced
Design: a feature owns its components, hooks, state, types and tests, and nothing outside
the feature folder reaches into it.
```
src/
app/ # bootstrap: routing, global styles/theme
pages/
Traces/ # has a shell
index.tsx # shell — tab switching only
constants.tsx # tab definitions
Explorer/ # a view
index.tsx # view entry — composition, no business logic
components/
QueryBar/ # same shape as a global component, nests further as needed
QueryBar.tsx
QueryBar.module.scss
components/
hooks/
__tests__/
hooks/ # feature hooks + React Query wrappers over api/generated
store/ # Zustand stores for feature-local client state
types.ts
utils.ts
constants.ts
__tests__/
README.md
Funnels/
Views/
ApiMonitoring/ # no shell — same shape, one level up
index.tsx
components/
hooks/
store/
types.ts
utils.ts
constants.ts
__tests__/
README.md
components/ # cross-feature components, same internal shape as above
DataTable/
DataTable.tsx
DataTable.module.scss
components/
hooks/
store/
types.ts
utils.ts
constants.ts
__tests__/
README.md
lib/
utils/
types/
constants/
store/ # app-wide client state only
i18n/
api/
generated/ # Orval output — never edited by hand
client/ # axios instances, interceptors, error handlers
index.tsx
```
## Rules
- **Folder names are PascalCase**, spelled the way the feature is spelled in the product
(`ApiMonitoring`, `LLMObservability`). This holds for shells, views and components alike.
- **A page folder is the unit of ownership.** Anything used by exactly one feature lives
inside it, however deeply nested. Promote to `src/components` / `src/utils` / `src/hooks`
only when a second feature needs it.
- **`index.tsx` is the entry**, and it composes. Business logic goes to `hooks/`, data
shaping to `utils.ts`, state to `store/`.
- **Nested components repeat the same shape.** A component folder may hold its own
`components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `__tests__/`.
Nest as deep as ownership actually goes; don't flatten a component that owns children.
- **Shell vs no shell.** A page with tabs gets a shell `index.tsx` whose only job is tab
switching, plus one folder per view. A page without tabs is just the feature folder.
- **Tests.** Feature-root tests in `__tests__/`; a component's tests next to the component
(its own `__tests__/`). Never reach across features in a test.
- **No barrel files.** A page's `index.tsx` is the route entry (a component), not a
re-export hub. Import components by their own path.
- **File size.** Split past ~300 LOC: extract components, and behaviour into
`use<Component>Callbacks`-style hooks. More than ~3 type declarations in a file means a
`types.ts`, and more than ~3 in `types.ts` means a `types/` folder.
- **Styling.** CSS Modules (`<Name>.module.scss`) next to the component — see
`docs/css-modules-guide.md`. Semantic tokens only.
- **State.** Server → React Query (prefer `api/generated` hooks); URL → nuqs; client →
Zustand, one store per file, always with a selector. No Redux or Context for new code.
## Migrating existing code
Most feature code still lives in `src/container` and `src/modules`, with a thin wrapper in
`src/pages`. When touching one of those features:
1. Scaffold the target with `pnpm scaffold page <Name>` (see `../SKILL.md`).
2. Move files in, one concern per commit — components, then hooks, then state.
3. Update importers; keep `src/container/<Feature>` deleted, not re-exported. A shim
directory is how the old layout survives.
4. Do the dead-code pass first: unused props, exports, imports and debug logs go before the
move, in their own commit.

View File

@@ -0,0 +1,606 @@
#!/usr/bin/env node
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
routeKey,
routeSpec,
substitute,
toCamel,
toDirName,
toKebab,
toTitle,
tokensFor,
} from './lib.mjs';
const SKILL_DIR = dirname(fileURLToPath(import.meta.url));
const TEMPLATES = join(SKILL_DIR, 'templates');
const FRONTEND = resolve(SKILL_DIR, '..', '..', '..');
const SRC = join(FRONTEND, 'src');
const ROUTE_FILES = {
routes: join(SRC, 'constants', 'routes.ts'),
permission: join(SRC, 'utils', 'permission', 'index.ts'),
pageComponents: join(SRC, 'AppRoutes', 'pageComponents.ts'),
appRoutes: join(SRC, 'AppRoutes', 'routes.ts'),
topNav: join(SRC, 'container', 'TopNav', 'DateTimeSelectionV2', 'constants.ts'),
};
const ROUTE_ROLES = "['ADMIN', 'EDITOR', 'VIEWER']";
// Port is fixed in vite.config.ts; the base path comes from VITE_BASE_PATH like vite does.
const DEV_SERVER_ORIGIN = 'http://localhost:3301';
function devServerUrl(path) {
const base = process.env.VITE_BASE_PATH ?? envFileValue('VITE_BASE_PATH') ?? '/';
return `${DEV_SERVER_ORIGIN}${base.replace(/\/+$/, '')}${path}`;
}
function envFileValue(name) {
const envFile = join(FRONTEND, '.env');
if (!existsSync(envFile)) {
return undefined;
}
const match = readFileSync(envFile, 'utf8').match(
new RegExp(`^\\s*${name}\\s*=\\s*["']?([^"'\\n#]*)`, 'm'),
);
return match?.[1].trim() || undefined;
}
// Created empty, so the folder exists before it has a file to justify it.
const FEATURE_DIRS = ['components', 'hooks', 'store'];
const USAGE = `usage:
pnpm scaffold page <Name> [--views A,B,C] [--no-tests] [--dry-run] [--force]
pnpm scaffold component <Name> [--parent <path>] [--full] [--no-tests] [--dry-run] [--force]
examples:
pnpm scaffold page ApiMonitoring
pnpm scaffold page Traces --views Explorer,Funnels,Views
pnpm scaffold page Traces/Explorer
pnpm scaffold component DataTable
pnpm scaffold component QueryBar --parent pages/Traces/Explorer`;
function fail(message) {
process.stderr.write(`error: ${message}\n\n${USAGE}\n`);
process.exit(1);
}
function expandEquals(argv) {
return argv.flatMap((arg) =>
arg.startsWith('--') && arg.includes('=')
? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
: [arg],
);
}
function parseArgs(argv) {
const flags = {
views: [],
parent: 'components',
full: false,
tests: true,
dryRun: false,
force: false,
};
const positional = [];
const provided = new Set();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
provided.add(arg);
if (arg === '--views' || arg === '--parent') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
fail(`${arg} needs a value`);
}
if (arg === '--views') {
flags.views = value
.split(',')
.map((view) => view.trim())
.filter(Boolean);
if (!flags.views.length) {
fail('--views needs at least one name');
}
} else {
flags.parent = value;
}
i += 1;
} else if (arg === '--full') {
flags.full = true;
} else if (arg === '--no-tests') {
flags.tests = false;
} else if (arg === '--dry-run') {
flags.dryRun = true;
} else if (arg === '--force') {
flags.force = true;
} else if (arg === '-h' || arg === '--help') {
process.stdout.write(`${USAGE}\n`);
process.exit(0);
} else if (arg.startsWith('-')) {
fail(`unknown option: ${arg}`);
} else {
positional.push(arg);
}
}
return { positional, flags, provided };
}
const created = [];
const skipped = [];
let targetExisted = false;
let pagePath = '';
function writeFile(target, contents, flags) {
const rel = relative(FRONTEND, target);
if (existsSync(target) && !flags.force) {
skipped.push(rel);
return;
}
if (!flags.dryRun) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents);
}
created.push(rel);
}
function createDirs(targetDir, dirs, flags) {
for (const dir of dirs) {
const target = join(targetDir, dir);
const rel = `${relative(FRONTEND, target)}/`;
if (existsSync(target)) {
skipped.push(rel);
continue;
}
if (!flags.dryRun) {
mkdirSync(target, { recursive: true });
}
created.push(rel);
}
}
// Template files carry a `.tmpl` suffix so no TypeScript, lint or editor tooling
// treats them as source; the suffix is dropped on the way out.
function renderTree(templateDir, targetDir, tokens, flags) {
for (const entry of readdirSync(templateDir).sort()) {
const from = join(templateDir, entry);
const name = substitute(entry.replace(/\.tmpl$/, ''), tokens);
if (statSync(from).isDirectory()) {
if (!flags.tests && name === '__tests__') {
continue;
}
renderTree(from, join(targetDir, name), tokens, flags);
} else {
writeFile(
join(targetDir, name),
substitute(readFileSync(from, 'utf8'), tokens),
flags,
);
}
}
}
// Icons for tab names the product already uses; anything else gets a neutral one.
const TAB_ICONS = {
Explorer: 'Compass',
Funnels: 'Cone',
Pipelines: 'Workflow',
SavedViews: 'TowerControl',
Views: 'TowerControl',
};
const DEFAULT_TAB_ICON = 'LayoutPanelTop';
const tabIcon = (view) => TAB_ICONS[toDirName(view)] ?? DEFAULT_TAB_ICON;
const tabName = (view) => `${toCamel(view)}Tab`;
function shellTokens(segments, views) {
const icons = [...new Set(views.map(tabIcon))].sort((a, b) => a.localeCompare(b));
const viewImports = views
.map((view) => `import ${toDirName(view)} from './${toDirName(view)}';`)
.join('\n');
const tabExports = views
.map((view) => {
const route = `ROUTES.${routeKey(segments, view)}`;
return [
`export const ${tabName(view)}: TabRoutes = {`,
`\tComponent: ${toDirName(view)},`,
'\tname: (',
'\t\t<div className={styles.tabItem}>',
`\t\t\t<${tabIcon(view)} size={16} /> ${toTitle(view)}`,
'\t\t</div>',
'\t),',
`\troute: ${route},`,
`\tkey: ${route},`,
'};',
].join('\n');
})
.join('\n\n');
const tabAssertions = views
.map(
(view) =>
`\t\texpect(screen.getByRole('tab', { name: '${toTitle(view)}' })).toBeInTheDocument();\n`,
)
.join('');
return {
__ICON_IMPORTS__: `import { ${icons.join(', ')} } from '@signozhq/icons';`,
__VIEW_IMPORTS__: viewImports,
__TAB_EXPORTS__: `${tabExports}\n`,
__TAB_NAMES__: views.map(tabName).join(', '),
__BASE_ROUTE__: `ROUTES.${routeKey(segments)}_BASE`,
__FIRST_TAB__: tabName(views[0]),
__FIRST_VIEW_TESTID__: `${toKebab(views[0])}-page`,
__TAB_ASSERTIONS__: tabAssertions,
};
}
const edited = [];
function insertBefore(source, anchor, text, rel, from = 0) {
const index = source.indexOf(anchor, from);
if (index === -1) {
fail(`could not find \`${anchor.trim()}\` in ${rel}`);
}
return source.slice(0, index) + text + source.slice(index);
}
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// An existing key or export is only reused when it already means what the generator
// would have written; anything else is a naming collision and stops the run before
// any shared file is touched.
function assertSame(rel, what, existing, expected) {
if (existing !== expected) {
fail(
`${what} already exists in ${rel} as ${existing}, expected ${expected} — ` +
'pick another name',
);
}
}
function planRoutes({ component, keys, routed }) {
return [
{
file: ROUTE_FILES.routes,
transform: (source, rel) => {
const added = keys.filter(({ key, path }) => {
const match = source.match(new RegExp(`\\n\\t${key}: '([^']*)',`));
if (match) {
assertSame(rel, `ROUTES.${key}`, `'${match[1]}'`, `'${path}'`);
}
return !match;
});
const text = added.map(({ key, path }) => `\n\t${key}: '${path}',`).join('');
return {
source: insertBefore(source, '\n} as const;', text, rel),
added: added.map(({ key }) => key),
};
},
},
{
file: ROUTE_FILES.permission,
transform: (source, rel) => {
const start = source.indexOf('export const routePermission');
if (start === -1) {
fail(`could not find \`routePermission\` in ${rel}`);
}
const added = keys
.map(({ key }) => key)
.filter((key) => !source.includes(`\n\t${key}: `));
const text = added.map((key) => `\n\t${key}: ${ROUTE_ROLES},`).join('');
return { source: insertBefore(source, '\n};', text, rel, start), added };
},
},
{
file: ROUTE_FILES.pageComponents,
transform: (source, rel) => {
const existing = source.match(
new RegExp(`export const ${component.name} = Loadable\\([\\s\\S]*?'([^']+)'`),
);
if (existing) {
assertSame(rel, component.name, `'${existing[1]}'`, `'${component.importPath}'`);
return { source, added: [] };
}
const text =
`\nexport const ${component.name} = Loadable(\n` +
`\t() => import(/* webpackChunkName: "${component.chunk}" */ '${component.importPath}'),\n);\n`;
return {
source: source.replace(/\n*$/, '\n') + text,
added: [component.name],
};
},
},
{
file: ROUTE_FILES.appRoutes,
transform: (source, rel) => {
const added = [];
let next = source;
const importEnd = next.indexOf("} from './pageComponents';");
const importStart = next.lastIndexOf('import {', importEnd);
if (importEnd === -1 || importStart === -1) {
fail(`could not find the pageComponents import in ${rel}`);
}
const names = next
.slice(importStart + 'import {'.length, importEnd)
.split(',')
.map((name) => name.trim())
.filter(Boolean);
if (!names.includes(component.name)) {
const lower = component.name.toLowerCase();
const at = names.findIndex((name) => name.toLowerCase() > lower);
names.splice(at === -1 ? names.length : at, 0, component.name);
next =
next.slice(0, importStart) +
`import {\n\t${names.join(',\n\t')},\n` +
next.slice(importEnd);
added.push(`import ${component.name}`);
}
const arrayStart = next.indexOf('const routes: AppRoutes[] = [');
if (arrayStart === -1) {
fail(`could not find \`const routes: AppRoutes[]\` in ${rel}`);
}
const missing = routed.filter((key) => {
const match = next.match(
new RegExp(`component: (\\w+),\\n\\t\\tkey: '${escapeRegExp(key)}',`),
);
if (match) {
assertSame(rel, `route ${key}`, match[1], component.name);
}
return !match;
});
const entries = missing
.map((key) =>
[
'\n\t{',
`\t\tpath: ROUTES.${key},`,
'\t\texact: true,',
`\t\tcomponent: ${component.name},`,
`\t\tkey: '${key}',`,
'\t\tisPrivate: true,',
'\t},',
].join('\n'),
)
.join('');
next = insertBefore(next, '\n];', entries, rel, arrayStart);
added.push(...missing);
return { source: next, added };
},
},
{
file: ROUTE_FILES.topNav,
transform: (source, rel) => {
const start = source.indexOf('export const routesToSkip = [');
if (start === -1) {
fail(`could not find \`routesToSkip\` in ${rel}`);
}
const end = source.indexOf('\n];', start);
const block = source.slice(start, end);
const added = routed.filter((key) => !block.includes(`ROUTES.${key},`));
const text = added.map((key) => `\n\tROUTES.${key},`).join('');
return { source: insertBefore(source, '\n];', text, rel, start), added };
},
},
];
}
// Every shared file is read and validated before any is written, so a failed anchor or
// a naming collision leaves the tree untouched.
function planRouteEdits(spec) {
return planRoutes(spec).map(({ file, transform }) => {
const rel = relative(FRONTEND, file);
if (!existsSync(file)) {
fail(`shared file not found: ${rel}`);
}
const { source, added } = transform(readFileSync(file, 'utf8'), rel);
return { file, rel, source, added };
});
}
function commitRouteEdits(pending, flags) {
for (const { file, rel, source, added } of pending) {
if (!added.length) {
continue;
}
if (!flags.dryRun) {
writeFileSync(file, source);
}
edited.push({ rel, added });
}
}
function scaffoldFeature(targetDir, name, flags) {
renderTree(join(TEMPLATES, 'feature'), targetDir, tokensFor(name), flags);
createDirs(targetDir, FEATURE_DIRS, flags);
}
function scaffoldPage(name, flags) {
const segments = name.split('/').filter(Boolean).map(toDirName);
if (!segments.length) {
fail('page needs a name');
}
const viewDirs = flags.views.map(toDirName);
const duplicate = viewDirs.find((dir, index) => viewDirs.indexOf(dir) !== index);
if (duplicate) {
fail(`duplicate view: ${duplicate}`);
}
const targetDir = join(SRC, 'pages', ...segments);
const leaf = segments[segments.length - 1];
targetExisted = existsSync(targetDir);
// Shared files land before the page folder so a watching type-checker never sees a
// page that references ROUTES keys that do not exist yet.
const spec = routeSpec(segments, flags.views);
commitRouteEdits(planRouteEdits(spec), flags);
pagePath = spec.keys[0].path;
if (flags.views.length) {
renderTree(
join(TEMPLATES, 'shell'),
targetDir,
{ ...tokensFor(leaf), ...shellTokens(segments, flags.views) },
flags,
);
for (const view of flags.views) {
scaffoldFeature(join(targetDir, toDirName(view)), view, flags);
}
} else {
scaffoldFeature(targetDir, leaf, flags);
}
return targetDir;
}
function resolveParent(parent) {
const segments = parent
.replace(/^src\//, '')
.replace(/\/components\/?$/, '')
.split('/')
.filter(Boolean);
if (segments[0] === 'pages') {
return ['pages', ...segments.slice(1).map(toDirName)];
}
return segments;
}
function scaffoldComponent(name, flags) {
const tokens = tokensFor(name);
const parent = resolveParent(flags.parent);
const isGlobal = parent.length === 1 && parent[0] === 'components';
const componentsDir = isGlobal
? join(SRC, 'components')
: join(SRC, ...parent, 'components');
if (relative(SRC, componentsDir).startsWith('..')) {
fail(`--parent must stay inside src: ${flags.parent}`);
}
if (parent[0] === 'pages' && parent.length < 2) {
fail('a component under pages/ needs a feature: --parent pages/<Feature>');
}
if (!isGlobal && !existsSync(join(SRC, ...parent))) {
fail(`parent does not exist: src/${parent.join('/')}`);
}
const targetDir = join(componentsDir, tokens.__Pascal__);
targetExisted = existsSync(targetDir);
renderTree(join(TEMPLATES, 'component'), targetDir, tokens, flags);
if (flags.full) {
renderTree(join(TEMPLATES, 'component-extras'), targetDir, tokens, flags);
createDirs(targetDir, FEATURE_DIRS, flags);
}
return targetDir;
}
function report(kind, targetDir, flags) {
const rel = relative(FRONTEND, targetDir);
const verb = flags.dryRun ? 'would create' : 'created';
const segments = rel.split('/').slice(2);
const isNestedView = kind === 'page' && segments.length > 1 && !flags.views.length;
const leafName = segments[segments.length - 1];
if (targetExisted) {
process.stdout.write(
`\nwarning: ${rel} already existed — only missing entries were added\n`,
);
}
process.stdout.write(`\n${verb} ${created.length} entr(ies) in ${rel}\n`);
for (const entry of created) {
process.stdout.write(` + ${entry}\n`);
}
if (skipped.length) {
process.stdout.write(
`\nskipped ${skipped.length} existing entr(ies) — pass --force to overwrite files\n`,
);
for (const entry of skipped) {
process.stdout.write(` = ${entry}\n`);
}
}
if (edited.length) {
const editVerb = flags.dryRun ? 'would edit' : 'edited';
process.stdout.write(`\n${editVerb} ${edited.length} shared file(s)\n`);
for (const { rel, added } of edited) {
const additions = added.map((entry) => `+${entry}`).join(', ');
process.stdout.write(` ~ ${rel}: ${additions}\n`);
}
}
const steps =
kind === 'page'
? [
'review the route registration (constants/routes.ts, utils/permission, AppRoutes/pageComponents.ts, AppRoutes/routes.ts, TopNav routesToSkip) and add a SideNav entry in container/SideNav/menuItems.tsx if the page needs one',
...(isNestedView
? [
`add a tab export for ${leafName} in the shell's constants.tsx and include it in the routes array in the shell's index.tsx`,
]
: []),
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
'fill in README.md',
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
]
: [
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
];
if (pagePath) {
process.stdout.write(`\nopen: ${devServerUrl(pagePath)}\n`);
}
process.stdout.write('\nnext:\n');
steps.forEach((step, index) => {
process.stdout.write(` ${index + 1}. ${step}\n`);
});
process.stdout.write(
'\nnote: git does not track empty folders — components/, hooks/ and store/ only\n' +
'show up in a commit once they hold a file.\n',
);
}
const { positional, flags, provided } = parseArgs(expandEquals(process.argv.slice(2)));
const [kind, name] = positional;
if (!kind || !name) {
fail('a command and a name are required');
}
if (positional.length > 2) {
fail(`unexpected argument: ${positional[2]}`);
}
function rejectFlags(unsupported) {
for (const flag of unsupported) {
if (provided.has(flag)) {
fail(`${flag} does not apply to \`${kind}\``);
}
}
}
let targetDir;
try {
if (kind === 'page') {
rejectFlags(['--parent', '--full']);
targetDir = scaffoldPage(name, flags);
} else if (kind === 'component') {
rejectFlags(['--views']);
targetDir = scaffoldComponent(name, flags);
} else {
fail(`unknown command: ${kind}`);
}
} catch (error) {
fail(error.message);
}
report(kind, targetDir, flags);

View File

@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
routeKey,
routePath,
routeSpec,
substitute,
toCamel,
toConst,
toDirName,
toKebab,
toTitle,
tokensFor,
} from './lib.mjs';
describe('names', () => {
it('keeps typed casing and forces the first letter up', () => {
assert.equal(toDirName('LLMObservability'), 'LLMObservability');
assert.equal(toDirName('apiMonitoring'), 'ApiMonitoring');
});
it('collapses separated names to PascalCase', () => {
assert.equal(toDirName('api-monitoring'), 'ApiMonitoring');
assert.equal(toDirName('api monitoring'), 'ApiMonitoring');
assert.equal(toDirName('saved_views'), 'SavedViews');
});
it('derives kebab, title, const and camel forms, splitting acronyms', () => {
assert.deepEqual(tokensFor('LLMObservability'), {
__Pascal__: 'LLMObservability',
__kebab__: 'llm-observability',
__camel__: 'lLMObservability',
__CONST__: 'LLM_OBSERVABILITY',
__Title__: 'LLM Observability',
});
assert.equal(toKebab('SavedViews'), 'saved-views');
assert.equal(toTitle('SavedViews'), 'Saved Views');
assert.equal(toConst('SavedViews'), 'SAVED_VIEWS');
assert.equal(toCamel('SavedViews'), 'savedViews');
});
it('rejects names with no usable characters', () => {
assert.throws(() => toDirName('***'), /no usable name characters/);
});
});
describe('substitute', () => {
it('replaces every occurrence of every token, in file names and contents', () => {
const tokens = tokensFor('ApiMonitoring');
assert.equal(substitute('__Pascal__.module.scss', tokens), 'ApiMonitoring.module.scss');
assert.equal(
substitute('__kebab__-page / __kebab__-shell / __Title__', tokens),
'api-monitoring-page / api-monitoring-shell / Api Monitoring',
);
});
});
describe('routes', () => {
it('builds keys and paths from every segment plus the view', () => {
assert.equal(routeKey(['Traces'], 'SavedViews'), 'TRACES_SAVED_VIEWS');
assert.equal(routePath(['Traces'], 'SavedViews'), '/traces/saved-views');
assert.equal(routeKey(['Traces', 'Explorer']), 'TRACES_EXPLORER');
assert.equal(routePath(['Traces', 'Explorer']), '/traces/explorer');
});
it('routes a leaf page under a single key', () => {
assert.deepEqual(routeSpec(['ApiMonitoring'], []), {
component: {
name: 'ApiMonitoringPage',
importPath: 'pages/ApiMonitoring',
chunk: 'Api Monitoring Page',
},
keys: [{ key: 'API_MONITORING', path: '/api-monitoring' }],
routed: ['API_MONITORING'],
});
});
it('routes a shell under a base key plus one key per view, all to the shell', () => {
const spec = routeSpec(['Traces'], ['Explorer', 'Funnels']);
assert.equal(spec.component.name, 'TracesPage');
assert.deepEqual(spec.keys, [
{ key: 'TRACES_BASE', path: '/traces' },
{ key: 'TRACES_EXPLORER', path: '/traces/explorer' },
{ key: 'TRACES_FUNNELS', path: '/traces/funnels' },
]);
assert.deepEqual(spec.routed, ['TRACES_BASE', 'TRACES_EXPLORER', 'TRACES_FUNNELS']);
});
it('points a view added under an existing shell at the shell component', () => {
const spec = routeSpec(['Traces', 'Explorer'], []);
assert.equal(spec.component.importPath, 'pages/Traces');
assert.deepEqual(spec.keys, [{ key: 'TRACES_EXPLORER', path: '/traces/explorer' }]);
});
});

View File

@@ -0,0 +1,21 @@
# __Pascal__
<!-- What this component renders, and the features that use it. -->
## API
<!-- Props, and the behaviour each one controls. -->
## Structure
| Path | Purpose |
| --- | --- |
| `__Pascal__.tsx` | The component. |
| `__Pascal__.module.scss` | Styles. |
| `components/` | Child components this one owns. |
| `hooks/` | Behaviour extracted out of the component. |
| `store/` | Zustand stores this component owns. |
| `types.ts` | Types shared inside this folder. |
| `utils.ts` | Pure helpers. |
| `constants.ts` | Constants. |
| `__tests__/` | Tests. |

View File

@@ -0,0 +1,4 @@
.__camel__ {
display: flex;
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,7 @@
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
return <div className={styles.__camel__} data-testid="__kebab__" />;
}
export default __Pascal__;

View File

@@ -0,0 +1,11 @@
import { render, screen } from 'tests/test-utils';
import __Pascal__ from '../__Pascal__';
describe('__Pascal__', () => {
it('renders', () => {
render(<__Pascal__ />);
expect(screen.getByTestId('__kebab__')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
# __Title__
<!-- One paragraph: what this feature does, who uses it, and where it is reachable from. -->
## Structure
| Path | Purpose |
| --- | --- |
| `index.tsx` | Feature entry. Composition only — no business logic. |
| `components/` | Feature-local components, nested as `components/<Name>/`. |
| `hooks/` | Feature hooks, including React Query wrappers over `api/generated`. |
| `store/` | Zustand stores for feature-local client state. |
| `types.ts` | Shared feature types. Split into `types/` past ~3 declarations. |
| `utils.ts` | Pure helpers. |
| `constants.ts` | Feature constants. |
| `__tests__/` | Feature-root tests. Component tests live with the component. |
## Data
<!-- Endpoints this feature reads/writes, and the hooks that wrap them. -->
## State
<!-- What lives in the URL (nuqs), what lives in React Query, what lives in store/. -->
## Routing
<!-- Route key in constants/routes.ts, lazy import in AppRoutes/pageComponents.ts, entry in AppRoutes/routes.ts. -->

View File

@@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
background: var(--l1-background);
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
}

View File

@@ -0,0 +1,11 @@
import { render, screen } from 'tests/test-utils';
import __Pascal__ from '../index';
describe('__Pascal__', () => {
it('renders the page', () => {
render(<__Pascal__ />);
expect(screen.getByTestId('__kebab__-page')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
return (
<section className={styles.container} data-testid="__kebab__-page">
<h1 className={styles.title}>__Title__</h1>
</section>
);
}
export default __Pascal__;

View File

@@ -0,0 +1,20 @@
# __Title__
<!-- One paragraph: what this section of the product is, and what each tab is for. -->
## Structure
| Path | Purpose |
| --- | --- |
| `index.tsx` | Shell. Tab switching only — no feature logic. |
| `constants.tsx` | One `TabRoutes` export per tab: icon, label, route and the view it renders. |
| `<View>/` | One folder per tab, each a self-contained feature. |
## Routing
Every path is registered in `src/constants/routes.ts`, `src/utils/permission/index.ts`,
`src/AppRoutes/routes.ts` and the `routesToSkip` list in
`src/container/TopNav/DateTimeSelectionV2/constants.ts`, all rendering this shell through the
lazy import in `src/AppRoutes/pageComponents.ts`. The base path redirects to the first tab;
`RouteTab` picks the tab from the current path. Adding a tab means a new `ROUTES` key, a
route entry, a permission entry, a `routesToSkip` entry and a `TabRoutes` export here.

View File

@@ -0,0 +1,21 @@
.shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:global(.ant-tabs-nav) {
padding: 0 var(--spacing-8);
margin-bottom: 0;
&::before {
border-bottom: 1px solid var(--l1-border) !important;
}
}
}
.tabItem {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,25 @@
import { render, screen } from 'tests/test-utils';
import ROUTES from 'constants/routes';
import { __FIRST_TAB__ } from '../constants';
import __Pascal__ from '../index';
describe('__Pascal__', () => {
it('renders one tab per view', () => {
render(<__Pascal__ />, undefined, { initialRoute: __FIRST_TAB__.route });
expect(screen.getByTestId('__kebab__-shell')).toBeInTheDocument();
__TAB_ASSERTIONS__ });
it('renders the view for the active tab', () => {
render(<__Pascal__ />, undefined, { initialRoute: __FIRST_TAB__.route });
expect(screen.getByTestId('__FIRST_VIEW_TESTID__')).toBeInTheDocument();
});
it('redirects the base path to the first tab', () => {
render(<__Pascal__ />, undefined, { initialRoute: __BASE_ROUTE__ });
expect(screen.getByTestId('__FIRST_VIEW_TESTID__')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,9 @@
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
__ICON_IMPORTS__
__VIEW_IMPORTS__
import styles from './__Pascal__.module.scss';
__TAB_EXPORTS__

View File

@@ -0,0 +1,32 @@
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import RouteTab from 'components/RouteTab';
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { __TAB_NAMES__ } from './constants';
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
const { pathname } = useLocation();
const routes: TabRoutes[] = [__TAB_NAMES__];
if (matchPath(pathname, { path: __BASE_ROUTE__, exact: true })) {
return <Redirect to={routes[0].route} />;
}
return (
<div className={styles.shell} data-testid="__kebab__-shell">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
/>
</div>
);
}
export default __Pascal__;

4
frontend/.gitignore vendored
View File

@@ -34,5 +34,5 @@ e2e/test-plan/user-preferences/
/storybook-static/
debug-storybook.log
# Storybook screenshot sweeps (scripts/story-shots.mjs)
/.story-shots/
# Storybook screenshots (sbshot)
/.sbshot/

View File

@@ -295,6 +295,8 @@
// 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",
{
@@ -336,6 +338,10 @@
"name": "api/generated/services/dashboard",
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
},
{
"name": "motion/react-client",
"message": "[motion] motion/react-client is for React Server Components only. Use 'motion/react' instead — it provides the same exports and works correctly in client-side SPAs."
}
]
}
@@ -576,17 +582,6 @@
"rules": {
"signoz/no-dashboard-fetch-outside-root": "off"
}
},
{
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
// algorithms that read worse when split up.
"files": [
"scripts/**"
],
"rules": {
"no-console": "off",
"sonarjs/cognitive-complexity": "off"
}
}
]
}

View File

@@ -27,12 +27,22 @@ 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`,
},
];
/**
@@ -55,12 +65,12 @@ const isExcluded = (plugin: PluginOption): boolean =>
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.@(ts|tsx)'],
stories: ['../src/storybook/docs/**/*.mdx', '../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'],
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
core: { disableTelemetry: true },
viteFinal: async (viteConfig) => {
const plugins = (viteConfig.plugins ?? [])
@@ -77,6 +87,14 @@ 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,

View File

@@ -1,7 +1,7 @@
/**
* Chromatic modes: one snapshot per entry, per story. The globals in a mode are
* Storybook's own, so `theme` is the toolbar's theme and the story renders the
* way it does locally. The width matches `scripts/story-shots.mjs` (`--width`),
* way it does locally. The width matches sbshot's default `--width`,
* so a cloud snapshot and a local shot frame the same page.
*/
export const allModes = {

View File

@@ -6,6 +6,17 @@
-->
<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>
@@ -24,3 +35,38 @@
},
};
</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>

View File

@@ -1,8 +1,11 @@
import type { Preview } from '@storybook/react-vite';
import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
import { configure } from 'storybook/test';
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';
@@ -13,7 +16,12 @@ import {
} from '../src/storybook/runtime/resolveStory';
import { allModes } from './modes';
import '../src/ReactI18';
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/styles.scss';
@@ -63,10 +71,131 @@ 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),
),
);
// testing-library's 1s default for `findBy*` and `waitFor` is shorter than a
// popup or a query takes to land on a loaded CI runner.
configure({ asyncUtilTimeout: 10_000 });
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
@@ -74,6 +203,9 @@ 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',
@@ -119,12 +251,21 @@ const preview: Preview = {
world.apply();
world.install(worker);
await ready;
await Promise.all([ready, translationsReady]);
},
],
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,

View File

@@ -88,10 +88,16 @@ self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// 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 navigation requests.
if (request.mode === 'navigate') {

View File

@@ -25,7 +25,25 @@ const IGNORED_MESSAGES = [
/violates the following Content Security Policy directive/,
];
const messagesByPage = new WeakMap<Page, string[]>();
interface CapturedMessage {
at: number;
text: string;
}
const PREPARE_TIMEOUT_MS = 180_000;
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);
/**
* Only `console.error` fails a story. `console.warn` is dev-time advice from
@@ -36,6 +54,15 @@ const config: TestRunnerConfig = {
// msw logs every mocked request at `log`; keep it out of the failure dump
// unless the job is re-run with debug logging (GitHub sets RUNNER_DEBUG=1).
logLevel: process.env.RUNNER_DEBUG === '1' ? 'info' : 'warn',
// The runner's default prepare, minus Playwright's 30s navigation timeout: on
// a cold dev server the first load compiles the whole preview, which outlasts
// it on a shared CI runner.
async prepare({ page }): Promise<void> {
await page.goto(new URL('iframe.html', process.env.TARGET_URL).toString(), {
waitUntil: 'load',
timeout: PREPARE_TIMEOUT_MS,
});
},
async preVisit(page): Promise<void> {
const existing = messagesByPage.get(page);
if (existing) {
@@ -43,14 +70,14 @@ const config: TestRunnerConfig = {
return;
}
const messages: string[] = [];
const messages: CapturedMessage[] = [];
messagesByPage.set(page, messages);
page.on('console', (message) => {
if (
message.type() === 'error' &&
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
) {
messages.push(`[error] ${message.text()}`);
messages.push({ at: Date.now(), text: `[error] ${message.text()}` });
}
});
// The console message alone ("Failed to load resource") doesn't name the
@@ -58,12 +85,23 @@ const config: TestRunnerConfig = {
// actionable instead of just a status code.
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push(`[response] ${response.status()} ${response.url()}`);
messages.push({
at: Date.now(),
text: `[response] ${response.status()} ${response.url()}`,
});
}
});
},
async postVisit(page, context): Promise<void> {
const messages = messagesByPage.get(page) ?? [];
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);
if (messages.length === 0) {
return;
}

View File

@@ -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;

View File

@@ -10,6 +10,7 @@
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"test:storybook": "bash scripts/test-storybook.sh",
"scaffold": "node .claude/skills/scaffold-feature/scaffold.mjs",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",
@@ -162,6 +163,7 @@
"@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",

View File

@@ -0,0 +1,41 @@
/**
* 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' });
}
},
};
},
};

View File

@@ -15,6 +15,7 @@ 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: {
@@ -31,5 +32,6 @@ 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,
},
};

View File

@@ -363,6 +363,9 @@ 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)
@@ -2184,6 +2187,12 @@ 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==}
@@ -3766,6 +3775,15 @@ 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:
@@ -4189,6 +4207,9 @@ 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==}
@@ -12199,6 +12220,12 @@ 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
@@ -13619,6 +13646,25 @@ 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))
@@ -14064,6 +14110,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.2
'@types/mdx@2.0.14': {}
'@types/ms@0.7.31': {}
'@types/node@16.18.25': {}

View File

@@ -4,7 +4,6 @@
"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",
@@ -34,7 +33,6 @@
"NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset",
"LIST_LICENSES": "SigNoz | List of Licenses",
@@ -56,13 +54,13 @@
"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",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -14,7 +14,6 @@
"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",
@@ -43,8 +42,6 @@
"NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
"OLD_LOGS_EXPLORER": "SigNoz | Old Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"LOGS_PIPELINES": "SigNoz | Logs Pipelines",
"HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset",
@@ -79,7 +76,6 @@
"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",
@@ -88,6 +84,7 @@
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -1,167 +0,0 @@
import { spawnSync } from 'node:child_process';
/**
* The caption band both story-shots.mjs and story-shots-diff.mjs stamp on their
* output, and the ImageMagick plumbing under it. A shot records the band's
* height in `shots.json` so the diff can crop it back off before comparing:
* otherwise two runs whose captions differ would report the caption as a change.
*/
export const CONFIG_KEYS = [
'args',
'clock',
'width',
'height',
'grow',
'motion',
'settle',
'ignore',
];
let tools;
const detect = () =>
(tools ??= {
seven: spawnSync('magick', ['-version']).status === 0,
six: spawnSync('convert', ['-version']).status === 0,
});
export const hasMagick = () => {
const { seven, six } = detect();
return seven || six;
};
export const requireMagick = () => {
if (hasMagick()) {
return;
}
console.error(
'ImageMagick not found. Install it (brew install imagemagick, apt install imagemagick).',
);
process.exit(1);
};
export const magick = (args, input) => {
// ImageMagick 6 has no `magick`: its tools are separate binaries.
const [command, ...rest] = detect().seven
? ['magick', ...args]
: ['identify', 'montage'].includes(args[0])
? args
: ['convert', ...args];
const result = spawnSync(command, rest, {
input,
maxBuffer: 1024 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command} ${rest.join(' ')}: ${result.stderr}`);
}
return result.stdout;
};
/**
* ImageMagick's built-in default is a serif that reads as a book, not as a
* screenshot label, so the band asks for what is installed: a sans for the
* heading, a mono for the lines that carry ids, args and numbers. An
* unrecognised name is fatal to `convert`, hence the check against the list it
* reports; a machine with none of them keeps the default.
*/
const FONTS = {
heading: [
'Helvetica-Bold',
'DejaVu-Sans-Bold',
'Liberation-Sans-Bold',
'Arial-Bold',
'Noto-Sans-Bold',
'DejaVu-Sans',
'Liberation-Sans',
],
body: [
'Menlo',
'DejaVu-Sans-Mono',
'Liberation-Mono',
'JetBrainsMono-NF-Regular',
'Courier',
],
};
let installed;
const fontArgs = (role) => {
installed ??= new Set(
[
...magick(['-list', 'font'])
.toString()
.matchAll(/^\s*Font:\s*(\S+)/gm),
].map(([, name]) => name),
);
const font = FONTS[role].find((name) => installed.has(name));
return font ? ['-font', font] : [];
};
/** Readable at fit-to-width, whatever the image is. */
export const pointsize = (width) =>
Math.min(Math.max(Math.round(width / 45), 24), 140);
// `label:` expands ImageMagick's own escapes and reads a file when the text
// starts with @, so story names and arg values go through neither.
export const bodyFont = () => fontArgs('body');
export const literal = (text) => text.replaceAll('%', '%%').replace(/^@/, ' @');
/** The gutter is the opposite of the theme, so the band keeps an edge. */
export const palette = (theme) =>
theme === 'light'
? { background: '#101014', foreground: '#f4f4f5' }
: { background: '#f4f4f5', foreground: '#101014' };
export const settingsLine = (config, keys = CONFIG_KEYS) =>
keys
.filter((key) => config?.[key])
.map((key) => `${key}:${config[key]}`)
.join(' ');
const heightOf = (file) => Number(magick(['identify', '-format', '%h', file]));
/**
* Writes `from` to `to` with `lines` above it, and returns how many rows that
* added — which is what a reader has to crop off to get the original back, so
* the band must never change the width. Each line is a `caption:` at the
* image's own width, wrapping instead of widening the canvas: a run whose
* caption is longer must still produce a shot the next run's shot pairs with.
* Type size follows the width, since a three-tile montage of 1680px shots is
* over 5000px wide and is read at fit-to-width.
*/
export const stamp = ({ lines, from, to, theme }) => {
const { background, foreground } = palette(theme);
const width = Number(magick(['identify', '-format', '%w', from]));
const heading = pointsize(width);
const before = heightOf(from);
const spacer = [
'-size',
`${width}x${Math.round(heading * 0.4)}`,
`xc:${background}`,
];
magick([
'-background',
background,
'-fill',
foreground,
'-gravity',
'center',
...spacer,
...lines.flatMap((line, index) => [
...fontArgs(index ? 'body' : 'heading'),
'-size',
`${width}x`,
'-pointsize',
String(index ? Math.round(heading * 0.62) : heading),
`caption:${literal(line)}`,
]),
...spacer,
from,
'-append',
to,
]);
return heightOf(to) - before;
};

View File

@@ -1,460 +0,0 @@
#!/usr/bin/env node
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import path from 'node:path';
import os from 'node:os';
import {
bodyFont,
CONFIG_KEYS,
literal,
magick,
palette,
pointsize,
requireMagick,
settingsLine,
stamp,
} from './story-shots-caption.mjs';
/**
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
* moved, per pair, largest first.
*
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
* distance from the baseline pixel is over `threshold` of the largest distance
* two colours can have, and pixels that are only antialiasing around an
* otherwise identical edge do not count. `threshold` is their `diffThreshold`
* and its default is theirs too.
*/
const MAX_YIQ_DELTA = 35_215;
const { values: opts, positionals } = parseArgs({
allowPositionals: true,
options: {
mode: { type: 'string', default: 'green' },
threshold: { type: 'string', default: '0.063' },
'include-aa': { type: 'boolean', default: false },
tint: { type: 'string', default: '' },
'no-caption': { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
});
const [baseDir, afterDir, outArg] = positionals;
const MODES = new Set(['green', 'green-parallel', 'red', 'red-parallel']);
if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
console.log(`usage: node scripts/story-shots-diff.mjs <baseline-dir> <after-dir> [diff-dir]
--mode green the after shot, changed pixels painted over it (default)
--mode green-parallel previous | current | green diff, side by side and labelled
--mode red the after shot faded out, changed pixels painted red
--mode red-parallel previous | current | red diff, side by side and labelled
--threshold <0..1> YIQ distance a pixel must move to count (default 0.063)
--include-aa count antialiasing changes too (default: ignore them)
--tint <#rrggbb> override the mode's highlight colour
--no-caption do not stamp the story and the run settings on top
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
process.exit(opts.help ? 0 : 1);
}
const outDir = outArg ?? path.join(path.dirname(baseDir), 'diff');
const threshold = Number(opts.threshold);
const maxDelta = MAX_YIQ_DELTA * threshold * threshold;
const highlight = hexToRgb(
opts.tint || (opts.mode.startsWith('green') ? '#00e05a' : '#ff003a'),
);
function hexToRgb(hex) {
const value = Number.parseInt(hex.replace('#', ''), 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
requireMagick();
/**
* `top` rows are dropped: story-shots.mjs stamps a caption on its shots and
* records how tall it is, and a caption is not part of what the two runs are
* being compared on.
*/
const readRgba = (file, top = 0) => {
const [width, height] = magick(['identify', '-format', '%w %h', file])
.toString()
.split(' ')
.map(Number);
const data = magick([file, '-depth', '8', 'RGBA:-']);
return top > 0 && top < height
? { width, height: height - top, data: data.subarray(top * width * 4) }
: { width, height, data };
};
const writeRgba = ({ width, height, data }, file) =>
writeFile(
file,
magick(
['-depth', '8', '-size', `${width}x${height}`, 'RGBA:-', 'png:-'],
data,
),
);
/* The pixelmatch colour maths, which is what Chromatic's threshold is scaled to. */
const y = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
/** Squared YIQ distance, signed by which pixel is brighter. */
const colorDelta = (a, b, posA, posB, yOnly = false) => {
let r1 = a[posA];
let g1 = a[posA + 1];
let b1 = a[posA + 2];
const a1 = a[posA + 3];
let r2 = b[posB];
let g2 = b[posB + 1];
let b2 = b[posB + 2];
const a2 = b[posB + 3];
if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) {
return 0;
}
// Anything translucent is composited over the same mid grey in both images,
// so a difference in alpha alone still registers.
if (a1 < 255) {
const alpha = a1 / 255;
r1 = r1 * alpha + 255 * (1 - alpha) * 0.5;
g1 = g1 * alpha + 255 * (1 - alpha) * 0.5;
b1 = b1 * alpha + 255 * (1 - alpha) * 0.5;
}
if (a2 < 255) {
const alpha = a2 / 255;
r2 = r2 * alpha + 255 * (1 - alpha) * 0.5;
g2 = g2 * alpha + 255 * (1 - alpha) * 0.5;
b2 = b2 * alpha + 255 * (1 - alpha) * 0.5;
}
const deltaY = y(r1, g1, b1) - y(r2, g2, b2);
if (yOnly) {
return deltaY;
}
const deltaI = i(r1, g1, b1) - i(r2, g2, b2);
const deltaQ = q(r1, g1, b1) - q(r2, g2, b2);
return (
0.5053 * deltaY * deltaY + 0.299 * deltaI * deltaI + 0.1957 * deltaQ * deltaQ
);
};
/**
* True when the pixel sits on an edge that is drawn one subpixel over rather
* than moved: it is the darkest or lightest of its neighbours in one image, and
* the other image has a pixel around there doing the same job.
*/
const antialiased = (a, x1, y1, width, height, b) => {
const x0 = Math.max(x1 - 1, 0);
const y0 = Math.max(y1 - 1, 0);
const x2 = Math.min(x1 + 1, width - 1);
const y2 = Math.min(y1 + 1, height - 1);
const pos = (y1 * width + x1) * 4;
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
let min = 0;
let max = 0;
let minX = 0;
let minY = 0;
let maxX = 0;
let maxY = 0;
for (let x = x0; x <= x2; x += 1) {
for (let yy = y0; yy <= y2; yy += 1) {
if (x === x1 && yy === y1) {
continue;
}
const delta = colorDelta(a, a, pos, (yy * width + x) * 4, true);
if (delta === 0) {
zeroes += 1;
if (zeroes > 2) {
return false;
}
} else if (delta < min) {
min = delta;
minX = x;
minY = yy;
} else if (delta > max) {
max = delta;
maxX = x;
maxY = yy;
}
}
}
if (min === 0 || max === 0) {
return false;
}
return (
(hasManySiblings(a, minX, minY, width, height) &&
hasManySiblings(b, minX, minY, width, height)) ||
(hasManySiblings(a, maxX, maxY, width, height) &&
hasManySiblings(b, maxX, maxY, width, height))
);
};
/** Whether the pixel has at least three identical neighbours. */
const hasManySiblings = (img, x1, y1, width, height) => {
const x0 = Math.max(x1 - 1, 0);
const y0 = Math.max(y1 - 1, 0);
const x2 = Math.min(x1 + 1, width - 1);
const y2 = Math.min(y1 + 1, height - 1);
const pos = (y1 * width + x1) * 4;
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
for (let x = x0; x <= x2; x += 1) {
for (let yy = y0; yy <= y2; yy += 1) {
if (x === x1 && yy === y1) {
continue;
}
const other = (yy * width + x) * 4;
if (
img[pos] === img[other] &&
img[pos + 1] === img[other + 1] &&
img[pos + 2] === img[other + 2] &&
img[pos + 3] === img[other + 3]
) {
zeroes += 1;
if (zeroes > 2) {
return true;
}
}
}
}
return false;
};
/**
* The changed pixels of the pair, painted over the after shot. The `red` modes
* fade the shot out first, the way a pixelmatch diff reads; the `green` ones
* leave it alone, the way Chromatic's does.
*/
const diffPair = (base, after, mode) => {
const width = Math.min(base.width, after.width);
const height = Math.min(base.height, after.height);
const out = Buffer.from(after.data);
const fade = !mode.startsWith('green');
let changed = 0;
if (fade) {
for (let pos = 0; pos < out.length; pos += 4) {
const grey = y(out[pos], out[pos + 1], out[pos + 2]);
const value = 255 + (grey - 255) * 0.1;
out[pos] = value;
out[pos + 1] = value;
out[pos + 2] = value;
out[pos + 3] = 255;
}
}
for (let row = 0; row < height; row += 1) {
for (let column = 0; column < width; column += 1) {
const basePos = (row * base.width + column) * 4;
const afterPos = (row * after.width + column) * 4;
const delta = colorDelta(base.data, after.data, basePos, afterPos);
if (Math.abs(delta) <= maxDelta) {
continue;
}
if (
!opts['include-aa'] &&
(antialiased(base.data, column, row, base.width, base.height, after.data) ||
antialiased(after.data, column, row, after.width, after.height, base.data))
) {
continue;
}
changed += 1;
out[afterPos] = highlight[0];
out[afterPos + 1] = highlight[1];
out[afterPos + 2] = highlight[2];
out[afterPos + 3] = 255;
}
}
// A shot that grew or shrank has no counterpart for the extra rows and
// columns, so all of them are a change.
const extra =
after.width * after.height -
width * height +
(base.width * base.height - width * height);
return {
data: out,
width: after.width,
height: after.height,
changed: changed + extra,
};
};
/**
* What each run was and how it was configured, from the `shots.json`
* story-shots.mjs leaves beside its output. A run shot before that existed, or
* a directory assembled by hand, simply gets no caption.
*/
const manifest = async (dir) => {
try {
return JSON.parse(await readFile(path.join(dir, 'shots.json'), 'utf8'));
} catch {
return null;
}
};
const [baseRun, afterRun] = await Promise.all([
manifest(baseDir),
manifest(afterDir),
]);
/** The settings the two runs disagree on: what a difference in the shots may be. */
const changedKeys = CONFIG_KEYS.filter(
(key) => (baseRun?.config?.[key] ?? '') !== (afterRun?.config?.[key] ?? ''),
);
const settings = (run, keys) => settingsLine(run?.config, keys);
const shotOf = (run, rel) =>
run?.shots?.find((shot) => shot.file === rel.split(path.sep).join('/'));
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
const withoutCaption = (file, { width, height }, top) =>
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
/** Story, then the settings both runs shared. One line each, widest font first. */
const header = (rel) => {
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
const shared = settings(
afterRun,
CONFIG_KEYS.filter((key) => !changedKeys.includes(key)),
);
return [
shot ? `${shot.title}/${shot.name}` : rel.replace(/\.png$/, ''),
[shot?.id ?? '', shot?.theme ?? '', shot?.status === 'busy' ? '(busy)' : '']
.filter(Boolean)
.join(' '),
shared,
].filter(Boolean);
};
/** A tile's own line: which side it is, and where its run differed. */
const sideLabel = (side, run) =>
[side, settings(run, changedKeys)].filter(Boolean).join(' ');
const captionLines = (rel, lines) =>
opts['no-caption'] ? [] : [...header(rel), ...lines].filter(Boolean);
const pngs = async (dir, prefix = '') => {
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
const files = [];
for (const entry of entries) {
const rel = path.join(prefix, entry.name);
if (entry.isDirectory()) {
files.push(...(await pngs(dir, rel)));
} else if (entry.name.endsWith('.png')) {
files.push(rel);
}
}
return files;
};
const results = [];
await mkdir(outDir, { recursive: true });
for (const rel of (await pngs(baseDir)).sort()) {
const afterFile = path.join(afterDir, rel);
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
let after;
try {
after = readRgba(afterFile, captionOf(afterRun, rel));
} catch {
console.error(`missing in after: ${rel}`);
continue;
}
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
const diff = diffPair(base, after, opts.mode);
const target = path.join(outDir, rel);
const parallel = opts.mode.endsWith('-parallel');
// With no tiles to label, a run's own settings go in the caption instead.
const caption = captionLines(
rel,
parallel || !changedKeys.length
? []
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
);
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
// The gutter is the opposite of the theme's own background, so the tiles and
// the caption keep an edge instead of bleeding into it.
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
const theme = shot?.theme ?? rel.split(path.sep)[0];
const { background, foreground } = palette(theme);
if (parallel) {
await writeRgba(diff, diffFile);
const tile = (label, file) => [
'(',
`label:${literal(label)}`,
file,
'-gravity',
'center',
'-append',
'-bordercolor',
background,
'-border',
'12',
')',
];
magick([
'-background',
background,
'-fill',
foreground,
...bodyFont(),
'-pointsize',
// The tiles end up side by side, so they are read at the montage's width.
String(Math.round(pointsize(after.width * 3) * 0.62)),
...tile(
sideLabel('previous', baseRun),
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
),
...tile(
sideLabel('current', afterRun),
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
),
...tile('diff', diffFile),
'-gravity',
'north',
'+append',
caption.length ? body : target,
]);
if (caption.length) {
stamp({ lines: caption, from: body, to: target, theme });
}
} else {
await writeRgba(diff, caption.length ? body : target);
if (caption.length) {
stamp({ lines: caption, from: body, to: target, theme });
}
}
results.push([diff.changed, rel]);
}
results
.sort((a, b) => b[0] - a[0])
.forEach(([changed, rel]) =>
console.log(`${String(changed).padStart(10)} ${rel}`),
);
console.error(`diffs in ${outDir}`);

View File

@@ -1,514 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { mkdir, rename, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { parseArgs } from 'node:util';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import os from 'node:os';
import {
CONFIG_KEYS,
hasMagick,
settingsLine,
stamp,
} from './story-shots-caption.mjs';
/**
* The wall clock every shot is taken at, passed to the preview as `storyClock`.
* `.storybook/preview-head.html` freezes the same instant by itself, so a
* Chromatic build reads the clock this run does.
*/
const FROZEN_CLOCK = '2026-06-15T12:00:00.000Z';
const { values: opts, positionals } = parseArgs({
allowPositionals: true,
options: {
out: { type: 'string', short: 'o' },
stories: { type: 'string', multiple: true, default: [] },
title: { type: 'string', default: '' },
name: { type: 'string', default: '' },
theme: { type: 'string', multiple: true, default: [] },
args: { type: 'string', multiple: true, default: [] },
port: { type: 'string', default: process.env.SB_PORT ?? '6006' },
width: { type: 'string', default: '1680' },
height: { type: 'string', default: '1200' },
'max-height': { type: 'string', default: '8000' },
grow: { type: 'string', default: 'scrollers' },
settle: { type: 'string', default: '1500' },
clock: { type: 'string', default: FROZEN_CLOCK },
motion: { type: 'boolean', default: false },
ignore: { type: 'string', multiple: true, default: [] },
flat: { type: 'boolean', default: false },
'no-caption': { type: 'boolean', default: false },
list: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
});
const outDir = opts.out ?? positionals[0];
const themes = opts.theme.flatMap((value) => value.split(',')).filter(Boolean);
const storyArgs = opts.args.filter(Boolean).join(';');
if (opts.help || (!outDir && !opts.list)) {
console.log(`usage: node scripts/story-shots.mjs <out-dir> [options]
--stories <match> only stories whose id or title/name path contains <match>
(repeatable, comma-separated; default: every story)
--title <prefix> only stories whose title starts with <prefix>
--name <match> only stories whose name contains <match>
--theme <themes> themes to shoot, e.g. dark,light (default: story default)
--args <k:v;k2:v2> arg overrides, storybook's own ?args= syntax (repeatable).
A value containing a dot is dropped by storybook itself
--port <port> storybook dev server port (default 6006, or $SB_PORT)
--width <px> viewport width, the only fixed dimension (default 1680)
--height <px> shortest the viewport may be (default 1200)
--max-height <px> tallest the viewport may grow to (default 8000)
--grow <what> scrollers (default) grows the viewport until the page's
own scrollers fit, document only follows the document
height (a no-op on any page with the app shell), none
keeps --height
--settle <ms> wait after the page goes quiet (default 1500)
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
--motion keep animations and transitions running
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
--no-caption do not stamp the story and the run settings on the shot
--list print the matched stories and exit
Screenshots land in <out-dir>/<theme>/<story-id>.png, alongside a shots.json
recording what each shot is, how the run was configured, and how tall the
caption on it is. story-shots-diff.mjs reads that to crop the caption off before
comparing, so two runs never diff their own captions.
Captioning needs ImageMagick; without it the shots are written bare.
Playwright is looked up in tests/e2e, then in the global install; override with
PLAYWRIGHT_MODULE. The browser is playwright's own chromium, else an installed
Chrome; override with CHROME_PATH.`);
process.exit(opts.help ? 0 : 1);
}
const base = `http://localhost:${opts.port}`;
// `index.json` carries raw control characters from story jsdoc, so it is read as
// text rather than piped through anything that revalidates it.
const index = JSON.parse(await (await fetch(`${base}/index.json`)).text());
const matches = opts.stories
.flatMap((value) => value.split(','))
.filter(Boolean);
const stories = Object.values(index.entries)
.filter((entry) => {
if (entry.type !== 'story') {
return false;
}
if (opts.title && !entry.title.startsWith(opts.title)) {
return false;
}
if (
opts.name &&
!entry.name.toLowerCase().includes(opts.name.toLowerCase())
) {
return false;
}
if (!matches.length) {
return true;
}
const haystack = `${entry.id} ${entry.title}/${entry.name}`.toLowerCase();
return matches.some((match) => haystack.includes(match.toLowerCase()));
})
.sort((a, b) => a.id.localeCompare(b.id));
if (opts.list) {
stories.forEach((story) =>
console.log(`${story.id}\t${story.title}/${story.name}`),
);
console.log(`${stories.length} stories`);
process.exit(0);
}
if (!stories.length) {
console.error('no story matched');
process.exit(1);
}
const ignoreSelectors = opts.ignore
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean);
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
console.error(`--clock: not a date: ${opts.clock}`);
process.exit(1);
}
/**
* `[data-shot-ignore]` and `--ignore` hide what cannot be settled, the local
* half of Chromatic's `data-chromatic="ignore"`. Everything else the shot needs
* held still - the frozen clock, the parked animations, the lists snapped onto
* their bottom - is done by the preview itself, so a Chromatic build and a shot
* from here see the same page.
*/
const ignoreCss = (
ignore,
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
.map((selector) => `, ${selector}`)
.join('')} {
visibility: hidden !important;
}`;
/**
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
* or wherever `$PLAYWRIGHT_MODULE` points. `@playwright/test` re-exports
* `chromium`, so an e2e install alone is enough.
*/
const resolvePlaywright = () => {
const specifiers = process.env.PLAYWRIGHT_MODULE
? [process.env.PLAYWRIGHT_MODULE]
: ['playwright', '@playwright/test'];
const find = (roots) => {
for (const specifier of specifiers) {
for (const root of roots) {
try {
return createRequire(path.join(root, '-')).resolve(specifier);
} catch {
/* next candidate */
}
}
}
return null;
};
const local = find([
import.meta.dirname,
path.resolve(import.meta.dirname, '../../tests/e2e'),
]);
if (local) {
return local;
}
// `npm root -g` prints the global node_modules; resolution starts a level up.
const globalRoot = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
const global =
globalRoot.status === 0 && find([path.dirname(globalRoot.stdout.trim())]);
if (global) {
return global;
}
console.error(
'playwright not found. Install it (pnpm -C tests/e2e install, or npm i -g playwright) or set PLAYWRIGHT_MODULE.',
);
return process.exit(1);
};
const pwModule = await import(pathToFileURL(resolvePlaywright()).href);
const pw = pwModule.chromium ? pwModule : pwModule.default;
console.log(
`${stories.length} stories x ${themes.length || 1} theme(s) -> ${outDir}`,
);
/**
* A playwright install carries no browser of its own, and the revision it wants
* is often not the one that was downloaded, so an installed Chrome is the
* fallback before giving up.
*/
const launch = async () => {
if (process.env.CHROME_PATH) {
return pw.chromium.launch({ executablePath: process.env.CHROME_PATH });
}
try {
return await pw.chromium.launch();
} catch (error) {
try {
return await pw.chromium.launch({ channel: 'chrome' });
} catch {
console.error(
`${error.message.split('\n')[0]}\nRun 'playwright install chromium' or set CHROME_PATH to a browser binary.`,
);
return process.exit(1);
}
}
};
const browser = await launch();
const failures = [];
const shots = [];
const runConfig = {
args: storyArgs,
clock: opts.clock,
width: opts.width,
height: opts.height,
grow: opts.grow,
motion: opts.motion ? 'live' : 'still',
settle: opts.settle,
ignore: ignoreSelectors.join(', '),
};
const captioning = !opts['no-caption'] && hasMagick();
if (!opts['no-caption'] && !captioning) {
console.error('ImageMagick not found: shots are written without a caption.');
}
const configLine = settingsLine(runConfig, CONFIG_KEYS);
for (const theme of themes.length ? themes : [null]) {
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
await mkdir(dir, { recursive: true });
if (theme) {
console.log(`\n[${theme}]`);
}
for (const story of stories) {
// A context per story: reusing one page loses the msw worker
// re-registration race after a few navigations and the story then dies on
// a missing worker.
const context = await browser.newContext({
viewport: { width: Number(opts.width), height: Number(opts.height) },
reducedMotion: opts.motion ? 'no-preference' : 'reduce',
});
const page = await context.newPage();
// react-query retries and msw both keep requests going long after load, so
// the settle waits on the page being quiet rather than on a fixed delay.
let inFlight = 0;
let lastActivity = Date.now();
page.on('request', () => {
inFlight += 1;
lastActivity = Date.now();
});
const done = () => {
inFlight = Math.max(inFlight - 1, 0);
lastActivity = Date.now();
};
page.on('requestfinished', done);
page.on('requestfailed', done);
// The height the rounds had reached when the page turned out to grow with
// the viewport, kept only to flag the story in the log.
let chasing = 0;
const url = new URL(`${base}/iframe.html`);
url.searchParams.set('viewMode', 'story');
url.searchParams.set('id', story.id);
// The preview owns the clock and the motion state, so both are asked for in
// the URL rather than injected here: a Chromatic build gets the defaults.
url.searchParams.set('storyClock', opts.clock);
const globals = [theme && `theme:${theme}`, opts.motion && 'motion:live']
.filter(Boolean)
.join(';');
if (globals) {
url.searchParams.set('globals', globals);
}
if (storyArgs) {
url.searchParams.set('args', storyArgs);
}
try {
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
// Storybook's own render phase is the readiness signal: it reaches
// `finished` only once the loaders, the decorators and the story's `play`
// are all done, which a DOM check cannot see. The dev server transforms
// each page module on first visit, so this is the slow wait.
await page.waitForFunction(
() =>
(window.__STORYBOOK_PREVIEW__?.storyRenders ?? []).some((render) =>
['finished', 'errored', 'aborted'].includes(render.phase),
) || document.body.classList.contains('sb-show-errordisplay'),
undefined,
{ timeout: 120_000 },
);
await page.addStyleTag({ content: ignoreCss(ignoreSelectors) });
if (!opts.motion) {
// Videos and GIFs are parked on their first frame, as Chromatic does.
await page.evaluate(() =>
document.querySelectorAll('video').forEach((video) => video.pause?.()),
);
}
// Text reflows when a webfont lands, so the shot waits for the faces the
// page asked for. Some stories keep a request open by design, hence the
// cap on the quiet wait rather than a plain networkidle.
await page.evaluate(() => document.fonts.ready);
const quietUntil = Date.now() + 15_000;
while (
Date.now() < quietUntil &&
(inFlight > 0 || Date.now() - lastActivity < 600)
) {
await page.waitForTimeout(200);
}
await page.waitForTimeout(Number(opts.settle));
// The width is the fixed dimension and the height follows the page, the
// way a Chromatic viewport does. `src/styles.scss` pins
// `html, body, #root` to `height: 100%; overflow: hidden`, so the
// document can never outgrow the viewport and its height says nothing
// about what is on the page: what overflows are the shell's inner
// scrollers. `scrollers` grows the viewport until the tallest of those
// fits, so nothing is cut off and no scrollbar is left in the shot.
// Growing changes the layout, hence the rounds. A page that sizes a panel
// in `vh` grows its own content as the viewport grows, so no height ever
// fits it and the rounds only chase: `.alert-chart-container` is `57vh`,
// which puts Create Alert's fixed point at 4344px with an empty band on
// top. Such a page is shot at `--height` with its own scrollbar instead,
// which is what it looks like in a browser.
if (opts.grow !== 'none') {
const maximum = Number(opts['max-height']);
const requested = Number(opts.height);
let height = requested;
let fits = false;
for (let round = 0; round < 3 && !fits; round += 1) {
const needed = Math.min(
maximum,
await page.evaluate((withScrollers) => {
const document_ = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
);
if (!withScrollers) {
return document_;
}
// Popups are skipped: they are out of the flow, and a tall
// dropdown or tooltip would otherwise drag the shot to a
// height nothing on the page itself needs.
const inFlow = (element) => {
for (
let node = element;
node && node !== document.documentElement;
node = node.parentElement
) {
const { position } = getComputedStyle(node);
if (position === 'fixed' || position === 'absolute') {
return false;
}
}
return true;
};
return [...document.querySelectorAll('*')].reduce((tallest, element) => {
const { overflowY } = getComputedStyle(element);
if (
!['auto', 'scroll', 'overlay'].includes(overflowY) ||
element.scrollHeight - element.clientHeight <= 1 ||
!inFlow(element)
) {
return tallest;
}
const box = element.getBoundingClientRect();
const above = box.top + window.scrollY;
const below = Math.max(0, document_ - (box.bottom + window.scrollY));
return Math.max(tallest, above + element.scrollHeight + below);
}, document_);
}, opts.grow === 'scrollers'),
);
fits = needed <= height;
if (fits) {
break;
}
height = needed;
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}
if (!fits && height !== requested) {
chasing = height;
height = requested;
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}
}
// The preview snapped its bottom-pinned lists at `afterEach`, before the
// page went quiet; a virtuoso list is usually still measuring then.
await page.evaluate(() => window.__signozSnapPinnedScrollers?.());
// A page that is still moving — a list scrolling itself to the bottom, a
// monaco editor re-measuring, a tooltip being repositioned — is shot
// twice in a row until two frames come back identical, since what the
// page is waiting on is not observable from here.
let shot = await page.screenshot();
let stable = false;
for (let attempt = 0; attempt < 8 && !stable; attempt += 1) {
await page.waitForTimeout(400);
const next = await page.screenshot();
stable = next.equals(shot);
shot = next;
}
const file = path.join(dir, `${story.id}.png`);
await writeFile(file, shot);
// The band goes on the shot itself so a single screenshot says what it
// is, and its height is recorded so a diff can take it back off.
let caption = 0;
if (captioning) {
const temporary = path.join(
os.tmpdir(),
`story-shots-caption-${process.pid}.png`,
);
caption = stamp({
lines: [
`${story.title}/${story.name}`,
[story.id, theme ?? 'default', stable ? '' : '(busy)']
.filter(Boolean)
.join(' '),
configLine,
].filter(Boolean),
from: file,
to: temporary,
theme: theme ?? 'dark',
});
await rename(temporary, file);
}
shots.push({
file: path.posix.join(
opts.flat ? '' : (theme ?? 'default'),
`${story.id}.png`,
),
id: story.id,
title: story.title,
name: story.name,
theme: theme ?? 'default',
status: stable ? 'ok' : 'busy',
caption,
});
console.log(
` ${stable ? 'ok ' : 'busy'} ${story.id}${
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
}`,
);
} catch (error) {
failures.push(`${theme ?? 'default'}/${story.id}`);
console.log(` FAIL ${story.id}: ${error.message.split('\n')[0]}`);
} finally {
await context.close();
}
}
}
await browser.close();
// The diff script captions its output from this, so the run's own settings sit
// next to the shots they produced rather than only in the shell history.
await writeFile(
path.join(outDir, 'shots.json'),
`${JSON.stringify({ config: runConfig, shots }, null, '\t')}\n`,
);
if (failures.length) {
console.error(`\n${failures.length} failed: ${failures.join(', ')}`);
process.exit(1);
}

View File

@@ -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 "$@"
pnpm exec test-storybook --ci --maxWorkers=2 --testTimeout 30000 "$@"

View File

@@ -8,7 +8,6 @@ import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -44,7 +43,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -135,14 +133,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <Redirect to={ROUTES.HOME} />;
}
if (
(pathname.startsWith(`${ROUTES.AI_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.AI_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;

View File

@@ -300,14 +300,6 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /logs-explorer/live to /logs/logs-explorer/live', () => {
renderPrivateRoute({ initialRoute: '/logs-explorer/live' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/logs/logs-explorer/live',
);
});
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
@@ -1588,24 +1580,14 @@ 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 },
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
OLD_LOGS_EXPLORER: {
path: ROUTES.OLD_LOGS_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER: {
path: ROUTES.METRICS_EXPLORER,
deniedRoles: DENIED_ROLES,

View File

@@ -12,13 +12,14 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
@@ -58,7 +59,6 @@ function App(): JSX.Element {
isFetchingActiveLicense,
activeLicenseFetchError,
userFetchError,
featureFlagsFetchError,
isLoggedIn: isLoggedInState,
featureFlags,
org,
@@ -66,6 +66,8 @@ function App(): JSX.Element {
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isSavedViewEnabled = useSavedViewEnabled();
const chatSupport = useChatSupport();
const { hostname } = window.location;
const [pathname, setPathname] = useState(history.location.pathname);
@@ -253,7 +255,9 @@ function App(): JSX.Element {
}, [isDarkMode]);
useEffect(() => {
// The bottom strip carries Support, so the floating bubble goes entirely.
if (
isSavedViewEnabled ||
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname === '/ai-assistant' ||
@@ -263,71 +267,32 @@ function App(): JSX.Element {
} else {
window.Pylon?.('showChatBubble');
}
}, [pathname]);
}, [pathname, isSavedViewEnabled]);
// eslint-disable-next-line sonarjs/cognitive-complexity
// Identity for the Pylon widget. Whether this user gets Pylon at all is
// `useChatSupport`'s call — this only fills in who they are.
useEffect(() => {
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
// to something went wrong which would ideally need a reload.
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
let isPremiumSupportEnabled = false;
if (featureFlags && featureFlags.length > 0) {
isChatSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
?.active || false;
isPremiumSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
?.active || false;
}
const showAddCreditCardModal =
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
if (
isLoggedInState &&
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser) &&
window.signozBootData?.settings?.pylon?.enabled
) {
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}
if (chatSupport !== ChatSupportState.Pylon) {
return;
}
}, [
isLoggedInState,
user,
pathname,
trialInfo?.trialConvertedToSubscription,
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
activeLicense,
trialInfo,
isCloudUser,
isEnterpriseSelfHostedUser,
]);
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}, [chatSupport, user]);
useEffect(() => {
if (!isFetchingUser && isCloudUser && user && user.email) {

View File

@@ -53,17 +53,6 @@ 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(
@@ -165,18 +154,6 @@ export const Logs = Loadable(
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
);
export const LogsExplorer = Loadable(
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsModulePage'),
);
export const OldLogsExplorer = Loadable(
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/Logs'),
);
export const LiveLogs = Loadable(
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
);
export const PipelinePage = Loadable(
() => import(/* webpackChunkName: "Pipelines" */ 'pages/LogsModulePage'),
);

View File

@@ -23,16 +23,13 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LiveLogs,
Login,
Logs,
LogsExplorer,
LogsIndexToFields,
LogsSaveViews,
MessagingQueuesMainPage,
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
OnboardingV2,
OrgOnboarding,
PasswordReset,
@@ -47,9 +44,7 @@ import {
SomethingWentWrong,
StatusPage,
SupportPage,
TraceDetailOldRedirect,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
TracesFunnels,
@@ -132,14 +127,6 @@ 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,
@@ -224,13 +211,6 @@ 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,
@@ -301,27 +281,6 @@ const routes: AppRoutes[] = [
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_EXPLORER,
exact: true,
component: LogsExplorer,
key: 'LOGS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.OLD_LOGS_EXPLORER,
exact: true,
component: OldLogsExplorer,
key: 'OLD_LOGS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.LIVE_LOGS,
exact: true,
component: LiveLogs,
key: 'LIVE_LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_PIPELINES,
exact: true,
@@ -554,7 +513,6 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-explorer/live': '/logs/logs-explorer/live',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/settings/access-tokens': '/settings/service-accounts',

View File

@@ -21,6 +21,7 @@ import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
AlertmanagertypesRepairChannelParamsDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
CreateChannel201,
@@ -35,6 +36,9 @@ import type {
ListNotificationChannels200,
ListNotificationChannelsParams,
RenderErrorResponseDTO,
RepairNotificationChannel200,
RepairNotificationChannelParams,
RepairNotificationChannelPathParameters,
UpdateChannelByIDPathParameters,
UpdateNotificationChannel200,
UpdateNotificationChannelPathParameters,
@@ -1144,6 +1148,113 @@ 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

View File

@@ -36,16 +36,12 @@ import type {
GetDashboardV2200,
GetDashboardV2PathParameters,
GetPublicDashboard200,
GetPublicDashboardData200,
GetPublicDashboardDataPathParameters,
GetPublicDashboardDataV2200,
GetPublicDashboardDataV2PathParameters,
GetPublicDashboardPanelQueryRangeV2200,
GetPublicDashboardPanelQueryRangeV2Params,
GetPublicDashboardPanelQueryRangeV2PathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
ListDashboardViews200,
@@ -474,217 +470,6 @@ export const useUpdatePublicDashboard = <
> => {
return useMutation(getUpdatePublicDashboardMutationOptions(options));
};
/**
* This endpoint returns the sanitized dashboard data for public access
* @summary Get public dashboard data
*/
export const getPublicDashboardData = (
{ id }: GetPublicDashboardDataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardData200>({
url: `/api/v1/public/dashboards/${id}`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardDataQueryKey = ({
id,
}: GetPublicDashboardDataPathParameters) => {
return [`/api/v1/public/dashboards/${id}`] as const;
};
export const getGetPublicDashboardDataQueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardData>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardData>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetPublicDashboardDataQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardData>>
> = ({ signal }) => getPublicDashboardData({ id }, signal);
return {
queryKey,
queryFn,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardData>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardDataQueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardData>>
>;
export type GetPublicDashboardDataQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get public dashboard data
*/
export function useGetPublicDashboardData<
TData = Awaited<ReturnType<typeof getPublicDashboardData>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardData>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardDataQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get public dashboard data
*/
export const invalidateGetPublicDashboardData = async (
queryClient: QueryClient,
{ id }: GetPublicDashboardDataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardDataQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint return query range results for a widget of public dashboard
* @summary Get query range result
*/
export const getPublicDashboardWidgetQueryRange = (
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardWidgetQueryRange200>({
url: `/api/v1/public/dashboards/${id}/widgets/${idx}/query_range`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardWidgetQueryRangeQueryKey = ({
id,
idx,
}: GetPublicDashboardWidgetQueryRangePathParameters) => {
return [`/api/v1/public/dashboards/${id}/widgets/${idx}/query_range`] as const;
};
export const getGetPublicDashboardWidgetQueryRangeQueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetPublicDashboardWidgetQueryRangeQueryKey({ id, idx });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>
> = ({ signal }) => getPublicDashboardWidgetQueryRange({ id, idx }, signal);
return {
queryKey,
queryFn,
enabled: id !== null && id !== undefined && idx !== null && idx !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardWidgetQueryRangeQueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>
>;
export type GetPublicDashboardWidgetQueryRangeQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get query range result
*/
export function useGetPublicDashboardWidgetQueryRange<
TData = Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardWidgetQueryRangeQueryOptions(
{ id, idx },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get query range result
*/
export const invalidateGetPublicDashboardWidgetQueryRange = async (
queryClient: QueryClient,
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardWidgetQueryRangeQueryKey({ id, idx }) },
options,
);
return queryClient;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List dashboard saved views

View File

@@ -150,7 +150,7 @@ export const invalidateListLLMPricingRules = async (
};
/**
* Single write endpoint used by both the user and the Zeus sync job. Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true) are fully preserved when the request does not provide isOverride; only synced_at is stamped.
* Single write endpoint used by both the user and the Zeus sync job. Rules without isOverride are matched by sourceId and override rows (is_override=true) are skipped. Rules with isOverride are matched by id and inserted when new.
* @summary Create or update pricing rules
*/
export const createOrUpdateLLMPricingRules = (

View File

@@ -24,8 +24,6 @@ import type {
GetMetricAlertsParams,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricDashboards200,
GetMetricDashboardsParams,
GetMetricDashboardsV2200,
GetMetricDashboardsV2Params,
GetMetricHighlights200,
@@ -1096,104 +1094,6 @@ export const invalidateGetMetricAttributes = async (
return queryClient;
};
/**
* This endpoint returns associated dashboards for a specified metric
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
params: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
url: `/api/v2/metrics/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsQueryKey = (
params?: GetMetricDashboardsParams,
) => {
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboards>>
> = ({ signal }) => getMetricDashboards(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricDashboardsQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricDashboards>>
>;
export type GetMetricDashboardsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric dashboards
*/
export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get metric dashboards
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
params: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric
* @summary Get metric highlights

View File

@@ -18,8 +18,12 @@ import type {
} from 'react-query';
import type {
CloneRuleByID201,
CloneRuleByIDPathParameters,
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
DeleteRuleViewPathParameters,
GetRuleByID200,
GetRuleByIDPathParameters,
GetRuleHistoryFilterKeys200,
@@ -40,13 +44,19 @@ import type {
GetRuleHistoryTopContributors200,
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRuleViews200,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
PatchRuleByID200,
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
RuletypesPostableRuleViewDTO,
TestRule200,
UpdateRuleByIDPathParameters,
UpdateRuleView200,
UpdateRuleViewPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -73,7 +83,353 @@ const withQueryKey = <T extends object, K>(
};
/**
* This endpoint lists all alert rules with their current evaluation state
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List rule saved views
*/
export const listRuleViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRuleViews200>({
url: `/api/v2/rule_views`,
method: 'GET',
signal,
});
};
export const getListRuleViewsQueryKey = () => {
return [`/api/v2/rule_views`] as const;
};
export const getListRuleViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
signal,
}) => listRuleViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRuleViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listRuleViews>>
>;
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List rule saved views
*/
export function useListRuleViews<
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRuleViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List rule saved views
*/
export const invalidateListRuleViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRuleViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
* @summary Create rule saved view
*/
export const createRuleView = (
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateRuleView201>({
url: `/api/v2/rule_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getCreateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
const mutationKey = ['createRuleView'];
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 createRuleView>>,
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createRuleView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createRuleView>>
>;
export type CreateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create rule saved view
*/
export const useCreateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
return useMutation(getCreateRuleViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete rule saved view
*/
export const deleteRuleView = (
{ id }: DeleteRuleViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/rule_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
const mutationKey = ['deleteRuleView'];
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 deleteRuleView>>,
{ pathParams: DeleteRuleViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteRuleView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRuleView>>
>;
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete rule saved view
*/
export const useDeleteRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
return useMutation(getDeleteRuleViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update rule saved view
*/
export const updateRuleView = (
{ id }: UpdateRuleViewPathParameters,
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateRuleView200>({
url: `/api/v2/rule_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getUpdateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
const mutationKey = ['updateRuleView'];
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 updateRuleView>>,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRuleView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRuleView>>
>;
export type UpdateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update rule saved view
*/
export const useUpdateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
return useMutation(getUpdateRuleViewMutationOptions(options));
};
/**
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated
* @summary List alert rules
*/
export const listRules = (signal?: AbortSignal) => {
@@ -115,6 +471,7 @@ export type ListRulesQueryResult = NonNullable<
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List alert rules
*/
@@ -134,6 +491,7 @@ export function useListRules<
}
/**
* @deprecated
* @summary List alert rules
*/
export const invalidateListRules = async (
@@ -607,6 +965,85 @@ export const useUpdateRuleByID = <
> => {
return useMutation(getUpdateRuleByIDMutationOptions(options));
};
/**
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller. Cloning fails with 400 when the stored source no longer passes create validation, for example when a channel it references has been deleted.
* @summary Clone alert rule
*/
export const cloneRuleByID = (
{ id }: CloneRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneRuleByID201>({
url: `/api/v2/rules/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['cloneRuleByID'];
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 cloneRuleByID>>,
{ pathParams: CloneRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof cloneRuleByID>>
>;
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone alert rule
*/
export const useCloneRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
return useMutation(getCloneRuleByIDMutationOptions(options));
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys
@@ -1388,3 +1825,97 @@ export const useTestRule = <
> => {
return useMutation(getTestRuleMutationOptions(options));
};
/**
* Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.
* @summary List alert rules (v3)
*/
export const listRulesV3 = (
params?: ListRulesV3Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListRulesV3200>({
url: `/api/v3/rules`,
method: 'GET',
params,
signal,
});
};
export const getListRulesV3QueryKey = (params?: ListRulesV3Params) => {
return [`/api/v3/rules`, ...(params ? [params] : [])] as const;
};
export const getListRulesV3QueryOptions = <
TData = Awaited<ReturnType<typeof listRulesV3>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListRulesV3Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRulesV3QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRulesV3>>> = ({
signal,
}) => listRulesV3(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRulesV3QueryResult = NonNullable<
Awaited<ReturnType<typeof listRulesV3>>
>;
export type ListRulesV3QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List alert rules (v3)
*/
export function useListRulesV3<
TData = Awaited<ReturnType<typeof listRulesV3>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListRulesV3Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRulesV3QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List alert rules (v3)
*/
export const invalidateListRulesV3 = async (
queryClient: QueryClient,
params?: ListRulesV3Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRulesV3QueryKey(params) },
options,
);
return queryClient;
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,17 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -16,6 +22,11 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetTraceThread200,
GetTraceThreadParams,
GetTraceThreadPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -27,6 +38,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -127,6 +158,223 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
* @summary Get summary for a trace
*/
export const getTraceSummary = (
{ traceID }: GetTraceSummaryPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceSummary200>({
url: `/api/v1/traces/${traceID}/summary`,
method: 'GET',
signal,
});
};
export const getGetTraceSummaryQueryKey = ({
traceID,
}: GetTraceSummaryPathParameters) => {
return [`/api/v1/traces/${traceID}/summary`] as const;
};
export const getGetTraceSummaryQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
signal,
}) => getTraceSummary({ traceID }, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceSummaryQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceSummary>>
>;
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get summary for a trace
*/
export function useGetTraceSummary<
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get summary for a trace
*/
export const invalidateGetTraceSummary = async (
queryClient: QueryClient,
{ traceID }: GetTraceSummaryPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
options,
);
return queryClient;
};
/**
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceThread200>({
url: `/api/v1/traces/${traceID}/thread`,
method: 'GET',
params,
signal,
});
};
export const getGetTraceThreadQueryKey = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
) => {
return [
`/api/v1/traces/${traceID}/thread`,
...(params ? [params] : []),
] as const;
};
export const getGetTraceThreadQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
signal,
}) => getTraceThread({ traceID }, params, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceThreadQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceThread>>
>;
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get thread view for a trace
*/
export function useGetTraceThread<
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceThreadQueryOptions(
{ traceID },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get thread view for a trace
*/
export const invalidateGetTraceThread = async (
queryClient: QueryClient,
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/addToSelectedFields';
const addToSelectedFields = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.post(`/logs/fields`, props);
return {
statusCode: 200,
error: null,
message: '',
payload: data.data,
};
} catch (error) {
return Promise.reject(ErrorResponseHandler(error as AxiosError));
}
};
export default addToSelectedFields;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/getLogs';
const GetLogs = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.get(`/logs`, {
params: props,
});
return {
statusCode: 200,
error: null,
message: '',
payload: data.data.results,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default GetLogs;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/getLogsAggregate';
const GetLogsAggregate = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.get(`/logs/aggregate`, {
params: props,
});
return {
statusCode: 200,
error: null,
message: '',
payload: data.data.items,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default GetLogsAggregate;

View File

@@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/logs/getSearchFields';
const GetSearchFields = async (): Promise<
SuccessResponse<PayloadProps> | ErrorResponse
> => {
try {
const data = await axios.get(`/logs/fields`);
return {
statusCode: 200,
error: null,
message: '',
payload: data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default GetSearchFields;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/addToSelectedFields';
const removeSelectedField = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.post(`/logs/fields`, props);
return {
statusCode: 200,
error: null,
message: '',
payload: data.data,
};
} catch (error) {
return Promise.reject(ErrorResponseHandler(error as AxiosError));
}
};
export default removeSelectedField;

View File

@@ -1,22 +0,0 @@
import apiV1 from 'api/apiV1';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { ENVIRONMENT } from 'constants/env';
import { LOCALSTORAGE } from 'constants/localStorage';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { withBasePath } from 'utils/basePath';
// 10 min in ms
const TIMEOUT_IN_MS = 10 * 60 * 1000;
export const LiveTail = (queryParams: string): EventSourcePolyfill =>
new EventSourcePolyfill(
ENVIRONMENT.baseURL
? `${ENVIRONMENT.baseURL}${apiV1}logs/tail?${queryParams}`
: withBasePath(`${apiV1}logs/tail?${queryParams}`),
{
headers: {
Authorization: `Bearer ${getLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN)}`,
},
heartbeatTimeout: TIMEOUT_IN_MS,
},
);

View File

@@ -1,21 +1,15 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldKeysConfig = GetFieldsKeysParams;
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldValuesConfig = GetFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,

View File

@@ -1,49 +0,0 @@
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;

View File

@@ -1,62 +0,0 @@
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;

View File

@@ -1,65 +0,0 @@
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;

View File

@@ -1,49 +0,0 @@
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;

View File

@@ -1,31 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/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;

View File

@@ -198,6 +198,7 @@ function createBaseSpec(
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
bucketOptions: queryData.bucketOptions,
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {

View File

@@ -1,6 +1,6 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st0{fill:#7A8291;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">

Before

Width:  |  Height:  |  Size: 714 B

After

Width:  |  Height:  |  Size: 714 B

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 16 15" xmlns="http://www.w3.org/2000/svg">
<path d="M14.0777 13.984C14.945 14.6345 16.2458 14.2008 15.0533 13.0084C11.476 9.53949 12.2349 0 7.79033 0C3.34579 0 4.10461 9.53949 0.527295 13.0084C-0.773543 14.3092 0.635692 14.6345 1.50293 13.984C4.86344 11.7076 4.64663 7.69664 7.79033 7.69664C10.934 7.69664 10.7172 11.7076 14.0777 13.984Z" fill="#4285F4" />
</svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><g fill="none" fill-rule="evenodd"><path fill="#c925d1" d="M0 0h64v64H0z"/><path fill="#fff" d="M36.213 16.011h-2.016v-2.005h2.016V12h2.017v2.006h2.016v2.005H38.23v2.006h-2.017zm9.074 8.023h-2.016v-2.006h2.016v-2.005h2.016v2.005h2.017v2.006h-2.017v2.006h-2.016v-2.006m-3.434 22.64c-1.495-3.292-4.482-6.263-7.792-7.75 3.31-1.487 6.297-4.459 7.792-7.75 1.494 3.291 4.482 6.263 7.791 7.75-3.31 1.487-6.297 4.458-7.79 7.75m12.139-8.753c-5.202 0-11.13-5.898-11.13-11.071 0-.555-.452-1.003-1.009-1.003s-1.008.448-1.008 1.003c0 5.173-5.93 11.071-11.13 11.071-.558 0-1.009.448-1.009 1.003s.45 1.003 1.008 1.003c5.202 0 11.13 5.898 11.13 11.07 0 .554.452 1.003 1.01 1.003.556 0 1.007-.45 1.007-1.003 0-5.172 5.93-11.07 11.13-11.07.558 0 1.009-.45 1.009-1.003 0-.555-.45-1.003-1.008-1.003m-31.39-19.904c6.85 0 10.587 1.988 10.587 3.008s-3.737 3.009-10.586 3.009-10.587-1.988-10.587-3.009 3.737-3.008 10.587-3.008m-.35 15.15c-5.012 0-8.62-1.063-10.236-2.19v-7.113c2.367 1.433 6.487 2.176 10.587 2.176s8.22-.743 10.586-2.176v6.484c-1.016 1.406-5.247 2.819-10.936 2.819M33.19 45.975c0 1.435-4.126 3.52-10.59 3.52-6.46 0-10.583-2.085-10.583-3.52v-4.407c2.258 1.354 6.014 2.192 10.276 2.192 2.942 0 5.786-.413 8.01-1.166l-.651-1.898c-2.019.682-4.633 1.058-7.359 1.058-5.39 0-9.252-1.402-10.276-2.76v-5.707c2.411 1.176 6.113 1.885 10.237 1.885 3.92 0 8.34-.725 10.936-2.269v2.162h2.016v-14.04c0-3.292-6.34-5.014-12.602-5.014S10 17.733 10 21.025v24.95c0 3.59 6.49 5.526 12.598 5.526 6.112 0 12.607-1.937 12.607-5.526v-2.887h-2.016z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,6 +1,6 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 50.6 50.6" style="enable-background:new 0 0 50.6 50.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st0{fill:#7A8291;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m23.365 13.556-1.442-2.895V8.994c0-2.764-2.218-5.002-4.954-5.002h-2.464c.178-.367.276-.779.276-1.213A2.77 2.77 0 0 0 12.018 0a2.77 2.77 0 0 0-2.763 2.779c0 .434.098.846.276 1.213H7.067c-2.736 0-4.954 2.238-4.954 5.002v1.667L.64 13.549c-.149.29-.149.636 0 .927l1.472 2.855v1.667C2.113 21.762 4.33 24 7.067 24h9.902c2.736 0 4.954-2.238 4.954-5.002V17.33l1.44-2.865c.143-.286.143-.622.002-.91m-12.854 2.36a2.27 2.27 0 0 1-2.261 2.273 2.27 2.27 0 0 1-2.261-2.273v-4.042A2.27 2.27 0 0 1 8.249 9.6a2.267 2.267 0 0 1 2.262 2.274zm7.285 0a2.27 2.27 0 0 1-2.26 2.273 2.27 2.27 0 0 1-2.262-2.273v-4.042A2.267 2.267 0 0 1 15.535 9.6a2.267 2.267 0 0 1 2.261 2.274z" fill="#7A8291" />
</svg>

After

Width:  |  Height:  |  Size: 761 B

View File

@@ -1,6 +1,6 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 43 43" style="enable-background:new 0 0 43 43;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#0074A2;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

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