Compare commits

..

17 Commits

Author SHA1 Message Date
Vikrant Gupta
216c1ef5de chore(claude): require page stories with frontend UI changes (#13231)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Add `.claude/rules/storybook.md`, scoped to `frontend/src/**/*.tsx`: a
UI change lands with its page story created or updated through the
`signoz-page-story` skill, with the opt-outs spelled out.
- Unwrap the prose in `signoz-page-story` and `sbshot-visual-diff` so
each paragraph and bullet is one line, like the rules files. Words and
code blocks are unchanged.
2026-10-23 03:52:59 +00:00
Nikhil Soni
24dac5fa28 chore(telemetrystore): read JSON natively instead of as a string (#13009)
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

Read JSON columns natively as `chcol.JSON` and drop the read-as-string
setting, so a key stored as both a scalar and an object (and typed
sub-paths) survives on read when paths are flattened. See
https://github.com/SigNoz/events-pod/issues/8

- Swap the read connection from
`output_format_native_write_json_as_string=1` to
`output_format_native_use_flattened_dynamic_and_json_serialization=1`
(CH 25.6+), the native serialization `clickhouse-go` can decode for
`JSON(max_dynamic_paths=0)` columns without desyncing the protocol.
- `JSONValue` now wraps the driver's `chcol.JSON` instead of a decoded
map, and derives each column's shape on demand:
- **Span attributes** → `Flatten()` (via `ValuesByPath`): the attribute
bag merges with the legacy `attributes_{string,number,bool}` maps, which
use literal dotted keys, so it must decode to the same flat keyspace.
Flat keeps a scalar-and-object key as two distinct keys.
- **Log body** → `Nest()` (via `NestedMap`): preserves arrays of objects
and typed sub-paths (`body_v2.message`). A scalar-and-object key
collapses here, as a nested document cannot hold both.
- `WrapRows` keeps `JSONValue` as the single JSON-column scan target
across both read stacks; holding the raw `chcol.JSON` lets the flat and
nested views be derived losslessly from the same column.

#### Issues closed by this PR

Closes SigNoz/events-pod#121,
https://github.com/SigNoz/events-pod/issues/8

#### Additional Information

- `clickhouse-go-mock` cannot scan a JSON column natively (panics on the
Dynamic sub-column), so the cmock-based JSON scan test is replaced with
direct decoder/label unit tests; the native read is covered by the
`querier_json_body` and `queriertraces` integration suites on CH
25.12.5.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KXz3it1JJ8fxJidqqEKjsW
2026-10-22 12:36:27 +00:00
Gaurav Tewari
27be58cfef feat: trace details thread (#13073)
<!--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


- Thread tab: renders a trace's gen-ai spans as a conversation from `GET
/api/v1/traces/{id}/thread`, with infinite scroll in both directions.
- `?spanId=` opens the thread at that span with its details dock; an
unknown span shows a "Span not found" state.
- Formatted / JSON toggle (`?view=`). Formatted renders text, reasoning,
compaction, tool calls and tool results, with links from a tool result
to its call.
- Span details panel shows Input / Output message accordions for AI
spans.
- Empty state for traces without AI spans, linking to the LLM
observability docs.
- LLM Observability explorer opens traces on the Thread tab.
- Also in pretty view we have fixed a small bug. if their is action item
no need to show the menu


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


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=263425456&issue=SigNoz%7Cnerve-pod%7C398

<!--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/80f249af-3b5e-4f98-af64-6caa3b96c315



https://github.com/user-attachments/assets/e6aefcee-1b3d-4d75-8787-c70b127223e5




<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- Badge colors may change here 
- If json is too big we have added show more. we can refine the
experience of that later.
- Would recommend seeing next pr. since we have added a stories as well
. since while testing on staging we might not all the data.

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

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-22 12:10:30 +00:00
Gaurav Tewari
3e31ac0c1a feat(trace-details): trace details & thread tab (#13071)
#### Description

This PR adds Overview / Thread tabs and extracts the code both tabs
share.

- **Overview / Thread tabs:** the active tab lives in `?tab=` (nuqs), so
links and refresh keep it.
- **Header moved out of Overview:** both tabs render the same header.
- **`useSpanDetailVariant`:** span details variant logic and panel
position (localStorage) extracted so the Thread tab can reuse the span
details panel.
- **Bottom strip span/error counts read from the trace summary:**
https://github.com/SigNoz/signoz/pull/13065#discussion_r4228107058 & TDD
- **Fix: span click reverted `?tab=`:** nuqs writes the URL directly and
react-router v5 never sees it, so `useLocation()` was stale and its old
`tab` got written back on span click. Span navigation now reads the live
URL via `getUnstableCurrentSearchParams()`.



#### Issues closed by this PR

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/f809e511-4a32-4431-aef2-51e1a0fa2824



#### Additional Information

Stack 4/5, based on #13067. Thread tab is an empty placeholder until
#13068.


- If we have some query in overview tab on tab change we don't store
that.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-22 11:02:38 +00:00
Gaurav Tewari
6ee8af9f1f refactor(trace-details): minor refactor (#13067)
#### Description

- Moves the trace details page body from `index.tsx` into
`TraceDetailsOverview`, no behaviour change.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information

Stack 3/5, based on #13066. Overview and Thread tabs follow in the next
PR.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-22 11:02:37 +00:00
Abhi kumar
5cb2ed97cf fix(querier): limit scalar metric series after reduceTo (#13083)
#### Description

- Scalar metric queries applied the limit before `reduceTo`, ranking
series by their average. With any `reduceTo` other than avg, the wrong
groups were kept and rows weren't sorted by the shown value.
- Scalar requests now run functions → `reduceTo` → limit. Time series
requests still rank by average.

Example: by `service.name`, reduceTo = max, order desc, limit 2

| service | points | avg | max |
|---|---|---|---|
| checkout | 10 10 10 10 10 | 10 | 10 |
| payment | 1 1 1 1 40 | 8.8 | 40 |
| auth | 0 0 0 0 30 | 6 | 30 |

Before: checkout 10, payment 40. After: payment 40, auth 30.

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

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-10-22 10:40:17 +00:00
Naman Verma
407be7d267 fix: make related logs/traces link carry the relevant query from a rule (#13112)
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

If an alert has multiple queries inside it, a threshold can still only
carry one of them. If a threshold rule is breached, the related
logs/traces link carried in the notification and in the alert history
page doesn't always carry the actual query that was involved.

This PR fixes the above issue. In the case of a formula being used in
the threshold, the related logs/traces link carries all the queries that
the formula is based on joined by an `OR`.

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

Closes https://github.com/SigNoz/signoz/issues/12562

<!--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/acb1aea8-fcc5-4778-ba7a-af4ca814d2bd



https://github.com/user-attachments/assets/7a650613-8deb-471d-8549-5b3f08a600ed
2026-10-22 08:10:24 +00:00
ashu8912
089de88bf0 feat(alerts): adopt server-side clone API in frontend (#13181)
#### Description

- Switches alert rule cloning from client-side re-posting to the new
server-side `POST /api/v2/rules/{id}/clone` endpoint.
- Updates `ActionsMenu` in `ListAlertRules` to call `cloneRuleByID({ id:
rule.id })` instead of re-posting the in-memory rule object with a
forced typecast.
- Updates `useAlertRuleDuplicate` in `AlertDetails` to navigate directly
using the newly created rule ID from the clone response, eliminating the
fragile list refetch race condition (`rules[rules.length - 1]`).
- Updates `ListAlertRules.actions.test.tsx` to mock `POST
/api/v2/rules/:id/clone` and verify navigation and event logging.

#### Issues closed by this PR

- Follow-up to the backend changes on `alerts/clone_alert_rule_api`.
- Closes https://github.com/SigNoz/pulse-pod/issues/597

#### Additional Information

- **Base branch:** Targeted against `alerts/clone_alert_rule_api`
(backend branch).
- Tested locally: all 11 test suites in `ListAlertRules` pass (64 tests
total) and `npx tsc --noEmit` compiles with 0 errors.
2026-10-22 07:54:53 +00:00
Naman Verma
e5a39e3489 fix: put non-positive formula heatmap values in the zero bucket (#13217)
<!--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

Every value at or below zero shares one bucket with upper bound 0 on
both axes. A formula on a linear axis put them in the first positive
bucket instead.

Integration tests now cover negative and zero values on both axes, for
builder queries and formulas.

<!--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/602
2026-10-22 07:25:18 +00:00
praneeth-signoz
50b48649dd fix(alertmanager): treat missing dotted labels as nil in downtime scope (#13118)
<!--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
- A downtime scope reading a dotted label the alert lacks
(`cluster.name`) failed with `cannot fetch name from <nil>`, aborting
the whole expression, so a matching `OR` branch never muted the alert.
- Member access in scopes is now nil-safe (`a.b` → `a?.b`), so a missing
dotted label compares like a missing plain label.

<!--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/526

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
2026-10-22 06:42:33 +00:00
Ashwin Bhatkal
9fc6fbb9fa test(e2e): cover the notification channel list, create and delete flows (#13216)
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

End-to-end cover for the rebuilt notification channel screens.
`edit.spec.ts` came with the v2 move; this adds the flows around it.

- **list** — search narrows the table, the type filter keeps only that
type, a search that matches nothing offers to clear itself, clicking a
row opens the channel, and View JSON masks the secrets.
- **create** — a webhook channel saves and appears in the list, and
saving without a name reports the missing field.
- **delete** — removing from the row menu takes the channel out of the
list.
- **one of each type** — eleven create specs and eleven edit specs, one
per channel type. Create fills only what the validator refuses to save
without and checks the row appears; edit seeds the channel through the
API, changes its first required field and checks the change survives a
reload. A type whose settings component or validator drifts fails here
rather than in support.

Everything the specs need lives in `drivers/channels.ts`, following
`drivers/routing.ts`: a `channels` driver for the list, the form and the
API, a worker-scoped `channelSet` that seeds one Slack and one email
channel sharing a prefix, and `CHANNEL_TYPES`, the one table both
per-type specs read so a new channel type is added in a single place.
The specs touch `page` only where they wait on a URL.

The type fixtures respect the real validators rather than guessing:
Google Chat needs https on `chat.googleapis.com`, Jira an https
`.atlassian.net` host, incident.io an id after `/v2/alert_events/http/`,
and Telegram a non-zero chat id.
2026-10-21 19:57:19 +00:00
Ashwin Bhatkal
93d054ee02 fix(notification-channels): apply the review follow-ups on the channel screens (#13214)
#### Description

From the review of SigNoz/signoz#12976 (channel list and form):

- Add the missing spacing and tooltip - fixed. Tooltip has not been a
common pattern for this though. We should make changes once the button
component supports it
- Add a discard button to the form - fixed
- Lay out the form's action buttons the way the alert pages do - fixed
- Fix light mode, which still looks off on these screens. - fixed
- Fix the text that is not visible in light mode. - fixed
- When the list is filtered by a kind, such as Jira or Google Chat, show
a button that creates a channel of that kind - fixed.
- Fix the field styling, which still looks off - fixed, updated design
- Give the TextArea the same background as the other inputs in light
mode. It differs because it comes from antd. - fixed background and
borders as well.
- Make the delete button the same height as the input next to it -
updated design, fixed
- allow search term api filtering in notification channel search api -
fixed
- show no data state on load aside from the empty state with filters
applied - fixed, an empty workspace gets its own screen with a create
action
- alerts list icon should be same size as the new channel button -
flipped and fixed
- the help icon should be to the right of the new channel button instead
of the left - fixed

#### Issues closed by this PR

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


#### Screenshots / Screen Recordings

Every story under `Pages/Alerts/Channels`, shot in dark and light before
and after: **https://claude.ai/artifact/6FQ2pawzEhK5hZhWsG1tB2**

130 pairs, 112 changed, 6 new screens. The page groups them, so you can
open just the form screens, just the list, or just the Slack sections.

One caveat on that page: it was captured before the global button font
rule was reverted, so it counts more changed screens than the branch now
has. The groups and what each one shows are still accurate.
2026-10-21 19:57:18 +00:00
Ashwin Bhatkal
fcb685bf37 feat(notification-channels): add the telegram channel form (#13119)
#### Description

Gives Telegram a form, now that the v2 API models the kind. Last in the
stack, so it is written against the v2 schemas rather than the v1 form
it would have had to use underneath.

- `Settings/Telegram.tsx`: bot token, the numeric chat id, an optional
forum topic id, the message template, an optional self-hosted Bot API
URL, and a send-silently toggle.
- Telegram joins the channel type select and `renderSettings()`.
- Stories for the new and edit screens, a prefill case covering every
field, and a round-trip case so a saved channel comes back unchanged.

#### Issues closed by this PR

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

#### Screenshots / Screen Recordings

[Screen diff against `main`, dark and
light](https://claude.ai/artifact/SeVW4fTm88oSCJpiCq2UM8)

Four new screens: the Telegram form, editing one, saving with the
required fields empty, and one with update denied. The other **115
screens are pixel for pixel the same** as on main, in both themes. The
single remaining row is a focus outline left by a story's own click, 95
pixels, checked by cropping both shots.

#### Additional Information

`chatId` is an int64 and `messageThreadId` an int, the first numeric
fields the channel form has had. An empty or half-typed box sends
nothing rather than `NaN`, which would serialise as `null` and be
rejected. The prefill test's field tuples widen to `string | number`
because a number input reports a number.

The message template is left out of the defaults on purpose: the API
fills `DefaultTelegramMessageTemplate` when the key is absent, and
sending an empty string would overwrite it.

The kind itself was taught to the list, the filter and the JSON view
(including masking the bot token) in the base PR, which is what the
exhaustive per-kind maps require once the schema carries `telegram`.
2026-10-21 17:38:49 +00:00
Vikrant Gupta
e69d3fcc8c fix(metricreduction): type the no-kept-labels preview count so the driver can scan it (#13221)
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

- Previewing a rule that drops every non-protected label of a metric
returned 500 "failed to estimate reduction impact". With no kept labels
the cardinality query selects a bare `1`, which ClickHouse types as
`UInt8`, and the driver refuses to scan that into the `uint64` the code
reads it into.
- The literal is now `toUInt64(1)`, matching the scan target, so such
previews report one retained series as intended.
2026-10-21 12:33:51 +00:00
Naman Verma
354214ca87 chore: drop sloglint key-naming-case (#13220)
<!--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

Dotted keys like rule.id are already the convention across the codebase.
2026-10-21 12:04:26 +00:00
Vikrant Gupta
e3488c1592 feat(authz): enable FGA for metrics explorer routes (#13150)
#### Description

- Metrics explorer routes were still on the coarse `ViewAccess` gate, so
a custom role scoped to a workspace key on metrics could list, inspect
and read stats for every metric through them while being denied on
`query_range`.
- Stats, treemap, inspect, highlights and onboarding derive the resource
from the caller's `filter.expression` or resolve to the metrics
wildcard, exactly as `query_range` does. Key-scoped custom roles get 403
unless the filter pins a granted key.
- A new `metrics-field` metaresource covers the metrics list, the
metadata and attributes reads, and the metadata update, with wildcard
grants: admin and editor may update, every managed role may list and
read. Existing organizations get the tuples through a migration.
- The alerts and dashboards lookups read the rule and dashboard stores,
so they require `rule:list` and `dashboard:list`.
- The new `metricsexplorer` integration suite is added to the CI matrix.

#### Issues closed by this PR

Part of SigNoz/keystone-pod#141
2026-10-21 11:40:56 +00:00
Swapnil Nakade
574bfa62b8 feat: enabled logs and added collection strategy for gcp integration (#13184)
<!--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
- with release of 0.163.0 of collector contrib, `googlecloudpubsubpush`
receiver is published
- enabling logs services in gcp integration for manual flow

<!--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/164

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-21 11:40:25 +00:00
211 changed files with 9254 additions and 2369 deletions

View File

@@ -0,0 +1,15 @@
---
paths:
- "frontend/src/**/*.tsx"
---
# Storybook
Page stories are written with the `signoz-page-story` skill ([`.claude/skills/signoz-page-story/SKILL.md`](../skills/signoz-page-story/SKILL.md)); the API surface is [`frontend/src/storybook/README.md`](../../frontend/src/storybook/README.md).
- **A UI change lands with its story.** Any change that alters what a page renders — a new page, a new state, a new branch, a changed layout — updates the story of every page that renders it, or creates one where the page has none. Go through the `signoz-page-story` skill; never hand-write a page story outside it.
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.
- **Prove it with a visual diff when asked.** Before/after screenshots and "did this touch other screens" go through `sbshot-visual-diff`, which can only show what a story renders.

View File

@@ -5,15 +5,9 @@ description: Shoot Storybook stories with sbshot at two git refs and produce a d
# sbshot visual diffs
`npx -y github:SigNoz/sbshot` shoots Storybook stories and pixel-diffs two runs.
Use it. Do not hand-roll a Playwright screenshot loop or a canvas pixel-compare:
sbshot already handles story selection, themes, settling, retries, diffing,
tagging, a viewer and a static export, and its runs are the format the rest of
the team can open.
`npx -y github:SigNoz/sbshot` shoots Storybook stories and pixel-diffs two runs. Use it. Do not hand-roll a Playwright screenshot loop or a canvas pixel-compare: sbshot already handles story selection, themes, settling, retries, diffing, tagging, a viewer and a static export, and its runs are the format the rest of the team can open.
sbshot is not installed by any package. It is run through `npx`, so nothing it
knows travels into this repo on its own. This file is where that knowledge
lives.
sbshot is not installed by any package. It is run through `npx`, so nothing it knows travels into this repo on its own. This file is where that knowledge lives.
## The shape of a run
@@ -24,58 +18,31 @@ tag <diff> <tag> <story-id>... # what the reviewer should look at
export <diff> --out <dir> # a static site, hostable
```
`<storybook>` is a running dev server (`http://localhost:6006`), a build
directory holding `index.json`, or a project directory with `.storybook/`. The
frontend has **no `build-storybook` script**, only `storybook dev -p 6006`, so
point `capture` at a dev server.
`<storybook>` is a running dev server (`http://localhost:6006`), a build directory holding `index.json`, or a project directory with `.storybook/`. The frontend has **no `build-storybook` script**, only `storybook dev -p 6006`, so point `capture` at a dev server.
## Cover the change with stories first
**A diff can only show what a story renders.** A screen, state or variant with no
story is invisible to sbshot: it produces no pair, so it reads as "nothing
changed" rather than "never looked at". This is the failure mode that makes a
clean-looking diff worthless.
**A diff can only show what a story renders.** A screen, state or variant with no story is invisible to sbshot: it produces no pair, so it reads as "nothing changed" rather than "never looked at". This is the failure mode that makes a clean-looking diff worthless.
Before capturing, list the screens and states the change touches and check each
has a story. For anything missing, write it first — the `signoz-page-story`
skill covers how. New states reached only by an interaction need a `play`
function, or the capture shoots the page at rest and the state never appears.
Before capturing, list the screens and states the change touches and check each has a story. For anything missing, write it first — the `signoz-page-story` skill covers how. New states reached only by an interaction need a `play` function, or the capture shoots the page at rest and the state never appears.
Then confirm it worked: the two runs' story counts should differ by exactly the
stories the change adds. A new screen that produced no new story id is a screen
nobody is reviewing. Say the count in the summary, so a reader can tell coverage
from silence.
Then confirm it worked: the two runs' story counts should differ by exactly the stories the change adds. A new screen that produced no new story id is a screen nobody is reviewing. Say the count in the summary, so a reader can tell coverage from silence.
## Rules that are not optional
**One freshly started Storybook per ref.** Never start one server and switch git
branches under it. Vite's HMR reloads the page, but some modules do not
re-initialise, so the shot differs silently from a clean boot and the difference
looks like a real change. Settle time does not fix this and is not the variable.
Use one worktree and one port per ref, and stop each server before the next.
**One freshly started Storybook per ref.** Never start one server and switch git branches under it. Vite's HMR reloads the page, but some modules do not re-initialise, so the shot differs silently from a clean boot and the difference looks like a real change. Settle time does not fix this and is not the variable. Use one worktree and one port per ref, and stop each server before the next.
**Capture both themes: `--theme dark,light`.** Without it sbshot shoots the
story's own default and everything lands in a single `default/` folder, so every
regression in the other theme is invisible. Themes double the shot count, so
scope with `--stories` or `--title`, never by dropping a theme.
**Capture both themes: `--theme dark,light`.** Without it sbshot shoots the story's own default and everything lands in a single `default/` folder, so every regression in the other theme is invisible. Themes double the shot count, so scope with `--stories` or `--title`, never by dropping a theme.
**Baseline is the merge-base, not `origin/main`.** `git merge-base origin/main
<branch>`. Against a moved main, every commit main gained since the branch's base
shows up reversed, as though the branch reverted it.
**Baseline is the merge-base, not `origin/main`.** `git merge-base origin/main <branch>`. Against a moved main, every commit main gained since the branch's base shows up reversed, as though the branch reverted it.
**The percentage sorts the list, it does not tell you what matters.** A dialog
that dims the page scores high for a one-line change, and a whole new form
section can score low. Open the shots and say what changed on each screen. The
biggest number is usually the least interesting one.
**The percentage sorts the list, it does not tell you what matters.** A dialog that dims the page scores high for a one-line change, and a whole new form section can score low. Open the shots and say what changed on each screen. The biggest number is usually the least interesting one.
**If you cannot explain a pair, assume the capture is at fault, not the code.**
Open it, crop both sides and compare them before calling it noise. A pixel count
and a good guess are not evidence.
**If you cannot explain a pair, assume the capture is at fault, not the code.** Open it, crop both sides and compare them before calling it noise. A pixel count and a good guess are not evidence.
## Noise, and the floor that proves it
The same story does not always render identically twice. Pairs often move for
reasons that have nothing to do with the change:
The same story does not always render identically twice. Pairs often move for reasons that have nothing to do with the change:
- a focus ring left on whatever a `play` function clicked last
- a scrollbar appearing, or its thumb resting a few pixels off
@@ -86,9 +53,7 @@ reasons that have nothing to do with the change:
- hover or active styling from where the pointer happened to rest
- a late-loading font or image shifting a line of text
Do not argue about these one at a time. **Measure them**: shoot the same commit
twice and diff those two runs against each other. Anything that moves there moved
for no reason, so it is noise.
Do not argue about these one at a time. **Measure them**: shoot the same commit twice and diff those two runs against each other. Anything that moves there moved for no reason, so it is noise.
```
capture <storybook> --name-run noise-a
@@ -97,60 +62,35 @@ diff noise-a noise-b --name-diff noise
diff <baseline> <after> --noise noise # marks the movers flaky
```
**The noise run has to cover the same stories as the real diff.** Do not scope it
down to the few pairs that moved, however tempting that is. `--noise` marks a
pair flaky when the noise diff does not show it as unchanged, so every story the
noise run leaves out is marked flaky too, including the new screens the reviewer
is there to look at. Shooting two narrow runs and diffing those against each
other has the same problem. Either shoot the full set twice, or skip `--noise`
and explain the few movers by hand.
**The noise run has to cover the same stories as the real diff.** Do not scope it down to the few pairs that moved, however tempting that is. `--noise` marks a pair flaky when the noise diff does not show it as unchanged, so every story the noise run leaves out is marked flaky too, including the new screens the reviewer is there to look at. Shooting two narrow runs and diffing those against each other has the same problem. Either shoot the full set twice, or skip `--noise` and explain the few movers by hand.
That is the trade: a second full capture costs about as long as the first, so on
a small diff it is often quicker to crop the two or three movers and say what
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
into CI, where something has to decide without a person looking.
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run hides a real 8% change in the real one. Compare the two numbers before trusting the mark. When most of the flaky marks are hiding real changes, run the diff without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing real is filtered out of the reviewer's view.
If a pair is flaky every run and the state it captures matters, fix the story
rather than living with it: blur what the `play` clicked, freeze the clock, or
assert the state instead of shooting it.
That is the trade: a second full capture costs about as long as the first, so on a small diff it is often quicker to crop the two or three movers and say what each one is. Use `--noise` when the diff is large, or when wiring `--fail-over` into CI, where something has to decide without a person looking.
If a pair is flaky every run and the state it captures matters, fix the story rather than living with it: blur what the `play` clicked, freeze the clock, or assert the state instead of shooting it.
## Making it readable for a reviewer
A reviewer cannot open `localhost`, so `sbshot ui` is for you, not for them.
1. `tag` the pairs that matter, with `--note` saying why. The viewer filters by
tag, so the reviewer lands on the real changes instead of scrolling past
noise that scored higher.
2. `export <diff> --out <dir>`. It writes a static site carrying only the pairs
that are not identical, converted to WebP, so a large diff stays small.
3. Host it. A Claude artifact works: publish the export's files and give the
reviewer the link. `index.html` is reserved for the artifact's own page, so
publish sbshot's entry as `viewer.html` and link to
`viewer.html#/job/diff/<diff>`.
1. `tag` the pairs that matter, with `--note` saying why. The viewer filters by tag, so the reviewer lands on the real changes instead of scrolling past noise that scored higher.
2. `export <diff> --out <dir>`. It writes a static site carrying only the pairs that are not identical, converted to WebP, so a large diff stays small.
3. Host it. A Claude artifact works: publish the export's files and give the reviewer the link. `index.html` is reserved for the artifact's own page, so publish sbshot's entry as `viewer.html` and link to `viewer.html#/job/diff/<diff>`.
4. Put the link in the PR body under **Screenshots / Screen Recordings**.
Say the numbers plainly: how many screens changed, how many are new, how many are
pixel-identical, how many are flaky. The count of unchanged screens is often the
most useful claim in the diff, because it is the one a reviewer cannot check by
eye.
Say the numbers plainly: how many screens changed, how many are new, how many are pixel-identical, how many are flaky. The count of unchanged screens is often the most useful claim in the diff, because it is the one a reviewer cannot check by eye.
## Operational traps
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.
- `pnpm test:storybook` starts its own server on 6006 and hangs silently if
something already holds that port. Free it first.
- ImageMagick on macOS may have no default font, so `--mode green-parallel` fails
with ``unable to read font `' ``. Map Helvetica to a real file in
`~/.config/ImageMagick/type.xml`.
- `sbshot link <dir> <name>` adds a run from elsewhere into the workspace, but it
symlinks. Keep the source directory alive until you have exported.
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or `--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how many to shoot at once by sampling free memory, and off Linux it reads `os.freemem()`, which counts only truly free pages. macOS parks almost everything in "inactive" instead, which is reclaimable on demand, so the sampler can read ~100 MB on a 25 GB machine and shrink to one forever. `--jobs` does not help: it is only an upper cap. Check `parallelism` in `<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes inactive, that is this. A 124-shot run takes about 90 minutes pinned and about 6 minutes at four at a time, so it is worth spotting before a long capture.
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is gitignored and generated. Run `node ./i18-generate-hash.cjs` in `<worktree>/frontend`, and symlink `node_modules` from the main checkout.
- `pnpm test:storybook` starts its own server on 6006 and hangs silently if something already holds that port. Free it first.
- ImageMagick on macOS may have no default font, so `--mode green-parallel` fails with ``unable to read font `' ``. Map Helvetica to a real file in `~/.config/ImageMagick/type.xml`.
- `sbshot link <dir> <name>` adds a run from elsewhere into the workspace, but it symlinks. Keep the source directory alive until you have exported.
- The export directory is build output. Delete it when done, or gitignore it.
- Running the whole Storybook test suite locally fails broadly with
`page.goto: Timeout` from resource starvation. Scope it; CI is the real signal.
- Running the whole Storybook test suite locally fails broadly with `page.goto: Timeout` from resource starvation. Scope it; CI is the real signal.
## Done means

View File

@@ -5,63 +5,35 @@ description: Explore a SigNoz page, map the endpoints, states and query params i
# SigNoz page stories
A page story renders the real page inside the real app shell against msw, and its
controls panel can reach every state the page has. The panel is the deliverable,
not the story list.
A page story renders the real page inside the real app shell against msw, and its controls panel can reach every state the page has. The panel is the deliverable, not the story list.
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
control builders, module mocks, navigation). Read it first; this skill is the
process on top of it.
`frontend/src/storybook/README.md` is the API surface (providers, parameters, control builders, module mocks, navigation). Read it first; this skill is the process on top of it.
## Workflow
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
the inventory (endpoints, states, params, permissions, caps) before writing
code. No inventory, no story.
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
names the endpoints step 1 missed.
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce the inventory (endpoints, states, params, permissions, caps) before writing code. No inventory, no story.
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console names the endpoints step 1 missed.
3. **Inventory to controls**: [references/controls.md](references/controls.md).
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's
mocks module.
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
report the story as done without it.
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's mocks module.
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never report the story as done without it.
## 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.
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.
**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`.
- 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`.
- 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.
- 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.
**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:
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag filter answers questions with. Only these:
| Tag | When |
| --- | --- |
@@ -73,8 +45,7 @@ filter answers questions with. Only these:
`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:
**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, {
@@ -97,79 +68,33 @@ const meta = {
} 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.
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``.
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.
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.
**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.
**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.
**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.
**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.
**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.
Check these by what is turned off, not by what the page says. The page reads the
same either way, so a story that is wrong still looks right. Check that the
control the permission guards is off, and that the denial message is there or
gone.
Check these by what is turned off, not by what the page says. The page reads the same either way, so a story that is wrong still looks right. Check that the control the permission guards is off, and that the denial message is there or gone.
**Do not use `toBeEnabled` or `toBeDisabled`.** They only look at the `disabled`
attribute. `@signozhq/ui` turns a button off with `aria-disabled` instead, so
these matchers pass on a button nobody can click, and the test goes green while
clicking straight through a denial. Check the attribute the component really
sets:
**Do not use `toBeEnabled` or `toBeDisabled`.** They only look at the `disabled` attribute. `@signozhq/ui` turns a button off with `aria-disabled` instead, so these matchers pass on a button nobody can click, and the test goes green while clicking straight through a denial. Check the attribute the component really sets:
```ts
expect(save).toHaveAttribute('aria-disabled', 'true');
```
**Some controls are off with no attribute at all.** The browser turns off
everything inside a disabled `<fieldset>` without marking it, and a switch is a
`<button>` underneath. For those, an attribute check proves nothing, and so does
looking for a parent `fieldset[disabled]`. Click it and check nothing happened:
**Some controls are off with no attribute at all.** The browser turns off everything inside a disabled `<fieldset>` without marking it, and a switch is a `<button>` underneath. For those, an attribute check proves nothing, and so does looking for a parent `fieldset[disabled]`. Click it and check nothing happened:
```ts
const before = toggle.getAttribute('aria-checked');
@@ -177,66 +102,22 @@ await userEvent.click(toggle);
await expect(toggle.getAttribute('aria-checked')).toBe(before);
```
**Check that a new assertion can fail.** Flip it once and run it. If that story
goes red and nothing else does, the assertion is real; put it back. A test that
cannot fail is worse than no test, because it looks like coverage.
**Check that a new assertion can fail.** Flip it once and run it. If that story goes red and nothing else does, the assertion is real; put it back. A test that cannot fail is worse than no test, because it looks like coverage.
**Delete a story once its difference disappears.** Say a story checks that a
button reads "View" rather than "Edit" when a permission is missing. If the label
later becomes the same either way, that story passes no matter which permissions
are held, including all of them, and it is testing nothing. When a behaviour
stops depending on a permission, delete the story or rewrite it to lock in the
new behaviour, and say which one you did in the PR.
**Delete a story once its difference disappears.** Say a story checks that a button reads "View" rather than "Edit" when a permission is missing. If the label later becomes the same either way, that story passes no matter which permissions are held, including all of them, and it is testing nothing. When a behaviour stops depending on a permission, delete the story or rewrite it to lock in the new behaviour, and say which one you did in the PR.
## Rules
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
every widget carrying data. Empty, loading and failed are variants or control
values, never the default.
- **A control is a knob on a response**, resolved through `handlers`, `config` or
`effect`. Never a component prop, never a module mock added for one story.
- **Every branch in the inventory is reachable from the panel.** A state that
needs a code edit to see is a missing control.
- **Never re-declare what every story already has**: banner, side nav, data state
(loaded/loading/error), access preset, permissions, check state.
- **A variant earns a story only when it is worth linking to**: an empty
workspace, a viewer, a page mid-load. Everything else stays a control.
- **Endpoints the page owns go through `response.json`**, so the Data control
covers loaded, loading and failed in one declaration. Endpoints the page cannot
render without (ingestion detection, preferences, feature payloads) take a
plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param
navigation works inside a story; a different pathname is blocked and reported
by the overlay. A control for a param is worth it only when the param is a page
mode someone would want to flip.
- **File layout**: every story file for a page lives under
`src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`,
payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing
page-specific in `src/storybook/controls/`. 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:
- **Default is the loaded page.** `export const Default: Story = {}` with no args, every widget carrying data. Empty, loading and failed are variants or control values, never the default.
- **A control is a knob on a response**, resolved through `handlers`, `config` or `effect`. Never a component prop, never a module mock added for one story.
- **Every branch in the inventory is reachable from the panel.** A state that needs a code edit to see is a missing control.
- **Never re-declare what every story already has**: banner, side nav, data state (loaded/loading/error), access preset, permissions, check state.
- **A variant earns a story only when it is worth linking to**: an empty workspace, a viewer, a page mid-load. Everything else stays a control.
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing page-specific in `src/storybook/controls/`. 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:
```ts
/**
@@ -245,52 +126,23 @@ new behaviour, and say which one you did in the PR.
*/
```
The root `.gitattributes` marks both paths `linguist-generated=true`, so the
reviewer gets them collapsed and spends the attention on the rendered page. The
story file is the human surface and never carries the banner. A file with the
banner has to stay regenerable from the page alone: no page knowledge in
`src/storybook/`, and builders typed from `src/api/generated` where the endpoint
has types, so a contract change is a compile error instead of a mock that lies.
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
writing a response shape inline, check if a builder exists; if not and the
shape will repeat, add it there. Page-specific builders stay in the page's
`__story_mockdata__/`.
- **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
name, a type, or what a builder plainly builds; if the sentence reads as the
signature in prose, delete it. Nothing addressed to a reviewer. The one
comment a story always gets is its own doc comment: what it shows, in the
page's own terms.
The root `.gitattributes` marks both paths `linguist-generated=true`, so the reviewer gets them collapsed and spends the attention on the rendered page. The story file is the human surface and never carries the banner. A file with the banner has to stay regenerable from the page alone: no page knowledge in `src/storybook/`, and builders typed from `src/api/generated` where the endpoint has types, so a contract change is a compile error instead of a mock that lies.
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before writing a response shape inline, check if a builder exists; if not and the shape will repeat, add it there. Page-specific builders stay in the page's `__story_mockdata__/`.
- **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 name, a type, or what a builder plainly builds; if the sentence reads as the signature in prose, delete it. Nothing addressed to a reviewer. The one comment a story always gets is its own doc comment: what it shows, in the page's own terms.
## Done means
- [ ] `Default` shows the page with data, checked in dark and light
- [ ] 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
- [ ] 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
request, no React warning
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
- [ ] no navigation overlay on mount
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
`pnpm exec oxfmt --check <files>` all clean. The repo has no
prettier: `pnpm exec prettier` prints a pass while exiting 254
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`, `pnpm exec oxfmt --check <files>` all clean. The repo has no prettier: `pnpm exec prettier` prints a pass while exiting 254

View File

@@ -1,13 +1,10 @@
# Turning the inventory into controls
Every row of the inventory becomes a control, a global control that already
exists, or a documented reason it cannot be one.
Every row of the inventory becomes a control, a global control that already exists, or a documented reason it cannot be one.
## Imports
Paths written as `src/storybook/...` in prose are repo paths, not import
specifiers. Stories import through the `@/` alias (`@/*` → `./src/*`); modules
inside `src/storybook/` import each other relatively.
Paths written as `src/storybook/...` in prose are repo paths, not import specifiers. Stories import through the `@/` alias (`@/*` → `./src/*`); modules inside `src/storybook/` import each other relatively.
| Import | From |
| --- | --- |
@@ -31,38 +28,21 @@ inside `src/storybook/` import each other relatively.
Rules that come with them:
- `countControl` `max` goes past what the page renders, so a story can show the
cap being hit. `0` is the empty state, which is why an empty list rarely needs
its own story. When the cap is in the *request* (`?limit=5`) rather than the
renderer, stop `max` at the limit: a longer response is a body the backend
cannot send.
- `choiceControl` options come from a `const` array typed with
`(typeof X)[number]`, not from string literals scattered in the handlers.
- Defaults describe the fully-populated page. The panel starts where `Default`
starts.
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
control.
- `description` only when the name does not carry the effect (what dismissing
does, what the cap is, which widget it feeds).
- `countControl` `max` goes past what the page renders, so a story can show the cap being hit. `0` is the empty state, which is why an empty list rarely needs its own story. When the cap is in the _request_ (`?limit=5`) rather than the renderer, stop `max` at the limit: a longer response is a body the backend cannot send.
- `choiceControl` options come from a `const` array typed with `(typeof X)[number]`, not from string literals scattered in the handlers.
- Defaults describe the fully-populated page. The panel starts where `Default` starts.
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or `'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per control.
- `description` only when the name does not carry the effect (what dismissing does, what the cap is, which widget it feeds).
## Which hook
`defineStoryMocks` takes three, all optional:
- `handlers(values, response)`: the page's endpoints. Everything the page owns
goes through `response.json`, so the global Data control turns the whole page
into loading or failed without a second declaration. An endpoint the page
cannot render at all without (ingestion detection, preferences, license
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
visible while the rest hangs or fails.
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
`appContext`, `reduxState`, `queryBuilder`, `theme`.
- `handlers(values, response)`: the page's endpoints. Everything the page owns goes through `response.json`, so the global Data control turns the whole page into loading or failed without a second declaration. An endpoint the page cannot render at all without (ingestion detection, preferences, license payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays visible while the rest hangs or fails.
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`, `appContext`, `reduxState`, `queryBuilder`, `theme`.
- `effect(values)`: module-level state no provider exposes.
One endpoint feeding several widgets stays one handler that reads the request.
`response.json` hands the request to the builder and awaits it, so reading a
query param, or a POST body, does not cost the Data control:
One endpoint feeding several widgets stays one handler that reads the request. `response.json` hands the request to the builder and awaits it, so reading a query param, or a POST body, does not cost the Data control:
```ts
rest.get(
@@ -85,18 +65,11 @@ rest.post(
),
```
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
endpoint has to keep answering while the Data control is on `loading` or
`error`: detection calls the page cannot render without.
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the endpoint has to keep answering while the Data control is on `loading` or `error`: detection calls the page cannot render without.
## Mutations
A control drives the response, so a write the page makes against state a control
owns does not stick: the refetch answers with the control's value and the button
appears to do nothing. Two honest options: leave it declarative and say so in
the PR, or move the state into `effect` so the handler can read what the page
wrote. Never fake the write by mutating a builder's module state without saying
where the state lives.
A control drives the response, so a write the page makes against state a control owns does not stick: the refetch answers with the control's value and the button appears to do nothing. Two honest options: leave it declarative and say so in the PR, or move the state into `effect` so the handler can read what the page wrote. Never fake the write by mutating a builder's module state without saying where the state lives.
## Wiring it up
@@ -146,15 +119,11 @@ const meta = {
} 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.
`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`:
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
@@ -179,39 +148,23 @@ const meta = {
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.
**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.
- 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.
An endpoint that only settles the transition between two steps (the profile a questionnaire saves before its last page) takes a plain resolver, or the Data control on `loading` strands the walk halfway.
## Not a control
- Anything the global controls already cover: banner, side nav, data state,
access preset, granted permissions, revoked permissions, check state.
- A knob whose effect nobody can see on the page. Delete it or find the widget it
was supposed to drive.
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
`viewer`), and the builder turns intent into the payload.
- Anything that needs a module mock or a component prop to work. If the state
cannot be produced from a response, config or module state, say so in the PR
instead of faking it.
- Anything the global controls already cover: banner, side nav, data 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`, `viewer`), and the builder turns intent into the payload.
- Anything that needs a module mock or a component prop to work. If the state cannot be produced from a response, config or module state, say so in the PR instead of faking it.
## Control or story
@@ -220,12 +173,8 @@ 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
- 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.
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.
Everywhere else the comment rule in SKILL.md applies: write one only for what
the code cannot show.
Each story gets one prose doc comment: what it shows, in the page's own terms. Everywhere else the comment rule in SKILL.md applies: write one only for what the code cannot show.

View File

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

View File

@@ -1,7 +1,6 @@
# Verifying a page story
A story is not done because it compiles. It is done when each control has been
seen changing the page and the console is silent.
A story is not done because it compiles. It is done when each control has been seen changing the page and the console is silent.
## Run it
@@ -9,12 +8,9 @@ seen changing the page and the console is silent.
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.
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an already-running server; an empty first poll is not a broken `stories` glob.
Story ids come from the meta title: `Pages/Services/List` →
`pages-services-list`, plus the story export in kebab-case. Render one story on
its own:
Story ids come from the meta title: `Pages/Services/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-list--default&viewMode=story
@@ -22,9 +18,7 @@ http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
## Flip controls from the URL
Args are settable in the iframe URL, so a whole sweep runs headless without
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
indexed, several separated by `;`, and the theme through `globals`:
Args are settable in the iframe URL, so a whole sweep runs headless without touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays indexed, several separated by `;`, and the theme through `globals`:
```
&args=services:0;apdex:poor;access:viewer;dataState:loading
@@ -32,13 +26,11 @@ indexed, several separated by `;`, and the theme through `globals`:
&globals=theme:light
```
That is the cheap way to check a control does something: load with and without
it, diff the page text.
That is the cheap way to check a control does something: load with and without it, diff the page text.
## Drive it
Playwright lives in the repo's e2e workspace, so a scratch script can use it
directly:
Playwright lives in the repo's e2e workspace, so a scratch script can use it directly:
```js
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
@@ -58,40 +50,18 @@ console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
await browser.close();
```
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`.
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`.
That is one run, for checking the story you just wrote. Comparing two branches is
a different job: see the `sbshot-visual-diff` skill, which covers the baseline to
pick, measuring the noise and exporting something a reviewer can open.
That is one run, for checking the story you just wrote. Comparing two branches is a different job: see the `sbshot-visual-diff` skill, which covers the baseline to pick, measuring the noise and exporting something a reviewer can open.
## Gates
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
msw unhandled-request warning, a React key or state warning: assume the story
is wrong first. A warning that survives is sometimes the app's. Prove it by
turning off the control that renders the widget and watching the warning go
with it, and by finding the same component elsewhere doing it right. Then
report the app bug in the PR. Never invent a field the API does not return to
silence a warning.
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
means the page is trying to leave: wrong `route`, or a guard denying on a
permission the controls did not grant.
- **Every control moves something.** Sweep them one at a time from the URL and
diff the page text. A control with no diff is either wired to nothing or aimed
at a widget that is not rendering. Some only show their effect after an
interaction, such as a tab that has to be clicked or a select that has to be
opened. Drive that interaction rather than calling the control unobservable.
- **Both themes render styled.** An unstyled page means the story is not inside
the provider decorator, or `<body data-theme>` was lost.
- **Roles agree.** `<body data-signoz-story-role>` and
`<body data-signoz-context-role>` disagreeing means the page reads a different
`AppContext` than the story config fills.
- **The page's own navigation works.** Tabs, filters and pagination that write
query params should re-render the page in place; only leaving the page belongs
in the overlay.
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an msw unhandled-request warning, a React key or state warning: assume the story is wrong first. A warning that survives is sometimes the app's. Prove it by turning off the control that renders the widget and watching the warning go with it, and by finding the same component elsewhere doing it right. Then report the app bug in the PR. Never invent a field the API does not return to silence a warning.
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load means the page is trying to leave: wrong `route`, or a guard denying on a permission the controls did not grant.
- **Every control moves something.** Sweep them one at a time from the URL and diff the page text. A control with no diff is either wired to nothing or aimed at a widget that is not rendering. Some only show their effect after an interaction, such as a tab that has to be clicked or a select that has to be opened. Drive that interaction rather than calling the control unobservable.
- **Both themes render styled.** An unstyled page means the story is not inside the provider decorator, or `<body data-theme>` was lost.
- **Roles agree.** `<body data-signoz-story-role>` and `<body data-signoz-context-role>` disagreeing means the page reads a different `AppContext` than the story config fills.
- **The page's own navigation works.** Tabs, filters and pagination that write query params should re-render the page in place; only leaving the page belongs in the overlay.
## Then the usual
@@ -101,9 +71,7 @@ pnpm exec oxlint <changed files>
pnpm exec oxfmt --check <changed files>
```
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
not one.
There is no prettier in this repo. `pnpm exec prettier --check` fetches something else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is not one.
## Common failures

View File

@@ -75,6 +75,7 @@ jobs:
- ttl
- clickhousecluster
- metricreduction
- metricsexplorer
sqlstore-provider:
- postgres
- sqlite

View File

@@ -41,7 +41,6 @@ linters:
no-global: all
context: all
static-msg: true
key-naming-case: snake
exclusions:
generated: lax
presets:

View File

@@ -3581,6 +3581,7 @@ components:
- meter-metrics
- logs-field
- traces-field
- metrics-field
type: string
CoretypesObject:
properties:
@@ -20637,9 +20638,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:list
- tokenizer:
- VIEWER
- metrics-field:list
summary: List metric names
tags:
- metrics
@@ -20706,9 +20707,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Get metric alerts
tags:
- metrics
@@ -20790,9 +20791,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric attributes
tags:
- metrics
@@ -20860,9 +20861,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metric highlights
tags:
- metrics
@@ -20919,9 +20920,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Inspect raw metric data points
tags:
- metrics
@@ -20989,9 +20990,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric metadata
tags:
- metrics
@@ -21035,9 +21036,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- metrics-field:update
- tokenizer:
- EDITOR
- metrics-field:update
summary: Update metric metadata
tags:
- metrics
@@ -21083,9 +21084,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Check if non-SigNoz metrics have been received
tags:
- metrics
@@ -21142,9 +21143,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics statistics
tags:
- metrics
@@ -21201,9 +21202,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics treemap
tags:
- metrics
@@ -28342,9 +28343,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- dashboard:list
- tokenizer:
- VIEWER
- dashboard:list
summary: Get metric dashboards (v2)
tags:
- metrics

View File

@@ -116,7 +116,7 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "1"
reducedExpr := "toUInt64(1)"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {

View File

@@ -12,6 +12,7 @@
"section_connection": "Connection",
"section_message": "Message",
"section_advanced": "Advanced",
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
"section_recipients": "Recipients",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
@@ -186,6 +187,49 @@
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
"button_discard_channel": "Discard",
"button_discard_confirm": "Discard",
"discard_changes_title": "Discard your changes?",
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
"tooltip_save_no_changes": "Nothing has changed yet.",
"entry_slack_field": "Field",
"entry_slack_action": "Action",
"entry_incidentio_metadata": "Entry",
"label_slack_field_title": "Title",
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
"label_slack_field_value": "Value",
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
"label_incidentio_metadata_key": "Key",
"label_incidentio_metadata_value": "Value",
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
"tooltip_slack_action_text": "The label on the button.",
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
"telegram_tip_link": "Learn how",
"field_telegram_bot_token": "Bot token",
"tooltip_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA...",
"field_telegram_chat_id": "Chat ID",
"tooltip_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
"placeholder_telegram_chat_id": "e.g. -1001234567890",
"field_telegram_message_thread_id": "Message thread ID",
"tooltip_telegram_message_thread_id": "Posts into one message thread of a forum group. Leave empty to post in the group itself.",
"field_telegram_title": "Title",
"tooltip_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped.",
"field_telegram_message": "Message",
"tooltip_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
"field_telegram_api_url": "Bot API URL",
"tooltip_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
"field_telegram_disable_notifications": "Send silently",
"tooltip_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices."
}

View File

@@ -3291,6 +3291,7 @@ export enum CoretypesKindDTO {
'meter-metrics' = 'meter-metrics',
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
}
export enum CoretypesTypeDTO {
user = 'user',

View File

@@ -15,12 +15,12 @@
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
color: var(--l1-foreground);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
color: var(--l2-foreground);
max-width: 400px;
}

View File

@@ -9,18 +9,18 @@
}
.icon {
color: var(--text-vanilla-400);
color: var(--l2-foreground);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
color: var(--l1-foreground);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
color: var(--l2-foreground);
max-width: 400px;
}

View File

@@ -0,0 +1,24 @@
/* The space sits between the way back and the title, rather than above both of
them, so the two read as separate steps. */
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-height: 26px;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--l1-foreground);
}
.divider {
--divider-color: var(--l1-border);
--divider-margin: 10px 0 16px 0;
}

View File

@@ -0,0 +1,56 @@
import { ReactNode } from 'react';
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './BackHeader.module.scss';
export interface BackHeaderProps {
/** Where the page came from, named as the place rather than the action. */
backLabel: string;
backRoute: string;
title: ReactNode;
testId?: string;
}
/**
* A way back on its own line, then the page's own title. The two rows keep the
* title clear of whatever sits beside it, such as a copy button.
*/
function BackHeader({
backLabel,
backRoute,
title,
testId,
}: BackHeaderProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
return (
<>
<div className={styles.header} data-testid={testId}>
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<ArrowLeft size={12} />}
onClick={(e: React.MouseEvent): void => {
safeNavigate(backRoute, { newTab: isModifierKeyPressed(e) });
}}
testId="back-link"
>
{backLabel}
</Button>
<div className={styles.title}>{title}</div>
</div>
<Divider className={styles.divider} />
</>
);
}
BackHeader.defaultProps = {
testId: undefined,
};
export default BackHeader;

View File

@@ -2,8 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
LogViewMode,
FontSize,
OptionsMenuConfig,
} from 'container/OptionsMenu/types';
import {
Check,
ChevronLeft,

View File

@@ -1,9 +1,14 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderVariant } from 'lib/query/queryMode';
import {
BuilderVariant,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
@@ -59,8 +64,20 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[config],
);
const isAIQuery =
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
useEffect(() => {
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
// AI queries are traces-only; BAR must not force metrics on them.
if (isAIQuery) {
handleSetConfig(newPanelType, DataSource.TRACES);
return;
}
if (newPanelType === PANEL_TYPES.BAR) {
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
return;
}
handleSetConfig(newPanelType, currentDataSource);
}
}, [
@@ -69,6 +86,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
initialDataSource,
currentDataSource,
newPanelType,
isAIQuery,
]);
useEffect(() => {

View File

@@ -1,109 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render } from 'tests/test-utils';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('../QueryV2/QueryV2', () => ({ QueryV2: (): null => null }));
jest.mock('../QueryV2/QueryFooter/QueryFooter', () => (): null => null);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const aiQuery: Query = {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: initialQueriesMap.traces.builder.queryData.map((query) => ({
...query,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
})),
},
};
function mockContext({
panelType = null,
initialDataSource = null,
currentQuery = initialQueriesMap.metrics,
}: {
panelType?: PANEL_TYPES | null;
initialDataSource?: DataSource | null;
currentQuery?: Query;
}): jest.Mock {
const handleSetConfig = jest.fn();
mockedUseQueryBuilder.mockReturnValue({
currentQuery,
panelType,
initialDataSource,
handleSetConfig,
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
addTraceOperator: jest.fn(),
handleRunQuery: jest.fn(),
} as unknown as ReturnType<typeof useQueryBuilder>);
return handleSetConfig;
}
describe('QueryBuilderV2 provider config sync', () => {
it('leaves the data source unset for a bar panel without a static config', () => {
const handleSetConfig = mockContext({});
render(<QueryBuilderV2 panelType={PANEL_TYPES.BAR} version="v3" />);
expect(handleSetConfig).toHaveBeenCalledWith(PANEL_TYPES.BAR, null);
});
it('syncs the static data source', () => {
const handleSetConfig = mockContext({});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.LOGS,
);
});
it('keeps an AI query on traces for a bar panel', () => {
const handleSetConfig = mockContext({ currentQuery: aiQuery });
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.TRACES,
);
});
it('does not reset the config when the provider already matches', () => {
const handleSetConfig = mockContext({
panelType: PANEL_TYPES.TIME_SERIES,
initialDataSource: DataSource.TRACES,
});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.TIME_SERIES}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).not.toHaveBeenCalled();
});
});

View File

@@ -3,23 +3,21 @@ import { Columns3 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import type { TableColumnDef } from 'components/TanStackTableView';
import {
hideColumn,
showColumn,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './ColumnSelector.module.scss';
import type { TableColumnDef } from './types';
import { hideColumn, showColumn, useHiddenColumnIds } from './useColumnStore';
interface ColumnSelectorProps<TData> {
columns: TableColumnDef<TData>[];
/** Shared with the table's `columnStorageKey`, which is what it writes to. */
storageKey: string;
testId?: string;
}
function ColumnSelector<TData>({
columns,
storageKey,
testId = 'column-selector-button',
}: ColumnSelectorProps<TData>): JSX.Element {
const hiddenColumnIds = useHiddenColumnIds(storageKey);
@@ -47,7 +45,7 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
testId="alert-columns-button"
testId={testId}
>
Columns
</Button>
@@ -77,4 +75,8 @@ function ColumnSelector<TData>({
);
}
ColumnSelector.defaultProps = {
testId: 'column-selector-button',
};
export default ColumnSelector;

View File

@@ -2,6 +2,7 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export { default as ColumnSelector } from './ColumnSelector';
export * from './TanStackHoverTooltip';
export * from './TanStackTableStateContext';
export * from './types';

View File

@@ -24,7 +24,7 @@ export const REACT_QUERY_KEY = {
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',

View File

@@ -101,6 +101,16 @@ const SPECS: Record<string, Record<string, unknown>> = {
metadata: { team: 'ops' },
sendResolved: true,
},
telegram: {
apiUrl: 'https://api.telegram.org',
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 't',
message: 'm',
disableNotifications: true,
sendResolved: true,
},
};
describe('load then save round trip', () => {

View File

@@ -22,13 +22,26 @@
min-width: 0;
}
/* Laid out like the alert pages: discard on the left, test and save together on
the right. Sticky rather than fixed, so it stays inside the page's own scroll
instead of adding another offset over the bottom strip. */
.actions {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: space-between;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l1-border);
border-top: 1px solid var(--l2-border);
background: var(--l1-background);
}
.actionGroup {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
@@ -36,3 +49,21 @@
opacity: 0.5;
cursor: not-allowed;
}
/* The @signozhq inputs leave their surface transparent and take the card's
colour. antd paints its textareas instead, so beside them they read as a
different control, most visibly in light mode. */
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
.fields :global(textarea.ant-input),
.fields :global(textarea.ant-input:hover),
.fields :global(textarea.ant-input:focus),
.fields :global(textarea.ant-input-focused) {
background: var(--input-background, transparent);
border-width: var(--input-border-width, 1px);
border-style: var(--input-border-style, solid);
border-color: var(--input-border-color, var(--border));
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
box-shadow: var(--input-box-shadow, none);
color: inherit;
}

View File

@@ -1,8 +1,8 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
gap: var(--spacing-3);
margin-bottom: var(--spacing-6);
}
.label {

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Plus } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button } from '@signozhq/ui/button';
import { Input as AntdInput } from 'antd';
@@ -10,6 +10,7 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
const { TextArea } = AntdInput;
@@ -139,10 +140,19 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
help={t('help_incidentio_metadata')}
>
{metadataRows.map((row, index) => (
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.row}>
<div className={styles.field}>
<RowEntry
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
noun={t('entry_incidentio_metadata')}
removeLabel={t('button_incidentio_remove_metadata')}
removeTestId={`incidentio-metadata-remove-${index}`}
onRemove={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
>
<FormField label={t('label_incidentio_metadata_key')}>
<Input
placeholder={t('placeholder_incidentio_metadata_key')}
value={row.key}
@@ -155,8 +165,9 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-key-${index}`}
/>
</div>
<div className={styles.field}>
</FormField>
<FormField label={t('label_incidentio_metadata_value')}>
<Input
placeholder={t('placeholder_incidentio_metadata_value')}
value={row.value}
@@ -169,22 +180,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-value-${index}`}
/>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('button_incidentio_remove_metadata')}
onClick={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
testId={`incidentio-metadata-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
</FormField>
</RowEntry>
))}
<div className={styles.add}>
<Button
variant="dashed"

View File

@@ -1,3 +1,10 @@
/* One height for every control a repeating row renders, so the remove button,
the add button and the inputs beside them agree. */
.entry,
.add {
--row-control-height: 28px;
}
.row {
display: flex;
align-items: center;
@@ -23,7 +30,9 @@
}
.add {
margin-top: var(--spacing-1);
/* Sits with the blocks it adds to, not against the next field's label. */
margin-top: var(--spacing-2);
--button-height: var(--row-control-height);
}
@media (max-width: 720px) {
@@ -35,3 +44,52 @@
max-width: none;
}
}
/* One entry per block, every input labelled. A row of bare boxes gave no way to
tell which column was which once a value was typed. The block keeps a readable
width while the rest of the form fills the page, so its two columns do not
stretch into a row of six. */
.entry {
max-width: 760px;
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--l2-background);
padding: var(--spacing-6) var(--spacing-7);
margin-bottom: var(--spacing-4);
}
.entryTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
/* The remove control is an icon button, so give it the height the design
system gives a small control rather than letting the glyph set it. */
--button-height: var(--row-control-height);
--button-icon-size: 14px;
}
.entryIndex {
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.entryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 var(--spacing-6);
}
/* The toggle reads as a sentence, so it takes the whole width rather than
sitting in a column sized for an input. */
.entryWide {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.entryGrid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,50 @@
import { ReactNode } from 'react';
import { Minus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './RowEditor.module.scss';
interface RowEntryProps {
/** Zero-based; shown to the reader as a count from one. */
index: number;
/** Singular noun for one entry, such as "Field" or "Action". */
noun: string;
removeLabel: string;
removeTestId: string;
onRemove: () => void;
children: ReactNode;
}
function RowEntry({
index,
noun,
removeLabel,
removeTestId,
onRemove,
children,
}: RowEntryProps): JSX.Element {
return (
<div className={styles.entry}>
<div className={styles.entryTop}>
<Typography.Text size="sm" className={styles.entryIndex}>
{noun} {index + 1}
</Typography.Text>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={removeLabel}
onClick={onRemove}
testId={removeTestId}
>
<Minus size={14} />
</Button>
</div>
<div className={styles.entryGrid}>{children}</div>
</div>
);
}
export default RowEntry;

View File

@@ -42,7 +42,9 @@ function Slack(): JSX.Element {
>
<Input {...register('channel')} data-testid="slack-channel-textbox" />
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
@@ -82,7 +84,12 @@ function Slack(): JSX.Element {
)}
/>
</FormField>
</FormCard>
<FormCard
title={t('section_advanced')}
description={t('description_slack_advanced')}
>
<FormField
label={t('field_slack_color')}
help={t('help_slack_color')}

View File

@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
/** `confirm` is an object on the DTO, so its one editable field is reached by path. */
@@ -29,49 +29,37 @@ function SlackActions(): JSX.Element {
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const labelFor = (column: ActionColumn): string =>
t(`label_slack_action_${column.replace('.text', '')}`);
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
return (
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
<FormField
label={t('field_slack_actions')}
tooltip={t('tooltip_slack_actions')}
>
<div>
{fields.length > 0 && (
<div className={styles.row}>
{COLUMNS.map((column) => (
<div key={column} className={styles.field}>
<Typography.Text size="sm" className={styles.heading}>
{labelFor(column)}
</Typography.Text>
</div>
))}
</div>
)}
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_action')}
removeLabel={t('remove_slack_action')}
removeTestId={`slack-action-remove-${index}`}
onRemove={(): void => remove(index)}
>
{COLUMNS.map((column) => (
<div key={column} className={styles.field}>
<FormField
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
>
<Input
{...register(`actions.${index}.${column}` as const)}
placeholder={t(
`placeholder_slack_action_${column.replace('.text', '')}`,
)}
testId={`slack-action-${column.replace('.text', '')}-${index}`}
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
testId={`slack-action-${keyFor(column)}-${index}`}
/>
</div>
</FormField>
))}
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_action')}
onClick={(): void => remove(index)}
testId={`slack-action-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
</RowEntry>
))}
</div>

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
@@ -8,6 +8,7 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
@@ -17,52 +18,61 @@ function SlackFields(): JSX.Element {
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
<FormField
label={t('field_slack_fields')}
tooltip={t('tooltip_slack_fields')}
>
<div>
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
<div className={styles.field}>
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_field')}
removeLabel={t('remove_slack_field')}
removeTestId={`slack-field-remove-${index}`}
onRemove={(): void => remove(index)}
>
<FormField
label={t('label_slack_field_title')}
tooltip={t('tooltip_slack_field_title')}
>
<Input
{...register(`fields.${index}.title`)}
placeholder={t('placeholder_slack_field_title')}
testId={`slack-field-title-${index}`}
/>
</div>
<div className={styles.field}>
</FormField>
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
>
<Input
{...register(`fields.${index}.value`)}
placeholder={t('placeholder_slack_field_value')}
testId={`slack-field-value-${index}`}
/>
</FormField>
<div className={styles.entryWide}>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
</div>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_field')}
onClick={(): void => remove(index)}
testId={`slack-field-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
</RowEntry>
))}
</div>

View File

@@ -0,0 +1,174 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input as AntdInput } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
const { TextArea } = AntdInput;
/**
* The API models the chat and the topic as integers. An empty or half-typed box
* has to become `undefined`, not `NaN`: `omitBlank` keeps `NaN`, which
* serialises as `null` and the API rejects it. react-hook-form also runs this
* over the value already in the form, which arrives as a number, not a string.
*/
const asOptionalNumber = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isNaN(value) ? undefined : value;
}
if (typeof value !== 'string' || value.trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
function TelegramSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="telegram-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('telegram_tip')}{' '}
<Typography.Link
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
>
{t('telegram_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_telegram_bot_token')}
tooltip={t('tooltip_telegram_bot_token')}
error={errors.botToken?.message}
required
>
<Input
{...register('botToken')}
aria-required
type="password"
data-testid="telegram-bot-token-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_chat_id')}
tooltip={t('tooltip_telegram_chat_id')}
error={errors.chatId?.message}
required
>
<Input
{...register('chatId', { setValueAs: asOptionalNumber })}
aria-required
type="number"
placeholder={t('placeholder_telegram_chat_id')}
data-testid="telegram-chat-id-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_message_thread_id')}
tooltip={t('tooltip_telegram_message_thread_id')}
error={errors.messageThreadId?.message}
>
<Input
{...register('messageThreadId', { setValueAs: asOptionalNumber })}
type="number"
data-testid="telegram-message-thread-id-textbox"
/>
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField
label={t('field_telegram_title')}
tooltip={t('tooltip_telegram_title')}
error={errors.title?.message}
>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="telegram-title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField
label={t('field_telegram_message')}
tooltip={t('tooltip_telegram_message')}
error={errors.message?.message}
>
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="telegram-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<FormCard title={t('section_advanced')}>
<FormField
label={t('field_telegram_api_url')}
tooltip={t('tooltip_telegram_api_url')}
error={errors.apiUrl?.message}
>
<Input {...register('apiUrl')} data-testid="telegram-api-url-textbox" />
</FormField>
<FormField
label={t('field_telegram_disable_notifications')}
tooltip={t('tooltip_telegram_disable_notifications')}
>
<Controller
control={control}
name="disableNotifications"
render={({ field }): JSX.Element => (
<Switch
color="primary"
textPlacement="right"
testId="telegram-disable-notifications-switch"
value={!!field.value}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
</>
);
}
export default TelegramSettings;

View File

@@ -24,6 +24,7 @@ describe('FormAlertChannels', () => {
onTypeChangeHandler={jest.fn()}
onSaveHandler={jest.fn()}
onTestHandler={jest.fn()}
onDiscardHandler={jest.fn()}
testingState={false}
savingState={false}
editing

View File

@@ -1,6 +1,7 @@
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
@@ -20,6 +21,7 @@ import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import TelegramSettings from './Settings/Telegram';
import WebhookSettings from './Settings/Webhook';
import { Button } from '@signozhq/ui/button';
@@ -38,6 +40,7 @@ const CHANNEL_TYPE_OPTIONS = [
{ value: ChannelKind.jira, label: 'Jira' },
{ value: ChannelKind.jsmops, label: 'Jira Service Management Ops' },
{ value: ChannelKind.incidentio, label: 'incident.io' },
{ value: ChannelKind.telegram, label: 'Telegram' },
];
function FormAlertChannels({
@@ -46,10 +49,12 @@ function FormAlertChannels({
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
onDiscardHandler,
savingState,
testingState,
editing = false,
readOnly = false,
hasChanges = true,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
@@ -80,6 +85,8 @@ function FormAlertChannels({
return <IncidentIOSettings />;
case ChannelKind.opsgenie:
return <OpsgenieSettings />;
case ChannelKind.telegram:
return <TelegramSettings />;
case ChannelKind.email:
return <EmailSettings />;
default:
@@ -111,7 +118,10 @@ function FormAlertChannels({
/>
</FormField>
<FormField label={t('field_send_resolved')}>
<FormField
label={t('field_send_resolved')}
tooltip={t('tooltip_send_resolved')}
>
<Controller
control={control}
name="sendResolved"
@@ -127,7 +137,11 @@ function FormAlertChannels({
/>
</FormField>
<FormField label={t('field_channel_type')} htmlFor="channel-type">
<FormField
label={t('field_channel_type')}
tooltip={t('tooltip_channel_type')}
htmlFor="channel-type"
>
<SelectSimple
id="channel-type"
items={CHANNEL_TYPE_OPTIONS}
@@ -144,30 +158,45 @@ function FormAlertChannels({
</fieldset>
<div className={styles.actions}>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<Button
variant="outlined"
color="secondary"
size="md"
testId="discard-channel-button"
prefix={<X size={14} />}
onClick={onDiscardHandler}
>
{t('button_discard_channel')}
</Button>
<div className={styles.actionGroup}>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
disabled={!hasChanges}
disabledTooltip={t('tooltip_save_no_changes')}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
</div>
</div>
</form>
</FormProvider>
@@ -180,12 +209,15 @@ interface FormAlertChannelsProps {
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: () => void;
onTestHandler: () => void;
onDiscardHandler: () => void;
testingState: boolean;
savingState: boolean;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
hasChanges?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
@@ -195,6 +227,7 @@ interface FormAlertChannelsProps {
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
hasChanges: true,
saveChecks: [],
testChecks: [],
};

View File

@@ -1,6 +1,7 @@
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { formUrlParams } from 'utils/traceUtils';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
@@ -20,6 +21,7 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
}
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
tab: TraceDetailsTab.Thread,
spanId,
levelUp: 0,
levelDown: 0,

View File

@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { getMs } from 'utils/timeUtils';
import { useTimezone } from 'providers/Timezone';
@@ -44,7 +45,10 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
to={{
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
search: `?tab=${TraceDetailsTab.Thread}`,
}}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>

View File

@@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -121,22 +122,26 @@ describe('ListAlertRules — actions menu', () => {
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
it('clone action: sends POST to clone endpoint and opens the cloned rule returned by the API', async () => {
let capturedRuleId: string | null = null;
server.use(
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
rest.post(
'http://localhost/api/v2/rules/:id/clone',
async (req, res, ctx) => {
capturedRuleId = req.params.id as string;
return res(
ctx.status(201),
ctx.json({
data: {
...alertRulesFixture[0],
id: 'cloned-from-server',
alert: 'High CPU Alert - Copy',
},
status: 'success',
}),
);
},
),
);
renderListAlertRules();
@@ -145,9 +150,7 @@ describe('ListAlertRules — actions menu', () => {
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
expect(capturedRuleId).toBe('rule-1');
});
// The id from the server response round-trips into the navigate URL — this

View File

@@ -6,10 +6,10 @@ import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
invalidateListRules,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
@@ -62,10 +62,9 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
const handleClone = useCallback((): void => {
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
toast.promise(
createRule({
...rule,
alert: `${rule.alert} - Copy`,
} as RuletypesPostableRuleDTO).then(async (response) => {
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
await invalidateListRules(queryClient);
const newRule = response.data;
if (newRule) {

View File

@@ -1,2 +1 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -15,7 +15,7 @@ import { useTimezone } from 'providers/Timezone';
import TextToolTip from 'components/TextToolTip';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import { ActionsMenu, ColumnSelector } from './components';
import { ActionsMenu } from './components';
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
@@ -134,7 +134,11 @@ function ListAlertRules(): JSX.Element {
{!isEmptyNoRules && (
<div className={styles.header}>
<div className={styles.refreshRow}>
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
/>
{addNewAlert && (
<Button
variant="solid"

View File

@@ -0,0 +1,58 @@
import { MouseEvent, ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useCopySpanLink } from '../useCopySpanLink';
const mockCopy = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn() },
}));
// The router only knows the stale URL; nuqs has since written ?tab=thread.
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
{children}
</MemoryRouter>
);
const clickEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent<HTMLElement>;
describe('useCopySpanLink', () => {
beforeEach(() => {
mockCopy.mockClear();
__setSearchParamsGetterForTest(
() => new URLSearchParams('tab=thread&spanId=old'),
);
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('builds the link from the live URL, keeping its tab', () => {
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
wrapper,
});
result.current.onSpanCopy(clickEvent);
const link = new URL(mockCopy.mock.calls[0][0]);
expect(link.pathname).toBe('/trace/abc');
expect(link.searchParams.get('tab')).toBe('thread');
expect(link.searchParams.get('spanId')).toBe('new');
});
});

View File

@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import useUrlQuery from 'hooks/useUrlQuery';
import { getAbsoluteUrl } from 'utils/basePath';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
@@ -15,7 +15,6 @@ interface SpanLike {
export const useCopySpanLink = (
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
@@ -28,21 +27,22 @@ export const useCopySpanLink = (
event.preventDefault();
event.stopPropagation();
urlQuery.delete('spanId');
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
const id = span.span_id || span.spanId;
if (id) {
urlQuery.set('spanId', id);
params.set('spanId', id);
}
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', {
position: 'top-right',
});
},
[span, urlQuery, pathname, setCopy],
[span, pathname, setCopy],
);
return {

View File

@@ -22,12 +22,17 @@ export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
const keyColor = token(el, '--bg-vanilla-400');
const valueColor = token(el, '--bg-robin-400');
// Both token colours are picked to read on a dark surface, so on a light one
// they wash out, the keys worst of all. Monaco's own `vs` palette is built
// for that surface, so let it keep its colours there, as the role viewer does.
const rules: { token: string; foreground: string }[] = [];
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
if (isDark(background)) {
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
}
const colors: Record<string, string> = {};

View File

@@ -7,8 +7,8 @@ import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import {
useAlertRuleClone,
useAlertRuleDelete,
useAlertRuleDuplicate,
useAlertRuleStatusToggle,
useAlertRuleUpdate,
} from 'pages/AlertDetails/hooks';
@@ -38,9 +38,7 @@ function AlertActionButtons({
const isDarkMode = useIsDarkMode();
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
const { handleAlertDuplicate } = useAlertRuleDuplicate({
alertDetails: alertDetails as unknown as AlertDef,
});
const { handleAlertClone } = useAlertRuleClone({ ruleId });
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
alertDetails: alertDetails as unknown as AlertDef,
@@ -72,10 +70,10 @@ function AlertActionButtons({
menuItems.push(
{
type: 'item',
value: 'duplicate-rule',
label: 'Duplicate',
value: 'clone-rule',
label: 'Clone',
prefix: <Copy size={16} color={Color.BG_VANILLA_400} />,
onClick: handleAlertDuplicate,
onClick: handleAlertClone,
},
{
type: 'item',

View File

@@ -6,7 +6,7 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
cloneRuleByID,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
@@ -17,7 +17,6 @@ import {
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
useGetRuleHistoryTopContributors,
useListRules,
} from 'api/generated/services/rules';
import {
Querybuildertypesv5OrderDirectionDTO,
@@ -478,39 +477,32 @@ export const useAlertRuleStatusToggle = ({
return { handleAlertStateToggle };
};
export const useAlertRuleDuplicate = ({
alertDetails,
export const useAlertRuleClone = ({
ruleId,
}: {
alertDetails: AlertDef;
ruleId: string;
}): {
handleAlertDuplicate: () => void;
handleAlertClone: () => void;
} => {
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const params = useUrlQuery();
const { refetch } = useListRules({
query: { cacheTime: 0 },
});
const { showErrorModal } = useErrorModal();
const { mutate: duplicateAlert } = useMutation(
[REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
(args: { data: AlertDef }) =>
createRule(toPostableRuleDTOFromAlertDef(args.data)),
const { mutate: cloneAlert } = useMutation(
[REACT_QUERY_KEY.CLONE_ALERT_RULE],
() => cloneRuleByID({ id: ruleId }),
{
onSuccess: async () => {
onSuccess: async (response) => {
notifications.success({
message: `Success`,
});
const { data: allAlertsData } = await refetch();
await invalidateListRules(queryClient);
const rules = allAlertsData?.data;
if (rules && rules.length > 0) {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}
params.set(QueryParams.ruleId, response.data.id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
},
onError: (error) =>
showErrorModal(
@@ -519,15 +511,13 @@ export const useAlertRuleDuplicate = ({
},
);
const handleAlertDuplicate = (): void => {
const args = {
data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') },
};
duplicateAlert(args);
const handleAlertClone = (): void => {
cloneAlert();
};
return { handleAlertDuplicate };
return { handleAlertClone };
};
export const useAlertRuleUpdate = ({
alertDetails,
setAlertRuleName,

View File

@@ -107,7 +107,7 @@ export const alertRulesMocks = defineStoryMocks({
})),
),
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
rest.post('http://localhost/api/v2/rules/:id/clone', (_req, res, ctx) =>
actionResponse(values.cloneState, res, ctx, () => ({
status: 'success',
data: alertRulesResponse(1, {

View File

@@ -58,6 +58,12 @@ export const Default: Story = {};
/** A workspace with nowhere to send an alert yet. */
export const NoChannels: Story = {
args: { channels: 0 },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByTestId('channels-empty-state', undefined, UNTIL_LOADED);
await canvas.findByTestId('channels-empty-create');
},
};
/** Data: the channel list's existing loading spinner. */
@@ -105,10 +111,24 @@ export const KindFilter: Story = {
UNTIL_LOADED,
);
await canvas.findByText('sre-email');
// the heading and the create button both name the filtered type
await canvas.findByText('All Email channels');
},
};
/** Interaction: a search matching nothing offers to clear itself. */
/** Interaction: the column menu, which turns table columns off and on. */
export const ColumnSelector: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByTestId('channels-columns'));
await screen.findByText('Toggle Columns');
},
};
/** Interaction: a search that matches no channel. */
export const NoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
@@ -120,6 +140,21 @@ export const NoResults: Story = {
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
},
};
/** Interaction: clearing the search from the empty state brings the list back. */
export const ClearSearch: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.type(
canvas.getByTestId('channels-search'),
'no-such-channel',
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByRole('button', { name: 'Clear Search' }));
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
},

View File

@@ -90,13 +90,16 @@ export const SaveFailure: Story = {
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
untilLoaded,
),
const canvas = within(canvasElement);
// Save is off until the form is edited, so the failure is reached through
// a real change rather than an empty submit.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, untilLoaded),
'-edited',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Storybook forced channel failure');
},
};
@@ -178,3 +181,8 @@ export const JiraServiceManagementOps: Story = {
export const IncidentIO: Story = {
args: { channelType: 'incidentio' },
};
/** A Telegram channel, with its bot token masked behind the password field. */
export const Telegram: Story = {
args: { channelType: 'telegram' },
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { expect, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -82,6 +82,12 @@ export const NoCreate: Story = {
await expectDisabled(
await canvas.findByTestId('test-channel-button', undefined, PAGE_LOAD),
);
// Save is also off until the form is edited, so the edit comes first and
// what is left is the permission.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
false,
@@ -106,6 +112,39 @@ export const ReadOnly: Story = {
},
};
/**
* The same denial on a Telegram channel. Its fields are the kind the form gained
* last, including the only toggle a settings panel renders, so this is where a
* gap in the gating would show first.
*
* The switch is checked by behaviour, not by an attribute: it is a button inside
* the disabled fieldset, so it carries no `disabled` of its own and an
* attribute check would pass whether or not the browser actually turned it off.
*/
export const TelegramReadOnly: Story = {
args: { channelType: 'telegram', revoked: [UPDATE, CREATE, DELETE] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(
await canvas.findByTestId(
'telegram-bot-token-textbox',
undefined,
PAGE_LOAD,
),
);
await expectDisabled(await canvas.findByTestId('telegram-chat-id-textbox'));
await expectDisabled(await canvas.findByTestId('telegram-message-textarea'));
const toggle = await canvas.findByTestId(
'telegram-disable-notifications-switch',
);
const before = toggle.getAttribute('aria-checked');
await userEvent.click(toggle);
await expect(toggle.getAttribute('aria-checked')).toBe(before);
},
};
/** No channel permission at all: the page is denied before the form exists. */
export const NoChannelAccess: Story = {
args: { revoked: NOTIFICATION_CHANNEL },
@@ -135,12 +174,13 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(save, false);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
},
};

View File

@@ -16,6 +16,11 @@ import {
} from '../../stories/__story_mockdata__/alerts';
const ACTIONS = 'Channel · actions';
const ROUTE = 'Channel · route';
/** The `?kind=` values worth reaching: a known type, and one the form rejects. */
const URL_KINDS = ['none', 'jira', 'not-a-kind'] as const;
type UrlKind = (typeof URL_KINDS)[number];
const resolveChannelCreated: MockResolver = (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null }));
@@ -29,7 +34,8 @@ const rejectChannelAction: MockResolver = (_req, res, ctx) =>
/**
* The form holds the channel type in component state, so the type itself is
* stories with a `play` that picks one rather than a control; saving and
* testing the channel are.
* testing the channel are. The type can also arrive in the URL, which the list
* uses to carry its filter over, so that is a control on the route.
*/
export const channelsNewMocks = defineStoryMocks({
controls: {
@@ -43,6 +49,11 @@ export const channelsNewMocks = defineStoryMocks({
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
kindInUrl: choiceControl<UrlKind>('Channel type in the URL', {
group: ROUTE,
options: URL_KINDS,
value: 'none',
}),
},
handlers: (values) => [
rest.post(
@@ -55,5 +66,10 @@ export const channelsNewMocks = defineStoryMocks({
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
),
],
config: () => ({ route: '/alerts/channels/new' }),
config: (values) => ({
route:
values.kindInUrl === 'none'
? '/alerts/channels/new'
: `/alerts/channels/new?kind=${values.kindInUrl}`,
}),
});

View File

@@ -58,9 +58,16 @@ export const ChannelTypeSelectOpen: Story = {
/** Mutation: saving without a channel name shows the form's required-field feedback. */
export const InvalidRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('save-channel-button'),
const canvas = within(canvasElement);
// Save stays off until the form is touched, so filling the webhook first is
// how a missing name is reached.
await userEvent.type(
await canvas.findByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Channel name is mandatory');
},
};
@@ -178,6 +185,38 @@ export const IncidentIO: Story = {
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Telegram$/);
},
};
/**
* Telegram's own required fields. The chat id is the form's first numeric field,
* and the API treats 0 as missing, so an empty box has to report the same error
* as a blank one rather than sending a zero.
*/
export const TelegramRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Telegram$/);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox'),
'alerts-telegram',
);
await userEvent.click(await canvas.findByTestId('save-channel-button'));
await screen.findByText(
'Bot token is mandatory for creating a telegram channel',
);
await screen.findByText(
'Chat ID is mandatory for creating a telegram channel',
);
},
};
/**
* The fields the API rejects a save without are marked before anything is
* submitted, so a long form does not have to be guessed at.
@@ -199,3 +238,38 @@ export const RequiredFields: Story = {
);
},
};
/**
* The type carried in the URL. Filtering the list by a type and pressing its
* create button lands here with `?kind=`, so the form opens on that type with
* nothing to pick.
*/
export const KindFromUrl: Story = {
args: { kindInUrl: 'jira' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Jira$/);
},
};
/**
* A `?kind=` the app does not have. The form opens on its own default rather
* than on an empty type, so a stale or hand-edited link still works.
*/
export const UnknownKindInUrl: Story = {
args: { kindInUrl: 'not-a-kind' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Slack$/);
},
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { expect, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -81,12 +81,13 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox', undefined, PAGE_LOAD),
'check-failed',
);
await expectDisabled(save, false);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
},
};

View File

@@ -11,13 +11,12 @@
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: var(--spacing-6) var(--spacing-8);
padding: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
@@ -25,7 +24,7 @@
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
gap: var(--spacing-4);
}
.headingBlock {

View File

@@ -7,10 +7,11 @@ import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
@@ -25,7 +26,9 @@ import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import ChannelsEmptyState from './components/ChannelsEmptyState/ChannelsEmptyState';
import {
CHANNEL_KIND_LABEL,
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
@@ -156,6 +159,10 @@ function NotificationChannels(): JSX.Element {
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
// With a kind picked, creating one of that kind is the likely next step, so
// the button offers it rather than making the type be chosen again.
const filteredKind =
kind !== KIND_FILTER_ALL ? (kind as ChannelKind) : undefined;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
@@ -169,27 +176,38 @@ function NotificationChannels(): JSX.Element {
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
All notification channels
{filteredKind
? `All ${CHANNEL_KIND_LABEL[filteredKind]} channels`
: 'All notification channels'}
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
{!isEmpty && (
<ColumnSelector
columns={columnsWithActions}
storageKey="notification-channels-columns"
testId="channels-columns"
/>
)}
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void => formView.openCreate(filteredKind)}
testId="channels-create"
>
{filteredKind
? `New ${CHANNEL_KIND_LABEL[filteredKind]} Channel`
: 'New Channel'}
</AuthZButton>
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</AuthZButton>
</div>
<div className={styles.filtersRow}>
@@ -215,12 +233,8 @@ function NotificationChannels(): JSX.Element {
</header>
<div ref={containerRef} className={styles.tableContainer}>
{/* Fails closed, unlike the guard's default: the fetch is gated on the
same check, so falling open would render an empty table shell with
no reason given. The header and filters stay, as on dashboards. */}
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): JSX.Element => (
<PermissionDeniedCallout
deniedPermissions={deniedPermissions}
@@ -245,6 +259,10 @@ function NotificationChannels(): JSX.Element {
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmpty ? (
<ChannelsEmptyState
onCreate={(): void => formView.openCreate(filteredKind)}
/>
) : (
<TanStackTable<Channel>
data={channels}

View File

@@ -10,7 +10,7 @@ const FIND = { timeout: 5000 };
interface PrefillCase {
kind: string;
spec: Record<string, unknown>;
fields: [string, string][];
fields: [string, string | number][];
}
const CASES: PrefillCase[] = [
@@ -186,6 +186,25 @@ const CASES: PrefillCase[] = [
['incidentio-description-textarea', 'incident description'],
],
},
{
kind: 'telegram',
spec: {
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 'telegram title',
message: 'telegram message',
apiUrl: 'https://api.telegram.org',
},
fields: [
['telegram-bot-token-textbox', '123456789:AA-token'],
['telegram-chat-id-textbox', -1001234567890],
['telegram-message-thread-id-textbox', 42],
['telegram-title-textarea', 'telegram title'],
['telegram-message-textarea', 'telegram message'],
['telegram-api-url-textbox', 'https://api.telegram.org'],
],
},
];
describe('ChannelForm prefill', () => {

View File

@@ -72,6 +72,52 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'sends no telegram message, so the backend keeps its own template',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Telegram$/ }));
await user.type(
screen.getByTestId('channel-name-textbox'),
'telegram alerts',
);
await user.type(screen.getByTestId('telegram-bot-token-textbox'), '123:abc');
await user.type(screen.getByTestId('telegram-chat-id-textbox'), '-100123');
// the box is empty on screen, and an empty box must not become an empty
// string in the request: that would overwrite the backend's default
expect(screen.getByTestId('telegram-message-textarea')).toHaveValue('');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
const spec = (body as { config: { spec: Record<string, unknown> } }).config
.spec;
expect(spec).not.toHaveProperty('message');
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
@@ -162,15 +208,20 @@ describe('ChannelForm save', () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
// Save is disabled while the checks are in flight and settles once they
// resolve, so waiting on it means the failure has landed
const save = await screen.findByTestId('save-channel-button', {}, FIND);
const channel = await screen.findByTestId('slack-channel-textbox', {}, FIND);
await waitFor(() => expect(channel).toBeEnabled(), FIND);
// Save is off until something changes, so one edit is what tells a
// settled failure apart from a denial
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(channel, '-edited');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
expect(screen.getByTestId('slack-channel-textbox')).toBeEnabled();
expect(
screen.queryByTestId('channel-read-only-notice'),
).not.toBeInTheDocument();
@@ -178,6 +229,69 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'keeps save off until something is edited',
async () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
// Save is also off while the permission checks are in flight. Test is
// gated on those checks alone, so it settling means Save is only off
// because nothing has changed.
const testButton = await screen.findByTestId(
'test-channel-button',
{},
FIND,
);
await waitFor(
() => expect(testButton).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
const save = screen.getByTestId('save-channel-button');
expect(save).toHaveAttribute('aria-disabled', 'true');
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
},
TEST_TIMEOUT,
);
it(
'asks before discarding an edited form, and leaves straight away otherwise',
async () => {
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const discard = await screen.findByTestId(
'discard-channel-button',
{},
FIND,
);
await user.click(discard);
expect(onDone).toHaveBeenCalledTimes(1);
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await user.click(discard);
// the test harness stubs `t`, so the modal renders the keys themselves
await screen.findByText('discard_changes_title', {}, FIND);
expect(onDone).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole('button', { name: 'button_discard_confirm' }),
);
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(2));
},
TEST_TIMEOUT,
);
it(
'explains a channel the v2 API cannot model instead of showing a blank form',
async () => {

View File

@@ -6,16 +6,27 @@ import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/pe
import ChannelFormBody from './ChannelFormBody';
import styles from './ChannelForm.module.scss';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useChannelDraft } from './useChannelDraft';
interface ChannelFormProps {
channelId?: string;
/** Preselects the type when creating, so a filtered list can offer its kind. */
initialKind?: ChannelKind;
onDone: () => void;
}
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
function ChannelForm({
channelId,
initialKind,
onDone,
}: ChannelFormProps): JSX.Element {
const { t } = useTranslation('channels');
const { draft, isLoading, loadError } = useChannelDraft(channelId);
const { draft, isLoading, loadError } = useChannelDraft(
channelId,
initialKind,
);
const {
canEdit,

View File

@@ -1,3 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import FormAlertChannels from 'container/FormAlertChannels';
import { ChannelFormState } from 'container/CreateAlertChannels/channelFormValues';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
@@ -24,23 +27,54 @@ function ChannelFormBody({
saveChecks,
testChecks,
}: ChannelFormBodyProps): JSX.Element {
const { form, type, onTypeChange, onSave, onTest, isSaving, isTesting } =
useChannelFormState({ draft, target, onDone });
const { t } = useTranslation('channels');
const { contextHolder, confirmDelete } = useDeleteConfirm();
const {
form,
type,
isDirty,
onTypeChange,
onSave,
onTest,
isSaving,
isTesting,
} = useChannelFormState({ draft, target, onDone });
const onDiscard = (): void => {
if (!isDirty) {
onDone();
return;
}
confirmDelete({
title: (
<Typography.Title level={5}>{t('discard_changes_title')}</Typography.Title>
),
content: t('discard_changes_body'),
confirmText: t('button_discard_confirm'),
onConfirm: onDone,
});
};
return (
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
saveChecks={saveChecks}
testChecks={testChecks}
/>
<>
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
onDiscardHandler={onDiscard}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
hasChanges={isDirty}
saveChecks={saveChecks}
testChecks={testChecks}
/>
{contextHolder}
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
ChannelInitialConfig,
DEFAULT_CHANNEL_KIND,
} from 'container/CreateAlertChannels/defaults';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { toAPIError } from 'utils/errorUtils';
export interface ChannelDraftResult {
@@ -22,7 +23,10 @@ export interface ChannelDraftResult {
* Kept apart from the form so the form is built once its values are known,
* rather than mounted empty and reset.
*/
export function useChannelDraft(channelId?: string): ChannelDraftResult {
export function useChannelDraft(
channelId?: string,
initialKind?: ChannelKind,
): ChannelDraftResult {
const isEditing = !!channelId;
const { data, error: loadFailure } = useGetNotificationChannel(
@@ -34,10 +38,10 @@ export function useChannelDraft(channelId?: string): ChannelDraftResult {
channelId
? null
: {
kind: DEFAULT_CHANNEL_KIND,
kind: initialKind ?? DEFAULT_CHANNEL_KIND,
values: {
sendResolved: true,
...ChannelInitialConfig[DEFAULT_CHANNEL_KIND],
...ChannelInitialConfig[initialKind ?? DEFAULT_CHANNEL_KIND],
},
},
);

View File

@@ -31,6 +31,8 @@ import { toAPIError } from 'utils/errorUtils';
export interface ChannelFormStateResult {
form: UseFormReturn<ChannelFormValues>;
type: ChannelKind;
/** False until something is edited, which is what gates saving. */
isDirty: boolean;
onTypeChange: (value: string) => void;
onSave: () => void;
onTest: () => void;
@@ -84,10 +86,13 @@ export function useChannelFormState({
setTypeState(nextType);
// shouldDirty, because the type lives outside the form: without it
// picking a different type leaves the form pristine and unsavable.
Object.entries(ChannelInitialConfig[nextType]).forEach(([field, value]) => {
form.setValue(
field as keyof ChannelFormValues,
value as ChannelFormValues[keyof ChannelFormValues],
{ shouldDirty: true },
);
});
},
@@ -177,6 +182,7 @@ export function useChannelFormState({
return {
form,
type,
isDirty: form.formState.isDirty,
onTypeChange,
onSave: form.handleSubmit(submitSave),
onTest: form.handleSubmit(submitTest),

View File

@@ -0,0 +1,48 @@
/* The same shape as the page's no-matches state, so the two empty screens read
as the same product rather than two designs. */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--l2-foreground);
}
.title {
color: var(--l1-foreground);
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
.subtitle {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
max-width: 420px;
}
/* The button sizes to its label here, so "New channel" is never clipped. */
.actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-1);
}
.docsLink {
color: var(--l2-foreground);
font-size: 13px;
text-decoration: none;
&:hover {
color: var(--l1-foreground);
text-decoration: underline;
}
}

View File

@@ -0,0 +1,54 @@
import { Bell, Plus } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import styles from './ChannelsEmptyState.module.scss';
const CREATE_CHECKS = [NotificationChannelCreatePermission];
const DOCS_URL = 'https://signoz.io/docs/setup-alerts-notification/';
interface ChannelsEmptyStateProps {
onCreate: () => void;
}
function ChannelsEmptyState({
onCreate,
}: ChannelsEmptyStateProps): JSX.Element {
return (
<div className={styles.emptyState} data-testid="channels-empty-state">
<Bell className={styles.icon} size={16} />
<Typography.Text className={styles.title}>
No notification channels yet
</Typography.Text>
<Typography.Text className={styles.subtitle}>
A channel is where an alert ends up. Create one and your alert rules and
routing policies can point at it.
</Typography.Text>
<div className={styles.actions}>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={onCreate}
testId="channels-empty-create"
>
New channel
</AuthZButton>
</div>
<a
className={styles.docsLink}
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="channels-empty-docs"
>
Read the setup guide
</a>
</div>
);
}
export default ChannelsEmptyState;

View File

@@ -4,7 +4,7 @@ import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
export interface ChannelFormView {
openCreate: () => void;
openCreate: (kind?: string) => void;
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
}
@@ -22,7 +22,10 @@ export function useChannelFormView(): ChannelFormView {
};
return {
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
openCreate: (kind?: string): void =>
history.push(
kind ? `${ROUTES.CHANNELS_NEW}?kind=${kind}` : ROUTES.CHANNELS_NEW,
),
openEdit,
};
}

View File

@@ -81,6 +81,7 @@ export const CHANNEL_TYPES = [
'jira',
'jsmops',
'incidentio',
'telegram',
] as const;
export type ChannelType = (typeof CHANNEL_TYPES)[number];
@@ -790,6 +791,22 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
// one of each so the repeating row editors render with content
fields: [
{ title: 'Severity', value: '{{ .CommonLabels.severity }}', short: true },
{
title: 'Service',
value: '{{ .CommonLabels.service_name }}',
short: false,
},
],
actions: [
{
type: 'button',
text: 'Runbook',
url: 'https://runbook.example.com/{{ .CommonLabels.alertname }}',
},
],
sendResolved: true,
},
webhook: {
@@ -847,6 +864,13 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
telegram: {
botToken: '123456789:AA-story-telegram-token',
chatId: -1001234567890,
title: '{{ .CommonLabels.alertname }}',
message: '{{ range .Alerts -}}{{ .Annotations.description }}\n{{ end }}',
sendResolved: true,
},
};
/** The v2 fetch by id, which is what the redesigned screens read. */

View File

@@ -2,12 +2,6 @@
padding: var(--spacing-6);
}
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
}
.crumbName {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
}

View File

@@ -1,6 +1,7 @@
import { Spinner } from '@signozhq/ui/spinner';
import { toast } from '@signozhq/ui/sonner';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import BackHeader from 'components/BackHeader/BackHeader';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
@@ -13,42 +14,45 @@ interface ChannelsEditPageProps {
}
function ChannelsEditPage({ channelId }: ChannelsEditPageProps): JSX.Element {
const { data } = useGetNotificationChannel({ id: channelId });
const { data, isLoading } = useGetNotificationChannel({ id: channelId });
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{
title: (
<span className={styles.crumbName}>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
</span>
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={ROUTES.ALL_CHANNELS}
testId="channel-edit-header"
title={
isLoading ? (
<Spinner
size={16}
aria-label="Loading channel"
testId="channel-name-loading"
/>
) : (
<>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
</span>
),
isLast: true,
},
]}
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
</>
)
}
/>
<div className={styles.content}>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
);
}

View File

@@ -1,30 +1,46 @@
import { useLocation } from 'react-router-dom';
import { Skeleton } from 'antd';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import BackHeader from 'components/BackHeader/BackHeader';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
import { KIND_KEY } from 'pages/AlertList/NotificationChannels/constants';
import styles from './styles.module.scss';
function ChannelsNew(): JSX.Element {
const requested = new URLSearchParams(useLocation().search).get('kind');
// an unknown kind in the URL falls back to the form's own default
const initialKind = (Object.values(ChannelKind) as string[]).includes(
requested ?? '',
)
? (requested as ChannelKind)
: undefined;
// Leaving goes back to the list the way it was left: filtered by the kind
// that opened this form.
const listRoute = initialKind
? `${ROUTES.ALL_CHANNELS}&${KIND_KEY}=${initialKind}`
: ROUTES.ALL_CHANNELS;
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: 'New channel', isLast: true },
]}
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={listRoute}
title="New channel"
testId="channel-new-header"
/>
<div className={styles.content}>
<ChannelForm
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
<ChannelForm
initialKind={initialKind}
onDone={(): void => {
history.replace(listRoute);
}}
/>
</div>
);
}

View File

@@ -2,6 +2,6 @@
padding: var(--spacing-6);
}
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
}

View File

@@ -0,0 +1,44 @@
import ReactMarkdown, { uriTransformer } from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
// Model output is untrusted: never add `rehype-raw`, so raw HTML renders as text.
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS: Components = {
a: ({ node: _node, children, ...props }): JSX.Element => (
<a {...props} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
// Images become links so viewing a message never fetches a third-party URL.
img: ({ src, alt }): JSX.Element => (
<a href={src} target="_blank" rel="noopener noreferrer">
{alt || src}
</a>
),
};
interface AIMarkdownProps {
content: string;
className?: string;
}
function AIMarkdown({ content, className }: AIMarkdownProps): JSX.Element {
return (
<ReactMarkdown
className={className}
remarkPlugins={MD_PLUGINS}
// Images render as links, so their URIs need the same sanitising links get.
transformImageUri={uriTransformer}
components={MD_COMPONENTS}
>
{content}
</ReactMarkdown>
);
}
AIMarkdown.defaultProps = {
className: undefined,
};
export default AIMarkdown;

View File

@@ -0,0 +1,376 @@
// Design token Paragraph/Base/400.
@mixin paragraph-base {
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
}
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.roleHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
color: var(--l1-foreground);
}
// Typography sets size, weight and line height; the mixin adds the rest.
.roleLabel {
@include paragraph-base;
}
.parts {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.empty {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.textBox {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-5);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
min-width: 0;
}
.text {
@include paragraph-base;
color: var(--l2-foreground);
word-break: break-word;
:global(p) {
margin: 0 0 var(--spacing-4);
}
:global(p:last-child) {
margin-bottom: 0;
}
:global(:is(ol, ul)) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-10);
}
:global(li::marker) {
color: var(--l2-foreground);
}
:global(:is(h1, h2, h3, h4, h5, h6)) {
margin: var(--spacing-6) 0 var(--spacing-4);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-20);
color: var(--l1-foreground);
}
:global(:is(h1, h2)) {
font-size: var(--periscope-font-size-medium);
}
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
margin-top: 0;
}
:global(strong) {
font-weight: var(--font-weight-semibold);
color: var(--l1-foreground);
}
:global(a) {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:global(code:not(pre code)) {
padding: 0 var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l3-background);
font-family: var(--font-mono);
font-size: 0.92em;
color: var(--l1-foreground);
}
:global(pre) {
position: relative;
margin: 0 0 var(--spacing-4);
}
// The highlighter sets its box with inline styles; syntax colours stay its own.
:global(pre > div:not(.code-copy-btn)) {
margin: 0 !important;
padding: var(--spacing-4) var(--spacing-5) !important;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2) !important;
background: var(--l1-background) !important;
overflow-x: auto;
}
:global(pre code) {
font-family: var(--font-mono) !important;
}
:global(blockquote) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-5);
border-left: 2px solid var(--l3-border);
color: var(--l2-foreground);
}
:global(hr) {
margin: var(--spacing-6) 0;
border: none;
border-top: 1px solid var(--l2-border);
}
}
.showMore {
align-self: flex-start;
padding: 0;
border: none;
background: none;
font-size: var(--font-size-xs);
color: var(--primary);
cursor: pointer;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
align-self: flex-start;
max-width: 100%;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-2);
background: var(--l2-background);
color: var(--l2-foreground);
min-width: 0;
@include paragraph-base;
&.isPlain {
padding: 0;
background: none;
}
}
.dot {
flex-shrink: 0;
width: var(--spacing-3);
height: var(--spacing-3);
border-radius: var(--radius-round);
&[data-tone='success'] {
background: var(--accent-forest);
}
&[data-tone='warning'] {
background: var(--accent-amber);
}
&[data-tone='error'] {
background: var(--accent-cherry);
}
&[data-tone='info'] {
background: var(--accent-primary);
}
}
.chipLabel {
flex-shrink: 0;
}
.chipSeparator {
flex-shrink: 0;
color: var(--l2-foreground);
}
.chipValue {
display: inline-flex;
gap: var(--spacing-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--accent-primary);
}
.chipKey {
flex-shrink: 0;
}
.toolName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-amber);
}
.toolCall,
.toolResult {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.toolCall {
border-radius: var(--radius-2);
transition: outline-color 0.3s ease;
outline: 1px solid transparent;
outline-offset: var(--spacing-1);
&.isHighlighted {
outline-color: var(--primary);
}
}
.toolLink {
display: inline-flex;
align-self: flex-start;
max-width: 100%;
min-width: 0;
padding: 0;
border: none;
border-radius: var(--radius-2);
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
&:hover {
.chip {
background: var(--l3-background);
}
.toolLinkIcon {
color: var(--primary);
}
}
}
.toolLinkIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.collapsible {
min-width: 0;
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
align-items: center !important;
gap: var(--spacing-2);
padding: 0 !important;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
// Unboxed: the chevron sits right after the label.
:global(.ant-collapse-header-text) {
flex: none !important;
margin-inline-end: 0 !important;
color: var(--l2-foreground);
@include paragraph-base;
}
:global(.ant-collapse-expand-icon) {
height: auto !important;
padding-inline-start: 0 !important;
}
:global(.ant-collapse-content-box) {
display: flex;
flex-direction: column;
min-width: 0;
padding: var(--spacing-4) 0 0 !important;
}
&.boxed {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
:global(.ant-collapse-header) {
padding: var(--spacing-3) var(--spacing-5) !important;
}
:global(.ant-collapse-header-text) {
flex: auto !important;
}
:global(.ant-collapse-content-box) {
padding: 0 var(--spacing-5) var(--spacing-4) !important;
}
// The block is the box; nested text drops its own.
.textBox {
padding: 0;
border: none;
background: none;
}
}
}
.collapsibleChevron {
display: flex;
color: var(--l2-foreground);
}
.jsonBlock {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.json {
position: relative;
min-width: 0;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
cursor: auto;
:global(.pretty-view) {
padding: 0;
}
}
.jsonCollapsed {
overflow: hidden;
}
.jsonCopy {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-2);
z-index: 1;
}

View File

@@ -0,0 +1,98 @@
import { Badge } from '@signozhq/ui/badge';
import { MessageType, ThreadMessage } from './types';
import CollapsibleBlock from './CollapsibleBlock';
import MessagePart from './MessagePart';
import PartChip from './PartChip';
import RoleLabel from './RoleLabel';
import { getFinishReasonTone, getRoleLabel } from './utils';
import styles from './AIThreadMessage.module.scss';
export interface AIThreadMessageProps {
message: ThreadMessage;
messageType?: MessageType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function AIThreadMessage({
message,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: AIThreadMessageProps): JSX.Element {
const { role, parts: messageParts, finish_reason: finishReason } = message;
const roleLabel = getRoleLabel(role);
const tone = getFinishReasonTone(finishReason);
const isOutput = messageType === MessageType.Output;
const parts = (
<div className={styles.parts}>
{messageParts.length === 0 ? (
<span className={styles.empty}>No content</span>
) : (
messageParts.map((part, index) => (
<MessagePart
// eslint-disable-next-line react/no-array-index-key
key={index}
part={part}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
))
)}
</div>
);
return (
<div
className={styles.message}
data-testid={`ai-thread-message-${role || 'unknown'}`}
>
{role === 'system' || role === 'developer' ? (
<CollapsibleBlock
label={roleLabel && <RoleLabel label={roleLabel} />}
testId="ai-message-system-toggle"
>
{parts}
</CollapsibleBlock>
) : (
<>
{(roleLabel || isOutput) && (
<div className={styles.roleHeader}>
{roleLabel && <RoleLabel label={roleLabel} />}
{isOutput && (
<Badge
variant="outlined"
color="secondary"
textTransform="none"
testId="ai-message-output-badge"
>
Output
</Badge>
)}
</div>
)}
{parts}
</>
)}
{finishReason && (
<PartChip
label="Finish Reason"
value={finishReason}
tone={tone}
testId="ai-message-finish-reason"
/>
)}
</div>
);
}
AIThreadMessage.defaultProps = {
messageType: MessageType.Input,
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default AIThreadMessage;

View File

@@ -0,0 +1,54 @@
import { MouseEvent, ReactNode } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import cx from 'classnames';
import styles from './AIThreadMessage.module.scss';
const PANEL_KEY = 'content';
interface CollapsibleBlockProps {
label: ReactNode;
// Boxed: full-width bordered row with the chevron on the right.
isBoxed?: boolean;
testId?: string;
children: ReactNode;
}
function CollapsibleBlock({
label,
isBoxed = false,
testId,
children,
}: CollapsibleBlockProps): JSX.Element {
// Toggling must not count as a click on the message card; the body still does.
const stopToggleFromSelectingCard = (event: MouseEvent): void => {
if ((event.target as HTMLElement).closest('.ant-collapse-header')) {
event.stopPropagation();
}
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div onClick={stopToggleFromSelectingCard}>
<Collapse
ghost
className={cx(styles.collapsible, isBoxed && styles.boxed)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.collapsibleChevron} data-testid={testId}>
{isActive ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</span>
)}
items={[{ key: PANEL_KEY, label, children }]}
/>
</div>
);
}
CollapsibleBlock.defaultProps = {
isBoxed: false,
testId: undefined,
};
export default CollapsibleBlock;

View File

@@ -0,0 +1,39 @@
import { MouseEvent, useState } from 'react';
import AIMarkdown from './AIMarkdown';
import { MAX_TEXT_LENGTH } from './utils';
import styles from './AIThreadMessage.module.scss';
interface ExpandableTextProps {
text: string;
}
function ExpandableText({ text }: ExpandableTextProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
return (
<>
<AIMarkdown className={styles.text} content={visibleText} />
{text.length > MAX_TEXT_LENGTH && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-message-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</>
);
}
export default ExpandableText;

View File

@@ -0,0 +1,79 @@
import { MouseEvent, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { PrettyView } from 'periscope/components/PrettyView';
import { useResizeObserver } from 'hooks/useDimensions';
import styles from './AIThreadMessage.module.scss';
// Branch toggles (arrow, key, summary); PrettyView already stops its actions menu.
const TREE_TOGGLES = '.pretty-view__nested-row > :not(ul)';
const NO_ACTIONS = { leaf: [], nested: [] };
// 10 tree rows: 18px line-height plus 2px padding top and bottom.
const COLLAPSED_MAX_HEIGHT = 10 * 22;
interface JsonBlockProps {
data: object;
}
// Expanding tree nodes must not count as a click on the message.
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
const treeRef = useRef<HTMLDivElement>(null);
const { height: treeHeight } = useResizeObserver(treeRef);
const isLong = treeHeight > COLLAPSED_MAX_HEIGHT;
const isClamped = isLong && !isExpanded;
const stopTreeToggleFromSelectingCard = (event: MouseEvent): void => {
if ((event.target as HTMLElement).closest(TREE_TOGGLES)) {
event.stopPropagation();
}
};
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
return (
<div className={styles.jsonBlock}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={cx(styles.json, isClamped && styles.jsonCollapsed)}
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
onClick={stopTreeToggleFromSelectingCard}
data-testid="ai-json-block"
>
<span className={styles.jsonCopy}>
<CopyButton
value={text}
size={12}
ariaLabel="Copy JSON"
testId="ai-json-copy"
/>
</span>
<div ref={treeRef}>
<PrettyView
data={data as Record<string, unknown>}
searchable={false}
drawerKey="ai-thread-message"
visibleActions={NO_ACTIONS}
/>
</div>
</div>
{isLong && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-json-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
);
}
export default JsonBlock;

View File

@@ -0,0 +1,105 @@
import { MessagePart as MessagePartType } from './types';
import JsonBlock from './JsonBlock';
import ThinkingPart from './parts/ThinkingPart';
import ToolCallPart from './parts/ToolCallPart';
import ToolResultPart from './parts/ToolResultPart';
import TextContent from './TextContent';
import {
isEmptyPart,
toDisplayText,
toToolCallKey,
truncateBlobContent,
} from './utils';
import styles from './AIThreadMessage.module.scss';
const EMPTY_PART = (
<span className={styles.empty} data-testid="ai-message-empty-part">
No content
</span>
);
interface MessagePartProps {
part: MessagePartType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function MessagePart({
part,
canLinkToToolCall,
onToolLinkClick,
}: MessagePartProps): JSX.Element {
if (isEmptyPart(part)) {
return EMPTY_PART;
}
switch (part.type) {
case 'text':
return <TextContent text={part.content} />;
case 'reasoning':
return <ThinkingPart content={part.content} />;
case 'compaction':
return (
<ThinkingPart
label="Compaction"
content={part.content ?? undefined}
testId="ai-message-compaction-toggle"
/>
);
case 'tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.arguments}
type={part.type}
/>
);
case 'server_tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.server_tool_call}
type={part.type}
/>
);
case 'tool_call_response':
case 'server_tool_call_response': {
const toolCallId = part.id ?? '';
const toolCallKey = toToolCallKey(
toolCallId,
part.type === 'server_tool_call_response',
);
return (
<ToolResultPart
toolCallId={toolCallId}
toolCallKey={toolCallKey}
type={part.type}
content={toDisplayText(
part.type === 'tool_call_response'
? part.response
: part.server_tool_call_response,
)}
isLinkEnabled={!!toolCallId && !!canLinkToToolCall?.(toolCallKey)}
onLinkClick={onToolLinkClick}
/>
);
}
case 'blob':
case 'file':
case 'uri':
return <JsonBlock data={truncateBlobContent(part)} />;
case 'generic':
return <TextContent text={toDisplayText(part.content)} />;
default:
return <TextContent text={toDisplayText(part)} />;
}
}
MessagePart.defaultProps = {
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default MessagePart;

View File

@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
import cx from 'classnames';
import { MessageTone } from './utils';
import styles from './AIThreadMessage.module.scss';
interface PartChipProps {
label: string;
value?: ReactNode;
tone?: MessageTone;
// Plain: no chip background, for headers like a tool call's.
isPlain?: boolean;
testId?: string;
children?: ReactNode;
}
function PartChip({
label,
value,
tone = 'success',
isPlain = false,
testId,
children,
}: PartChipProps): JSX.Element {
return (
<span
className={cx(styles.chip, isPlain && styles.isPlain)}
data-testid={testId}
>
<span className={styles.dot} data-tone={tone} />
<span className={styles.chipLabel}>{label}</span>
{value !== undefined && (
<>
<span className={styles.chipSeparator}>—</span>
<span className={styles.chipValue}>{value}</span>
</>
)}
{children}
</span>
);
}
PartChip.defaultProps = {
value: undefined,
tone: 'success',
isPlain: false,
testId: undefined,
children: undefined,
};
export default PartChip;

View File

@@ -0,0 +1,17 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './AIThreadMessage.module.scss';
interface RoleLabelProps {
label: string;
}
function RoleLabel({ label }: RoleLabelProps): JSX.Element {
return (
<Typography.Text size="base" weight="normal" className={styles.roleLabel}>
{label}
</Typography.Text>
);
}
export default RoleLabel;

View File

@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import ExpandableText from './ExpandableText';
import JsonBlock from './JsonBlock';
import { parseJsonObject } from './utils';
import styles from './AIThreadMessage.module.scss';
interface TextContentProps {
text: string;
}
/** Renders JSON text as a tree, anything else as markdown. */
function TextContent({ text }: TextContentProps): JSX.Element {
const json = useMemo(() => parseJsonObject(text), [text]);
if (json) {
return <JsonBlock data={json} />;
}
return (
<div className={styles.textBox}>
<ExpandableText text={text} />
</div>
);
}
export default TextContent;

View File

@@ -0,0 +1,97 @@
import { render, screen, userEvent } from 'tests/test-utils';
import AIThreadMessage from '../AIThreadMessage';
import JsonBlock from '../JsonBlock';
import { MessageType } from '../types';
// jsdom has no layout, so the tree height JsonBlock measures is set per test.
let mockTreeHeight = 0;
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 0,
height: mockTreeHeight,
}),
}));
beforeEach(() => {
mockTreeHeight = 0;
});
describe('AIThreadMessage', () => {
it('shows the Output badge when the message has no role', () => {
render(
<AIThreadMessage
message={{ parts: [{ type: 'text', content: 'hi' }] }}
messageType={MessageType.Output}
/>,
);
expect(screen.getByTestId('ai-message-output-badge')).toBeInTheDocument();
});
it.each([
['blank text', { type: 'text' as const, content: ' ' }],
['empty generic', { type: 'generic' as const, content: '' }],
])('renders No content for a %s part', (_label, part) => {
render(<AIThreadMessage message={{ role: 'user', parts: [part] }} />);
expect(screen.getByTestId('ai-message-empty-part')).toHaveTextContent(
'No content',
);
});
it('hides system message parts until the toggle is clicked', async () => {
const user = userEvent.setup();
render(
<AIThreadMessage
message={{ role: 'system', parts: [{ type: 'text', content: 'be brief' }] }}
/>,
);
expect(screen.queryByText('be brief')).not.toBeInTheDocument();
await user.click(screen.getByTestId('ai-message-system-toggle'));
expect(screen.getByText('be brief')).toBeInTheDocument();
});
it('shows the finish reason', () => {
render(
<AIThreadMessage
message={{
role: 'assistant',
parts: [{ type: 'text', content: 'hi' }],
finish_reason: 'stop',
}}
/>,
);
expect(screen.getByTestId('ai-message-finish-reason')).toHaveTextContent(
'stop',
);
});
});
describe('JsonBlock', () => {
it('collapses JSON taller than 10 rows behind Show more', async () => {
mockTreeHeight = 300;
const user = userEvent.setup();
render(<JsonBlock data={{ a: 1 }} />);
const toggle = screen.getByTestId('ai-json-show-more');
expect(toggle).toHaveTextContent('Show more');
await user.click(toggle);
expect(toggle).toHaveTextContent('Show less');
});
it('does not collapse JSON of 10 rows or fewer', () => {
mockTreeHeight = 220;
render(<JsonBlock data={{ a: 1 }} />);
expect(screen.queryByTestId('ai-json-show-more')).not.toBeInTheDocument();
});
it('renders a copy button', () => {
render(<JsonBlock data={{ a: 1 }} />);
expect(screen.getByTestId('ai-json-copy')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,34 @@
import CollapsibleBlock from '../CollapsibleBlock';
import TextContent from '../TextContent';
import styles from '../AIThreadMessage.module.scss';
interface ThinkingPartProps {
content?: string;
label?: string;
testId?: string;
}
function ThinkingPart({
content,
label = 'Thinking',
testId = 'ai-message-thinking-toggle',
}: ThinkingPartProps): JSX.Element {
return (
<CollapsibleBlock label={label} isBoxed testId={testId}>
{content ? (
<TextContent text={content} />
) : (
<span className={styles.empty}> - </span>
)}
</CollapsibleBlock>
);
}
ThinkingPart.defaultProps = {
content: undefined,
label: 'Thinking',
testId: 'ai-message-thinking-toggle',
};
export default ThinkingPart;

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import JsonBlock from '../JsonBlock';
import PartChip from '../PartChip';
import { parseJsonObject, TOOL_CALL_ID_ATTR, toToolCallKey } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolCallPartProps {
id: string;
name: string;
args: unknown;
type: 'tool_call' | 'server_tool_call';
}
function ToolCallPart({
id,
name,
args,
type,
}: ToolCallPartProps): JSX.Element {
// Streamed calls can carry truncated, unparseable argument strings.
const json = useMemo(() => {
if (typeof args === 'string') {
return parseJsonObject(args);
}
return typeof args === 'object' && args !== null ? args : undefined;
}, [args]);
const toolCallAttr = {
[TOOL_CALL_ID_ATTR]: toToolCallKey(id, type === 'server_tool_call'),
};
return (
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
<PartChip
label={type}
tone="info"
isPlain
value={
<>
<span className={styles.chipKey}>name :</span>
<span className={styles.toolName}>{name}</span>
</>
}
/>
{json ? (
<JsonBlock data={json} />
) : (
<span className={styles.empty}> - </span>
)}
</div>
);
}
export default ToolCallPart;

View File

@@ -0,0 +1,80 @@
import { MouseEvent, useMemo } from 'react';
import { CornerUpRight } from '@signozhq/icons';
import PartChip from '../PartChip';
import TextContent from '../TextContent';
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolResultPartProps {
toolCallId: string;
toolCallKey: string;
type: 'tool_call_response' | 'server_tool_call_response';
name?: string;
content: string;
isError?: boolean;
isLinkEnabled: boolean;
onLinkClick?: (toolCallKey: string) => void;
}
function ToolResultPart({
toolCallId,
toolCallKey,
type,
name,
content,
isError,
isLinkEnabled,
onLinkClick,
}: ToolResultPartProps): JSX.Element {
// Short plain results fit in the chip; JSON or long ones render below it.
const isInline = useMemo(
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
[content],
);
const handleLinkClick = (event: MouseEvent): void => {
event.stopPropagation();
onLinkClick?.(toolCallKey);
};
const chip = (
<PartChip
label={type}
value={isInline ? content || '…' : name || toolCallId}
tone={isError ? 'error' : 'success'}
>
{isLinkEnabled && (
<CornerUpRight size={12} className={styles.toolLinkIcon} />
)}
</PartChip>
);
return (
<div className={styles.toolResult} data-testid="ai-tool-result">
{isLinkEnabled ? (
<button
type="button"
className={styles.toolLink}
onClick={handleLinkClick}
title="Go to tool call"
data-testid="ai-tool-result-link"
>
{chip}
</button>
) : (
chip
)}
{!isInline && <TextContent text={content} />}
</div>
);
}
ToolResultPart.defaultProps = {
name: undefined,
isError: false,
onLinkClick: undefined,
};
export default ToolResultPart;

View File

@@ -0,0 +1,58 @@
import type {
AiobservabilitytypesBlobPartDTO,
AiobservabilitytypesCompactionPartDTO,
AiobservabilitytypesFilePartDTO,
AiobservabilitytypesOutputMessageDTO,
AiobservabilitytypesReasoningPartDTO,
AiobservabilitytypesServerToolCallPartDTO,
AiobservabilitytypesServerToolCallResponsePartDTO,
AiobservabilitytypesTextPartDTO,
AiobservabilitytypesToolCallRequestPartDTO,
AiobservabilitytypesToolCallResponsePartDTO,
AiobservabilitytypesUriPartDTO,
} from 'api/generated/services/sigNoz.schemas';
// Generated enum `type`s as string literals, so `part.type === 'text'` narrows.
type Part<T extends { type: string }> = Omit<T, 'type'> & {
type: `${T['type']}`;
};
// Keys the schema marks required may still be missing.
type LoosePart<T extends { type: string }> = Partial<Omit<T, 'type'>> & {
type: `${T['type']}`;
};
/** OTel GenAI message part; `text` content is always normalised to a string. */
export type MessagePart =
| Part<AiobservabilitytypesTextPartDTO>
| LoosePart<AiobservabilitytypesReasoningPartDTO>
| LoosePart<AiobservabilitytypesCompactionPartDTO>
| LoosePart<AiobservabilitytypesToolCallRequestPartDTO>
| LoosePart<AiobservabilitytypesToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesServerToolCallPartDTO>
| LoosePart<AiobservabilitytypesServerToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesBlobPartDTO>
| LoosePart<AiobservabilitytypesFilePartDTO>
| LoosePart<AiobservabilitytypesUriPartDTO>
// The API's generic part is an untyped index signature, which defeats narrowing on `type`.
| { type: 'generic'; content?: unknown };
export type MessagePartType = MessagePart['type'];
export type MediaMessagePart = Extract<
MessagePart,
{ type: 'blob' | 'file' | 'uri' }
>;
// role: system | developer | user | assistant | tool; any other value renders headerless.
// finish_reason: stop | tool_call | length | content_filter | compaction | error, or provider-specific.
export type ThreadMessage = Partial<
Omit<AiobservabilitytypesOutputMessageDTO, 'parts'>
> & {
parts: MessagePart[];
};
export enum MessageType {
Input = 'input',
Output = 'output',
}

View File

@@ -0,0 +1,43 @@
import { RefObject, useCallback } from 'react';
import { TOOL_CALL_ID_ATTR } from './utils';
import styles from './AIThreadMessage.module.scss';
const HIGHLIGHT_MS = 1500;
interface ToolCallLinks {
canLinkToToolCall: (toolCallKey: string) => boolean;
onToolLinkClick: (toolCallKey: string) => void;
}
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
export function useToolCallLinks(
rootRef: RefObject<HTMLElement>,
toolCallKeys: Set<string>,
): ToolCallLinks {
const canLinkToToolCall = useCallback(
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
[toolCallKeys],
);
const onToolLinkClick = useCallback(
(toolCallKey: string): void => {
const target = rootRef.current?.querySelector<HTMLElement>(
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
);
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add(styles.isHighlighted);
window.setTimeout(
() => target.classList.remove(styles.isHighlighted),
HIGHLIGHT_MS,
);
},
[rootRef],
);
return { canLinkToToolCall, onToolLinkClick };
}

View File

@@ -0,0 +1,96 @@
import { attempt, isError, isObjectLike } from 'lodash-es';
import { MediaMessagePart, MessagePart, ThreadMessage } from './types';
export const MAX_TEXT_LENGTH = 1000;
const MAX_BLOB_PREVIEW_LENGTH = 100;
export const MAX_INLINE_RESULT_LENGTH = 80;
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
const ROLE_LABELS: Record<string, string> = {
system: 'System',
developer: 'Developer',
user: 'User',
assistant: 'Assistant',
tool: 'Tool',
};
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
// Provider values the backend could not map fall back to info.
const FINISH_REASON_TONES: Record<string, MessageTone> = {
stop: 'success',
tool_call: 'success',
length: 'warning',
content_filter: 'warning',
compaction: 'info',
error: 'error',
};
/** Undefined for missing or unknown roles, which render headerless. */
export function getRoleLabel(role?: string): string | undefined {
return role ? ROLE_LABELS[role] : undefined;
}
export function getFinishReasonTone(reason?: string | null): MessageTone {
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
}
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
export function parseJsonObject(text: string): object | undefined {
const parsed: unknown = attempt(JSON.parse, text);
return isObjectLike(parsed) && !isError(parsed)
? (parsed as object)
: undefined;
}
export function toDisplayText(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
/** Text and generic parts with only whitespace have nothing to render. */
export function isEmptyPart(part: MessagePart): boolean {
if (part.type === 'text') {
return part.content.trim() === '';
}
if (part.type === 'generic') {
return toDisplayText(part.content).trim() === '';
}
return false;
}
/** Blob content is inline base64; only a prefix is shown. */
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
if (
part.type !== 'blob' ||
!part.content ||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
) {
return part;
}
return {
...part,
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
};
}
// Server and client calls can share an id, so links match on kind too.
export function toToolCallKey(id: string, isServer: boolean): string {
return `${isServer ? 'server' : 'client'}:${id}`;
}
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
return messages.flatMap((message) =>
message.parts.flatMap((part) =>
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
: [],
),
);
}

View File

@@ -16,7 +16,8 @@ function EntityMetadataItem({
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<Tooltip title={tooltip}>
// Below the row: above it the tooltip covers the Overview / Thread tabs.
<Tooltip title={tooltip} side="bottom">
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>

View File

@@ -0,0 +1,46 @@
@use '../../../../styles/scrollbar' as *;
.accordion {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
// Direct children only, so nested collapses keep their own styles.
> :global(.ant-collapse-item) {
border: none;
}
> :global(.ant-collapse-item > .ant-collapse-header) {
align-items: center !important;
height: 40px;
padding: 0 var(--spacing-4) !important;
}
> :global(.ant-collapse-item > .ant-collapse-content) {
background: transparent;
border-top: 1px solid var(--l2-border);
}
> :global(
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.chevron {
display: flex;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,102 @@
import { MouseEvent, ReactNode, useState } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../../AIThreadMessage/types';
import MessagesSection from '../../SpanMessages/MessagesSection';
import { RawMessagesValue } from '../../utils/spanMessages';
import { ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
import styles from './MessagesAccordion.module.scss';
const PANEL_KEY = 'messages';
interface MessagesAccordionProps extends Pick<
AIThreadMessageProps,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
> {
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesAccordionProps): JSX.Element | null {
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState(ThreadView.Formatted);
const canFormat = messages.length > 0;
const effectiveView = canFormat ? view : ThreadView.Json;
if (!canFormat && !raw) {
return null;
}
const stopToggle = (event: MouseEvent): void => event.stopPropagation();
return (
<section data-testid={testId}>
<Collapse
className={styles.accordion}
activeKey={isOpen ? [PANEL_KEY] : []}
onChange={(): void => setIsOpen((prev) => !prev)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.chevron} data-testid={`${testId}-toggle`}>
{isActive ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
)}
items={[
{
key: PANEL_KEY,
label: (
<span className={styles.title}>
{icon}
{title}
</span>
),
extra: isOpen && canFormat && (
// The view toggle lives in the header; its clicks must not fold the panel.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={stopToggle}>
<ThreadViewToggle
value={effectiveView}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
</span>
),
children: (
<MessagesSection
view={effectiveView}
messages={messages}
raw={raw}
testId={`${testId}-body`}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
),
},
]}
/>
</section>
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -0,0 +1,7 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex-shrink: 0;
min-width: 0;
}

View File

@@ -0,0 +1,58 @@
import { useMemo, useRef } from 'react';
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
import styles from './SpanAIMessages.module.scss';
interface SpanAIMessagesProps {
span: ThreadSpan;
}
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
const data = useMemo(() => getSpanMessages(span), [span]);
const rootRef = useRef<HTMLDivElement>(null);
const toolCallKeys = useMemo(() => buildToolCallIndex(span), [span]);
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
rootRef,
toolCallKeys,
);
if (!hasAnyMessages(data)) {
return null;
}
return (
<div className={styles.root} ref={rootRef} data-testid="span-ai-messages">
<MessagesAccordion
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesAccordion
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
export default SpanAIMessages;

View File

@@ -34,6 +34,15 @@
min-width: 0;
}
// Inside the Overview tab there's no flex gap, so match .panelBody's 16px.
.tabAiMessages {
margin-bottom: 16px;
&:empty {
display: none;
}
}
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
// a true fixed footer flush with the panel's bottom edge — always visible
// regardless of how the body is scrolled.

View File

@@ -48,6 +48,7 @@ import Events from './Events/Events';
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -281,6 +282,7 @@ function SpanDetailsContent({
traceEndTime={traceEndTime}
/>
);
const aiMessages = <SpanAIMessages span={selectedSpan} />;
const eventsCount = selectedSpan.events?.length || 0;
const spanDetailTabs: TabsItemProps[] = [
@@ -291,6 +293,7 @@ function SpanDetailsContent({
children: (
<>
{isWide && summary}
{isWide && <div className={styles.tabAiMessages}>{aiMessages}</div>}
<DataViewer
data={spanDisplayData}
drawerKey="trace-details"
@@ -369,6 +372,7 @@ function SpanDetailsContent({
<>
<div className={styles.panelBody} ref={bodyRef}>
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
{!isWide && aiMessages}
<div className={styles.tabsSection}>
<Tabs

View File

@@ -0,0 +1,68 @@
import AIThreadMessage, {
AIThreadMessageProps,
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadView } from '../TraceDetailsThread/types';
import RawMessages from './RawMessages';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
type MessageProps = Pick<
AIThreadMessageProps,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
>;
interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesSection({
title,
view,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesSectionProps): JSX.Element | null {
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? messages.length === 0 : !raw) {
return null;
}
return (
<section className={styles.section} data-testid={testId}>
{title && <div className={styles.sectionTitle}>{title}</div>}
{isFormatted ? (
<div className={styles.messages}>
{messages.map((message, index) => (
<AIThreadMessage
// Messages have no stable id and never reorder.
// eslint-disable-next-line react/no-array-index-key
key={index}
message={message}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
))}
</div>
) : (
<RawMessages value={raw as RawMessagesValue} />
)}
</section>
);
}
MessagesSection.defaultProps = {
title: undefined,
raw: undefined,
};
export default MessagesSection;

View File

@@ -0,0 +1,21 @@
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<span className={styles.rawUnavailable} data-testid="span-raw-messages">
Unable to render messages
</span>
);
}
export default RawMessages;

View File

@@ -0,0 +1,43 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.sectionTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
}
.messages {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
min-width: 0;
}
.rawUnavailable {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.missing {
padding: var(--spacing-10) var(--spacing-6);
border-radius: var(--radius-2);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
text-align: center;
}

View File

@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { MessageType } from '../AIThreadMessage/types';
import MessagesSection from './MessagesSection';
import {
getSpanMessages,
hasFormattedMessages,
hasRawMessages,
} from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface SpanMessagesProps extends Pick<
AIThreadMessageProps,
'canLinkToToolCall' | 'onToolLinkClick'
> {
span: ThreadSpan;
view: ThreadView;
className?: string;
}
/** Input and output messages of one AI span, shared by Thread and Span Details. */
function SpanMessages({
span,
view,
className,
canLinkToToolCall,
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
return (
<div
className={cx(styles.missing, className)}
data-testid="span-messages-empty"
>
This span has no input or output.
</div>
);
}
if (!isFormatted) {
return (
<div className={cx(styles.root, className)} data-testid="span-messages-json">
<JsonBlock
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
/>
</div>
);
}
return (
<div className={cx(styles.root, className)}>
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
SpanMessages.defaultProps = {
className: undefined,
};
export default SpanMessages;

View File

@@ -0,0 +1,94 @@
import { render, screen, within } from 'tests/test-utils';
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
import SpanMessages from '../SpanMessages';
// jsdom has no layout; report a JSON tree taller than the 10-row clamp.
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 0,
height: 300,
}),
}));
const makeSpan = (overrides: Partial<ThreadSpan>): ThreadSpan =>
({ span_id: 's1', attributes: {}, ...overrides }) as unknown as ThreadSpan;
describe('SpanMessages', () => {
it('shows the empty state when there are no formatted messages', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Formatted} />);
expect(screen.getByTestId('span-messages-empty')).toHaveTextContent(
'This span has no input or output.',
);
});
it('renders formatted input and output messages', () => {
const span = makeSpan({
formattedInput: [
{ role: 'user', parts: [{ type: 'text', content: 'question' }] },
],
formattedOutput: [
{ role: 'assistant', parts: [{ type: 'text', content: 'answer' }] },
],
});
render(<SpanMessages span={span} view={ThreadView.Formatted} />);
expect(
within(screen.getByTestId('span-messages-input')).getByText('question'),
).toBeInTheDocument();
expect(
within(screen.getByTestId('span-messages-output')).getByText('answer'),
).toBeInTheDocument();
});
it('shows the empty state in JSON view when there are no raw messages', () => {
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
});
it('renders raw messages in JSON view', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
});
it('opens every key in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
});
it('collapses tall JSON behind Show more in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
{ role: 'user', content: 'hi' },
]),
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('ai-json-show-more')).toHaveTextContent(
'Show more',
);
});
});

View File

@@ -29,6 +29,10 @@
}
}
.tabs {
flex-shrink: 0;
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -13,16 +13,20 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -60,6 +64,17 @@ function TraceDetailsHeader({
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID);
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
// The filter query lives in Filters' local state, which unmounts with the
// Overview tab; drop its result too so both start empty on return.
useEffect(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
onFilteredSpansChange([], false);
}, [tab, onFilteredSpansChange]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -93,13 +108,14 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
useTraceDetailsStripInfo({
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
});
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
: 0;
return (
<div className={styles.wrapper}>
<div className={styles.header}>
@@ -120,9 +136,10 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{showTraceDetailsHeaderOptions && traceSummary && (
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -138,6 +155,7 @@ function TraceDetailsHeader({
icon
color="secondary"
aria-label="Analytics"
testId="trace-analytics-button"
onClick={handleToggleAnalytics}
>
<ChartPie size={14} />
@@ -172,16 +190,20 @@ function TraceDetailsHeader({
)}
</div>
{showTraceDetails && (
{(showTraceDetails || !isOverview) && (
<div className={styles.subHeader}>
{traceSummary ? (
{!traceSummary ? (
<DetailsLoader />
) : (
<EntityMetadataRow
entity="trace"
service={{
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={durationMs}
durationMs={
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
@@ -189,13 +211,11 @@ function TraceDetailsHeader({
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

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