Compare commits

...

9 Commits

Author SHA1 Message Date
nityanandagohain
fb33e31c4c Merge remote-tracking branch 'origin/main' into issue_nerve_462 2026-10-26 17:54:41 +05:30
nityanandagohain
0a0cd96e93 feat(spanmapper): add fga to span mapper group and mapper routes 2026-10-26 17:51:42 +05:30
Swapnil Nakade
aaca29d26d feat: adding gcp cloud integration agent mode changes (#13103)
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
<!--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

- Adding gcp cloud integration agent mode support
  - Type changes
  - Module changes
  - Generated openapi specs
- Added a new key ConnectionMethod for GCP provider to differentiate
between Agent and Manual flow
- Added integration tests for agent mode
- Frontend changes will be addressed in different PR

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

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-26 09:39:25 +00:00
ashu8912
2139580419 fix(settings): remove notification channels from settings nav (#13225)
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

- Removed the Notification Channels entry from the settings side nav;
the channel screens are still reachable at their own route.
- Renamed the `shift+g+n` shortcut from
`NavigateToSettingsNotificationChannels` to
`NavigateToNotificationChannels`, since it no longer points into
Settings. The key binding and target route are unchanged.
- Updated the Settings nav test so it checks that the entry is gone.

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

#### Additional Information

Resolves the TODO linked to SigNoz/engineering-pod#5323.
2026-10-26 01:43:18 +00:00
Vinicius Lourenço
9ba2a25eb9 refactor(workspaced-locked): avoid using images from signoz.io (#12880)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This prevents issues similar to
https://github.com/SigNoz/engineering-pod/issues/6083

I didn't find any other image being imported from signoz.io

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

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

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

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

Before (when the images were removed from signoz.io):

<img width="809" height="872" alt="image"
src="https://github.com/user-attachments/assets/4a30aad9-cc46-457c-af88-88758db79c5f"
/>

After:

<img width="970" height="980" alt="image"
src="https://github.com/user-attachments/assets/328b17a9-bcbf-44d3-90df-f5f71beb53a7"
/>
2026-10-23 15:35:31 +00:00
Swapnil Nakade
30839d390f chore: bump cloud integration agent version (#13245)
#### Description

- bump cloud integration agent version to v0.0.16


#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/161
2026-10-23 12:10:52 +00:00
Aditya Singh
2483c41452 chore(codeowners): add owner for saved views and the bottom strip (#13243)
#### Description
- Adds @aks07 as code owner for the saved views and bottom strip code:
  - `frontend/src/container/SavedViews/`
  - `frontend/src/container/Home/SavedViews/`
  - `frontend/src/hooks/saveViews/`
  - `frontend/src/container/BottomStrip/`
2026-10-23 11:47:34 +00:00
Vikrant Gupta
216c1ef5de chore(claude): require page stories with frontend UI changes (#13231)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Add `.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
112 changed files with 5245 additions and 1623 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,76 +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.
**`--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.
**`--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.
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.
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.
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).
- **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.
- 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

8
.github/CODEOWNERS vendored
View File

@@ -290,6 +290,14 @@ go.mod @therealpandey
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
## Saved Views
/frontend/src/container/SavedViews/ @aks07
/frontend/src/container/Home/SavedViews/ @aks07
/frontend/src/hooks/saveViews/ @aks07
## Bottom Strip
/frontend/src/container/BottomStrip/ @aks07
## Storybook
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad

View File

@@ -2239,6 +2239,8 @@ components:
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
@@ -2248,13 +2250,79 @@ components:
type: string
type: array
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
properties:
cliCommand:
type: string
required:
- cliCommand
type: object
CloudintegrationtypesGCPConnectionMethod:
enum:
- manual
- agent
type: string
CloudintegrationtypesGCPIntegrationConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesGCPTelemetryCollectionStrategy'
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
- telemetryCollectionStrategy
type: object
CloudintegrationtypesGCPLogsCollectionStrategy:
nullable: true
properties:
sinkFilter:
type: string
required:
- sinkFilter
type: object
CloudintegrationtypesGCPMetricsCollectionStrategy:
nullable: true
properties:
metricNames:
items:
type: string
type: array
required:
- metricNames
type: object
CloudintegrationtypesGCPPostableAccountConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPServiceConfig:
properties:
@@ -2277,6 +2345,16 @@ components:
required:
- enabled
type: object
CloudintegrationtypesGCPTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPMetricsCollectionStrategy'
required:
- metrics
- logs
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -2413,7 +2491,7 @@ components:
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
$ref: '#/components/schemas/CloudintegrationtypesGCPPostableAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -2648,7 +2726,6 @@ components:
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId
@@ -3582,6 +3659,8 @@ components:
- logs-field
- traces-field
- metrics-field
- span-mapper-group
- span-mapper
type: string
CoretypesObject:
properties:
@@ -15741,9 +15820,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- span-mapper-group:list
- tokenizer:
- VIEWER
- span-mapper-group:list
summary: List span attribute mapping groups
tags:
- spanmapper
@@ -15804,9 +15883,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- span-mapper-group:create
- tokenizer:
- ADMIN
- span-mapper-group:create
summary: Create a span attribute mapping group
tags:
- spanmapper
@@ -15851,9 +15930,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- span-mapper-group:delete
- tokenizer:
- ADMIN
- span-mapper-group:delete
summary: Delete a span attribute mapping group
tags:
- spanmapper
@@ -15909,9 +15988,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- span-mapper-group:update
- tokenizer:
- ADMIN
- span-mapper-group:update
summary: Update a span attribute mapping group
tags:
- spanmapper
@@ -15974,9 +16053,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- span-mapper-group:read
- tokenizer:
- VIEWER
- span-mapper-group:read
summary: List span mappers for a group
tags:
- spanmapper
@@ -16049,9 +16128,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- span-mapper:create
- span-mapper-group:attach
- tokenizer:
- ADMIN
- span-mapper:create
- span-mapper-group:attach
summary: Create a span mapper
tags:
- spanmapper
@@ -16101,9 +16182,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- span-mapper:delete
- span-mapper-group:detach
- tokenizer:
- ADMIN
- span-mapper:delete
- span-mapper-group:detach
summary: Delete a span mapper
tags:
- spanmapper
@@ -16164,9 +16247,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- span-mapper:update
- tokenizer:
- ADMIN
- span-mapper:update
summary: Update a span mapper
tags:
- spanmapper
@@ -16174,7 +16257,8 @@ paths:
/api/v1/span_mapper_groups/test:
post:
deprecated: false
description: Tests how span mappers would transform sample spans
description: Tests how span mappers would transform sample spans. Groups sent
without mappers are read from the store by name.
operationId: TestSpanMappers
requestBody:
content:
@@ -16228,9 +16312,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- span-mapper-group:read
- tokenizer:
- VIEWER
- span-mapper-group:read
summary: Test span mappers against sample spans
tags:
- spanmapper

View File

@@ -7,24 +7,66 @@ import (
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
func (g *gcpcloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
var metricNames []string
var sinkFilters []string
for _, storedSvc := range services {
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
if err != nil {
return nil, err
}
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil {
return nil, err
}
if svcDef.TelemetryCollectionStrategy.GCP == nil {
continue
}
strategy := svcDef.TelemetryCollectionStrategy.GCP
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
}
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
}
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
if err != nil {
return nil, err
}
return &cloudintegrationtypes.ConnectionArtifact{
GCP: connectionArtifact,
}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
@@ -34,3 +76,7 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}

View File

@@ -2,7 +2,6 @@ package implcloudintegration
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -171,18 +170,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
@@ -195,41 +182,36 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
syncState := account.NextSyncState(req.SyncedVersion)
// If account has been removed (disconnected), return a minimal response with empty integration config.
if account.RemovedAt != nil {
// Heartbeat stays frozen after removal, only the sync state is updated.
if account.AgentReport != nil && syncState != nil {
account.UpdateSyncState(syncState)
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
}
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
), nil
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
return nil, errors.Newf(
errors.TypeAlreadyExists,
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
)
}
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
@@ -498,11 +480,45 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
}
// NOTE: not adding stats for services for now.
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
if err == nil {
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
}
return stats, nil
}
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
response := cloudintegrationtypes.NewAgentCheckInResponse(
providerAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
)
if account.AgentReport == nil {
return response, nil
}
if syncState == nil {
return response, nil
}
account.UpdateSyncState(syncState)
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
return response, nil
}
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
return cloudProviderModule, nil

View File

@@ -3292,6 +3292,8 @@ export enum CoretypesKindDTO {
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
'span-mapper-group' = 'span-mapper-group',
'span-mapper' = 'span-mapper',
}
export enum CoretypesTypeDTO {
user = 'user',
@@ -3792,7 +3794,12 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export enum CloudintegrationtypesGCPConnectionMethodDTO {
manual = 'manual',
agent = 'agent',
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
@@ -4039,7 +4046,10 @@ export interface CloudintegrationtypesCollectedMetricDTO {
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
/**
* @type string
*/
cliCommand: string;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
@@ -4078,8 +4088,62 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesGCPLogsCollectionStrategyDTO = {
/**
* @type string
*/
sinkFilter: string;
} | null;
/**
* @nullable
*/
export type CloudintegrationtypesGCPMetricsCollectionStrategyDTO = {
/**
* @type array
*/
metricNames: string[];
} | null;
export interface CloudintegrationtypesGCPTelemetryCollectionStrategyDTO {
logs: CloudintegrationtypesGCPLogsCollectionStrategyDTO | null;
metrics: CloudintegrationtypesGCPMetricsCollectionStrategyDTO | null;
}
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
[key: string]: unknown;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
telemetryCollectionStrategy: CloudintegrationtypesGCPTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesGCPPostableAccountConfigDTO {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
@@ -4234,7 +4298,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -4377,9 +4441,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
*/
deploymentRegion: string;
/**
* @type array,null
* @type array
*/
projectIds: string[] | null;
projectIds: string[];
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {

View File

@@ -803,7 +803,7 @@ export const useUpdateSpanMapper = <
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* Tests how span mappers would transform sample spans. Groups sent without mappers are read from the store by name.
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -2,6 +2,7 @@ export const GlobalShortcuts = {
NavigateToServices: 'shift+s',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -23,7 +24,6 @@ export const GlobalShortcuts = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -34,6 +34,7 @@ export const GlobalShortcutsName = {
NavigateToTraces: 'shift+t',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -44,7 +45,6 @@ export const GlobalShortcutsName = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -59,6 +59,7 @@ export const GlobalShortcutsDescription = {
NavigateToLogs: 'Navigate to Logs Explorer',
NavigateToDashboards: 'Navigate to Dashboards List',
NavigateToAlerts: 'Navigate to Alerts List',
NavigateToNotificationChannels: 'Navigate to Notification Channels',
NavigateToExceptions: 'Navigate to Exceptions List',
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
ToggleSidebar: 'Toggle sidebar visibility',
@@ -68,8 +69,6 @@ export const GlobalShortcutsDescription = {
NavigateToSettings: 'Navigate to Settings',
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
NavigateToSettingsBilling: 'Navigate to Billing Settings',
NavigateToSettingsNotificationChannels:
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',

View File

@@ -0,0 +1,5 @@
.waitingMessage {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,147 @@
import { LoaderCircle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
import { useForm } from 'react-hook-form';
import ConnectionMethodSelector from '../ConnectionMethodSelector';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
import DeploymentRegionField from '../fields/DeploymentRegionField';
import SetupDrawerFooter from '../SetupDrawerFooter';
import SetupGuideCallout from '../SetupGuideCallout';
import { GcpSetupFormValues } from '../types';
import { usePrefillCredentials } from '../usePrefillCredentials';
import AgentSetupCommand from './AgentSetupCommand';
import styles from './AgentSetup.module.scss';
interface AgentSetupProps {
footerSlot: HTMLDivElement | null;
onConnectionMethodChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
onCommandGenerated: () => void;
/** Asks to leave the setup; the parent may confirm before closing. */
onCancel: () => void;
onClose: () => void;
}
function AgentSetup({
footerSlot,
onConnectionMethodChange,
onCommandGenerated,
onCancel,
onClose,
}: AgentSetupProps): JSX.Element {
const {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
} = useAgentAccountSetup({ onClose, onCommandGenerated });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_SETUP_FORM_VALUES,
});
usePrefillCredentials(connectionParams, setValue);
return (
<>
<ConnectionMethodSelector
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
onChange={onConnectionMethodChange}
disabled={isLoading || Boolean(connectionCommand)}
/>
<SetupGuideCallout {...AGENT_SETUP_GUIDE} />
{connectionCommand ? (
<>
<AgentSetupCommand
deploymentProjectId={commandProjectId}
command={connectionCommand}
/>
{modalState === ModalStateEnum.WAITING && (
<Callout
type="info"
showIcon={false}
title={
<div className={styles.waitingMessage}>
<LoaderCircle size={14} className="animate-spin" />
Waiting for GCP account connection, retrying in 10 secs...
</div>
}
testId="gcp-agent-waiting"
/>
)}
{modalState === ModalStateEnum.ERROR && (
<Callout
type="error"
showIcon
title="We couldn't establish a connection to your GCP account. Please try again"
testId="gcp-agent-connection-error"
/>
)}
</>
) : (
<>
<DeploymentProjectIdField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</>
)}
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to generate the GCP setup command"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onCancel}
>
{!connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(generateCommand)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-generate-command-btn"
>
Generate Command
</Button>
)}
{modalState === ModalStateEnum.ERROR && (
<Button
size="md"
variant="solid"
color="primary"
onClick={checkConnectionAgain}
testId="gcp-check-connection-btn"
>
Check Again
</Button>
)}
</SetupDrawerFooter>
</>
);
}
export default AgentSetup;

View File

@@ -0,0 +1,31 @@
.commands {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.commandsHead {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.mono {
composes: mono from '../shared.module.scss';
}
// CodeBlock renders the copy button's wrapper first and the highlighted code last;
// the code theme sets the block's margin and padding inline, hence !important.
.command {
> div:first-child {
top: var(--spacing-6);
right: var(--spacing-6);
}
> div:last-child {
// The theme's top margin pushes the bordered block under the copy button.
margin: 0 !important;
// Keeps long lines from running underneath the copy button.
padding-right: var(--spacing-24) !important;
}
}

View File

@@ -0,0 +1,32 @@
import { Typography } from '@signozhq/ui/typography';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import styles from './AgentSetupCommand.module.scss';
interface AgentSetupCommandProps {
deploymentProjectId: string;
command: string;
}
function AgentSetupCommand({
deploymentProjectId,
command,
}: AgentSetupCommandProps): JSX.Element {
return (
<div className={styles.commands} data-testid="gcp-agent-command">
<div className={styles.commandsHead}>
<Typography.Text weight="bold" size="base">
Deploy the SigNoz agent
</Typography.Text>
<Typography.Text as="p" size="small" color="muted">
Run this command in Google Cloud Shell, or in a terminal where gcloud is
signed in to <span className={styles.mono}>{deploymentProjectId}</span>. It
contains your SigNoz keys, so do not share it.
</Typography.Text>
</div>
<CodeBlock language="bash" code={command} className={styles.command} />
</div>
);
}
export default AgentSetupCommand;

View File

@@ -0,0 +1,183 @@
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
DEPLOYMENT_PROJECT_ID,
fillAgentForm,
onClose,
onCommandGenerated,
renderAgentSetup,
mockAgentSetupApi,
AgentSetupApiMock,
} from './agentSetupTestHelpers';
import {
connectionCredentials,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
gcpConnectionCommand,
} from '../../../__tests__/mockData';
// `useAgentAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('GCP AgentSetup form', () => {
let api: AgentSetupApiMock;
beforeEach(() => {
jest.clearAllMocks();
api = mockAgentSetupApi();
});
it('asks only for the deployment project, region and credentials', async () => {
renderAgentSetup();
await waitFor(() => {
expect(screen.getByTestId('gcp-signoz-api-url-input')).toHaveTextContent(
connectionCredentials.sigNozApiUrl,
);
});
expect(
screen.getByTestId('gcp-deployment-project-id-input'),
).toBeInTheDocument();
expect(
screen.getByTestId('gcp-deployment-region-select'),
).toBeInTheDocument();
expect(
screen.queryByTestId('gcp-account-name-input'),
).not.toBeInTheDocument();
expect(document.querySelector('#gcp-project-ids-select')).toBeNull();
expect(screen.getByTestId('gcp-setup-guide-callout')).toHaveTextContent(
'SigNoz integration sets up everything needed',
);
});
it('blocks submission and surfaces validation errors when the form is empty', async () => {
const user = userEvent.setup();
renderAgentSetup();
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(
screen.getByText('Please enter the deployment project ID'),
).toBeInTheDocument();
});
expect(screen.getByText('Please select a region')).toBeInTheDocument();
expect(api.createAccountPayload).toBeNull();
});
it('creates an agent account and shows its setup command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
});
expect(api.createAccountPayload).toStrictEqual({
config: {
gcp: {
connectionMethod: 'agent',
deploymentRegion: 'asia-south1',
deploymentProjectId: DEPLOYMENT_PROJECT_ID,
projectIds: [DEPLOYMENT_PROJECT_ID],
},
},
credentials: connectionCredentials,
});
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
gcpConnectionCommand.cliCommand,
);
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
DEPLOYMENT_PROJECT_ID,
);
expect(
screen.queryByTestId('gcp-generate-command-btn'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-deployment-project-id-input'),
).not.toBeInTheDocument();
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
expect(onCommandGenerated).toHaveBeenCalledTimes(1);
expect(api.checkInCalled).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
it('shows the backend error inline and keeps the form when creation fails', async () => {
server.use(
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
status: 'error',
error: {
message:
'projectIds must contain only deploymentProjectId for GCP agent setup',
},
}),
),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
'projectIds must contain only deploymentProjectId for GCP agent setup',
);
});
expect(screen.queryByTestId('gcp-agent-command')).not.toBeInTheDocument();
expect(screen.getByTestId('gcp-generate-command-btn')).toBeInTheDocument();
});
it('locks the connection method and keeps the submitted project while the command is generating', async () => {
server.use(
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.delay(300), ctx.status(201), ctx.json(createAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'-edited',
);
const commands = await screen.findByTestId('gcp-agent-command');
expect(commands).toHaveTextContent(`signed in to ${DEPLOYMENT_PROJECT_ID}.`);
expect(commands).not.toHaveTextContent(`${DEPLOYMENT_PROJECT_ID}-edited`);
});
});

View File

@@ -0,0 +1,118 @@
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
fillAgentForm,
onClose,
renderAgentSetup,
mockAgentSetupApi,
} from './agentSetupTestHelpers';
import {
connectedAgentAccountResponse,
GCP_AGENT_ACCOUNT_URL,
pendingAgentAccountResponse,
} from '../../../__tests__/mockData';
// `useAgentAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('GCP AgentSetup connection polling', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAgentSetupApi();
});
it('waits for the agent after showing the command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-waiting');
expect(
screen.queryByTestId('gcp-check-connection-btn'),
).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the drawer once the agent checks in', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('reports a timeout and lets the user check again', async () => {
const realNow = Date.now.bind(Date);
let clockOffset = 0;
const nowSpy = jest
.spyOn(Date, 'now')
.mockImplementation(() => realNow() + clockOffset);
let pollCount = 0;
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) => {
pollCount += 1;
if (pollCount === 1) {
// Polling started before this response, so jumping the clock
// past the 10 minute window makes this first poll time out.
clockOffset += 11 * 60 * 1000;
return res(ctx.status(200), ctx.json(pendingAgentAccountResponse));
}
return res(ctx.status(200), ctx.json(connectedAgentAccountResponse));
}),
);
try {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-connection-error');
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('gcp-check-connection-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
} finally {
nowSpy.mockRestore();
}
});
it('shows a retryable error when the connection status cannot be fetched', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-connection-error');
expect(screen.getByTestId('gcp-check-connection-btn')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,102 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import AgentSetup from '../AgentSetup';
import SetupDrawerLayout from '../../SetupDrawerLayout';
import {
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from '../../../__tests__/mockData';
export const DEPLOYMENT_PROJECT_ID = 'my-deployment-project-123';
type AgentSetupProps = ComponentProps<typeof AgentSetup>;
export const onClose = jest.fn() as jest.MockedFunction<
AgentSetupProps['onClose']
>;
export const onConnectionMethodChange = jest.fn() as jest.MockedFunction<
AgentSetupProps['onConnectionMethodChange']
>;
export const onCommandGenerated = jest.fn() as jest.MockedFunction<
AgentSetupProps['onCommandGenerated']
>;
export const onCancel = jest.fn() as jest.MockedFunction<
AgentSetupProps['onCancel']
>;
export const renderAgentSetup = (): void => {
render(
<SetupDrawerLayout onClose={onClose}>
{(footerSlot): JSX.Element => (
<AgentSetup
footerSlot={footerSlot}
onConnectionMethodChange={onConnectionMethodChange}
onCommandGenerated={onCommandGenerated}
onCancel={onCancel}
onClose={onClose}
/>
)}
</SetupDrawerLayout>,
);
};
export const fillAgentForm = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
DEPLOYMENT_PROJECT_ID,
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
};
export interface AgentSetupApiMock {
readonly createAccountPayload: Record<string, unknown> | null;
readonly checkInCalled: boolean;
}
/** Registers the agent setup endpoints; the polled account stays pending. */
export const mockAgentSetupApi = (): AgentSetupApiMock => {
let createAccountPayload: Record<string, unknown> | null = null;
let checkInCalled = false;
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
// check_in is registered first, it is a more specific path than
// /accounts and msw matches handlers in registration order.
rest.post(GCP_CHECK_IN_URL, (_req, res, ctx) => {
checkInCalled = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.post(GCP_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
createAccountPayload = await req.json();
return res(ctx.status(201), ctx.json(createAgentAccountResponse));
}),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
return {
get createAccountPayload(): Record<string, unknown> | null {
return createAccountPayload;
},
get checkInCalled(): boolean {
return checkInCalled;
},
};
};

View File

@@ -1,142 +0,0 @@
.setupDrawer {
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
// Input and ComboboxSimple default to --border borders, inherited text and
// --muted-foreground placeholders; the drawer wants the dimmer --l2-border with
// brighter values and duller placeholders. Backgrounds are left alone — both
// components default to transparent and every field sits on an --l2-background
// surface already. Focus borders stay at their per-component defaults.
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--l2-border);
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
--combobox-trigger-border-color: var(--l2-border);
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
padding: var(--spacing-10) var(--spacing-12);
overflow-y: auto;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
}
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.mono {
composes: mono from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.projectIdsSelect {
:global(.ant-select-selector) {
min-height: 36px;
background: var(--l2-background);
border: 1px solid var(--l2-border) !important;
}
&:hover :global(.ant-select-selector),
&:global(.ant-select-focused) :global(.ant-select-selector) {
border-color: var(--l2-border);
box-shadow: none;
}
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
:global(.ant-select-selection-placeholder),
:global(.ant-select-selection-search-input),
:global(.ant-select-selection-item) {
font-size: var(--periscope-font-size-base);
}
:global(.ant-select-selection-placeholder) {
color: var(--l3-foreground);
}
:global(.ant-select-selection-search-input) {
color: var(--l1-foreground);
}
:global(.ant-select-selection-item) {
color: var(--l1-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
:global(.ant-select-selection-item-remove) {
color: var(--l3-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}

View File

@@ -1,288 +1,88 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
import { Controller, useForm } from 'react-hook-form';
import { popupContainer } from 'utils/selectPopupContainer';
import ConnectionSecretsFields from './ConnectionSecretsFields';
import FieldLabel from './FieldLabel';
import FlowSelector from './FlowSelector';
import SetupGuideCallout from './SetupGuideCallout';
import { GcpSetupFormValues, SetupFlow } from './types';
import styles from './CloudAccountSetupDrawer.module.scss';
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
const DEFAULT_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
import AgentSetup from './AgentSetup/AgentSetup';
import ManualSetup from './ManualSetup/ManualSetup';
import SetupDrawerLayout from './SetupDrawerLayout';
function CloudAccountSetupDrawer({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
isLoading,
connectAccount,
handleClose,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useCloudAccountSetupDrawer({ onClose });
const [connectionMethod, setConnectionMethod] =
useState<CloudintegrationtypesGCPConnectionMethodDTO>(
CloudintegrationtypesGCPConnectionMethodDTO.manual,
);
// Set once the agent command exists; closing after that loses the pending setup.
const [hasGeneratedCommand, setHasGeneratedCommand] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_VALUES,
});
const [flow, setFlow] = useState<SetupFlow>('manual');
// Pre-fill the deployment/ingestion fields with the fetched credentials.
useEffect(() => {
if (!connectionParams) {
const requestClose = (): void => {
if (hasGeneratedCommand) {
setIsCloseConfirmOpen(true);
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
const footer = (
<div className={styles.footerContainer}>
{submitError && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={clearSubmitError}
title="Failed to connect GCP account"
testId="gcp-connect-error"
>
{submitError}
</Callout>
)}
<div className={styles.footer}>
<Button
size="md"
variant="outlined"
color="secondary"
onClick={handleClose}
testId="gcp-cancel-btn"
>
Cancel
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</div>
</div>
);
onClose();
};
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
handleClose();
<>
<SetupDrawerLayout onClose={requestClose}>
{(footerSlot): JSX.Element =>
connectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent ? (
<AgentSetup
footerSlot={footerSlot}
onConnectionMethodChange={setConnectionMethod}
onCommandGenerated={(): void => setHasGeneratedCommand(true)}
onCancel={requestClose}
onClose={onClose}
/>
) : (
<ManualSetup
footerSlot={footerSlot}
onConnectionMethodChange={setConnectionMethod}
onClose={onClose}
/>
)
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="base"
footer={footer}
drawerHeaderProps={{ className: styles.title }}
>
<FlowSelector value={flow} onChange={setFlow} />
<SetupGuideCallout />
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
/>
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
required
/>
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip="The GCP region where your OTel Collector will be deployed"
required
/>
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Select
id="gcp-project-ids-select"
className={cx(styles.fullWidth, styles.projectIdsSelect)}
mode="tags"
value={field.value}
onChange={(value): void => field.onChange(value)}
placeholder="Add project IDs…"
tokenSeparators={[',', ' ']}
notFoundContent={null}
suffixIcon={null}
getPopupContainer={popupContainer}
data-testid="gcp-project-ids-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</DrawerWrapper>
</SetupDrawerLayout>
<DialogWrapper
open={isCloseConfirmOpen}
onOpenChange={setIsCloseConfirmOpen}
title="Close GCP agent setup?"
testId="gcp-close-setup-confirm"
footer={
<>
<Button
size="md"
variant="solid"
color="highlight-danger"
onClick={onClose}
testId="gcp-close-setup-btn"
>
Close Setup
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={(): void => setIsCloseConfirmOpen(false)}
testId="gcp-continue-setup-btn"
>
Continue Setup
</Button>
</>
}
>
<Typography>
The agent has not connected yet. If you close now, you will have to start
the installation process again with a new setup command.
</Typography>
</DialogWrapper>
</>
);
}

View File

@@ -10,12 +10,6 @@
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
.methodRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -0,0 +1,89 @@
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import styles from './ConnectionMethodSelector.module.scss';
interface ConnectionMethodSelectorProps {
value: CloudintegrationtypesGCPConnectionMethodDTO;
onChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
disabled?: boolean;
}
function ConnectionMethodSelector({
value,
onChange,
disabled,
}: ConnectionMethodSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<Typography.Text
weight="bold"
size="base"
className={styles.drawerSurfaceHead}
>
Connection method
</Typography.Text>
<RadioGroup
color="primary"
textOverflow="wrap"
value={value}
onChange={(next): void =>
onChange(next as CloudintegrationtypesGCPConnectionMethodDTO)
}
disabled={disabled}
disabledTooltip="The connection method can't be changed once setup has started"
items={[
{
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
testId: 'gcp-connection-method-manual',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect Manually
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
Deploy your own OTel Collector.
</Typography.Text>
</>
),
},
{
value: CloudintegrationtypesGCPConnectionMethodDTO.agent,
testId: 'gcp-connection-method-agent',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect via Agent
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</>
),
},
]}
/>
</div>
);
}
ConnectionMethodSelector.defaultProps = {
disabled: false,
};
export default ConnectionMethodSelector;

View File

@@ -21,10 +21,6 @@
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}

View File

@@ -9,6 +9,7 @@ import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import FormField from './FormField';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
@@ -141,43 +142,32 @@ function ConnectionSecretsFields({
}
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<Controller
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
key={field.name}
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<FormField
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
error={fieldState.error}
>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
</FormField>
)}
/>
);
})}
</div>

View File

@@ -1,82 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { SetupFlow } from './types';
import styles from './FlowSelector.module.scss';
interface FlowSelectorProps {
value: SetupFlow;
onChange: (flow: SetupFlow) => void;
}
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Connection method
</Typography.Text>
</div>
<RadioGroup
color="primary"
textOverflow="wrap"
value={value}
onChange={(next): void => onChange(next as SetupFlow)}
items={[
{
value: 'manual',
testId: 'gcp-flow-manual',
label: (
<>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect Manually
</Typography.Text>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
Deploy your own OTel Collector.
</Typography.Text>
</>
),
},
{
value: 'agent',
testId: 'gcp-flow-agent',
disabled: true,
disabledTooltip: 'Soon',
label: (
<>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect via Agent
</Typography.Text>
<Badge color="primary" variant="solid">
Soon
</Badge>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</>
),
},
]}
/>
</div>
);
}
export default FlowSelector;

View File

@@ -0,0 +1,7 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}

View File

@@ -0,0 +1,55 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { FieldError } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import styles from './FormField.module.scss';
interface FormFieldProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
error?: FieldError;
children: ReactNode;
}
/** A labelled setup drawer field with its validation message below the input. */
function FormField({
htmlFor,
label,
tooltip,
required,
error,
children,
}: FormFieldProps): JSX.Element {
return (
<div className={styles.drawerSection}>
<FieldLabel
htmlFor={htmlFor}
label={label}
tooltip={tooltip}
required={required}
/>
{children}
{error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{error.message}
</Typography.Text>
)}
</div>
);
}
FormField.defaultProps = {
required: false,
error: undefined,
};
export default FormField;

View File

@@ -0,0 +1,139 @@
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { useManualAccountSetup } from 'hooks/integration/gcp/useManualAccountSetup';
import { Controller, useForm } from 'react-hook-form';
import ProjectIdsInput from '../../ProjectIdsInput';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import FormField from '../FormField';
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
import DeploymentRegionField from '../fields/DeploymentRegionField';
import ConnectionMethodSelector from '../ConnectionMethodSelector';
import SetupDrawerFooter from '../SetupDrawerFooter';
import SetupGuideCallout from '../SetupGuideCallout';
import { MANUAL_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
import { GcpSetupFormValues } from '../types';
import { usePrefillCredentials } from '../usePrefillCredentials';
import styles from './ManualSetup.module.scss';
interface ManualSetupProps {
footerSlot: HTMLDivElement | null;
onConnectionMethodChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
onClose: () => void;
}
function ManualSetup({
footerSlot,
onConnectionMethodChange,
onClose,
}: ManualSetupProps): JSX.Element {
const {
isLoading,
connectAccount,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useManualAccountSetup({ onClose });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_SETUP_FORM_VALUES,
});
usePrefillCredentials(connectionParams, setValue);
return (
<>
<ConnectionMethodSelector
value={CloudintegrationtypesGCPConnectionMethodDTO.manual}
onChange={onConnectionMethodChange}
disabled={isLoading}
/>
<SetupGuideCallout {...MANUAL_SETUP_GUIDE} />
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
error={fieldState.error}
>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
</FormField>
)}
/>
<DeploymentProjectIdField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
error={fieldState.error}
>
<ProjectIdsInput
id="gcp-project-ids-select"
testId="gcp-project-ids-select"
value={field.value}
onChange={field.onChange}
/>
</FormField>
)}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to connect GCP account"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onClose}
>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</SetupDrawerFooter>
</>
);
}
export default ManualSetup;

View File

@@ -0,0 +1,14 @@
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}

View File

@@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import styles from './SetupDrawerFooter.module.scss';
interface SetupDrawerFooterProps {
slot: HTMLDivElement | null;
errorTitle: string;
error: string | null;
onDismissError: () => void;
onCancel: () => void;
children?: ReactNode;
}
function SetupDrawerFooter({
slot,
errorTitle,
error,
onDismissError,
onCancel,
children,
}: SetupDrawerFooterProps): JSX.Element | null {
if (!slot) {
return null;
}
return createPortal(
<div className={styles.footerContainer}>
{error && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={onDismissError}
title={errorTitle}
testId="gcp-connect-error"
>
{error}
</Callout>
)}
<div className={styles.footer}>
<Button
size="md"
variant="outlined"
color="secondary"
onClick={onCancel}
testId="gcp-cancel-btn"
>
Cancel
</Button>
{children}
</div>
</div>,
slot,
);
}
SetupDrawerFooter.defaultProps = {
children: undefined,
};
export default SetupDrawerFooter;

View File

@@ -0,0 +1,36 @@
.setupDrawer {
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
overflow-y: auto;
}
// The region menu renders in place (no portal), so it needs its own surface
// to sit above the fields below it.
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
}
.footerSlot {
width: 100%;
}

View File

@@ -0,0 +1,40 @@
import { ReactNode, useState } from 'react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import styles from './SetupDrawerLayout.module.scss';
interface SetupDrawerLayoutProps {
onClose: () => void;
/** Receives the footer slot element that SetupDrawerFooter portals into. */
children: (footerSlot: HTMLDivElement | null) => ReactNode;
}
/** Stays mounted across connection method switches so the drawer does not replay its open animation. */
function SetupDrawerLayout({
onClose,
children,
}: SetupDrawerLayoutProps): JSX.Element {
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="extra-wide"
footer={<div ref={setFooterSlot} className={styles.footerSlot} />}
testId="gcp-setup-drawer"
>
{children(footerSlot)}
</DrawerWrapper>
);
}
export default SetupDrawerLayout;

View File

@@ -4,24 +4,30 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './SetupGuideCallout.module.scss';
const GCP_INTEGRATION_DOCS_URL =
'https://signoz.io/docs/integrations/gcp/gcp-integration/';
interface SetupGuideCalloutProps {
description: string;
docsUrl: string;
linkLabel: string;
}
function SetupGuideCallout(): JSX.Element {
function SetupGuideCallout({
description,
docsUrl,
linkLabel,
}: SetupGuideCalloutProps): JSX.Element {
return (
<Callout icon={<KeyRound />} testId="gcp-setup-guide-callout">
<Typography.Text as="span" size="base">
Please go through our GCP integration guide, which covers all prerequisites
— service account, IAM roles, and resource setup.
{description}
</Typography.Text>
<a
className={styles.guideLink}
href={GCP_INTEGRATION_DOCS_URL}
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
data-testid="gcp-setup-guide-link"
>
GCP integration guide
{linkLabel}
<ArrowUpRight size={12} />
</a>
</Callout>

View File

@@ -0,0 +1,27 @@
import { GcpSetupFormValues } from './types';
export const DEFAULT_SETUP_FORM_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
export const MANUAL_SETUP_GUIDE = {
description:
'Please follow our setup guide to create the resources needed to complete the integration.',
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'View setup guide',
};
export const AGENT_SETUP_GUIDE = {
description:
'SigNoz integration sets up everything needed to collect telemetry from your project.',
// TODO: point to the agent setup docs once they are published.
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'Learn more',
};

View File

@@ -0,0 +1,54 @@
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP project that hosts your OTel Collector deployment, often separate from the projects you actually monitor',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP project where SigNoz deploys the agent. The agent monitors this project.',
};
interface DeploymentProjectIdFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentProjectIdField({
control,
connectionMethod,
}: DeploymentProjectIdFieldProps): JSX.Element {
return (
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
</FormField>
)}
/>
);
}
export default DeploymentProjectIdField;

View File

@@ -0,0 +1,65 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP region where your OTel Collector will be deployed',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP region where the agent resources are deployed',
};
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
interface DeploymentRegionFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentRegionField({
control,
connectionMethod,
}: DeploymentRegionFieldProps): JSX.Element {
return (
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, styles.regionSelect, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
</FormField>
)}
/>
);
}
export default DeploymentRegionField;

View File

@@ -0,0 +1,19 @@
.mono {
composes: mono from '../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.regionSelect {
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
&.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}

View File

@@ -1,5 +1,4 @@
export type SetupFlow = 'manual' | 'agent';
/** Shared by both connection methods; agent setup leaves accountName and projectIds unused. */
export interface GcpSetupFormValues {
accountName: string;
deploymentProjectId: string;

View File

@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import { UseFormSetValue } from 'react-hook-form';
import { GcpSetupFormValues } from './types';
// Pre-fill the deployment/ingestion fields with the fetched credentials.
export function usePrefillCredentials(
connectionParams: CloudintegrationtypesCredentialsDTO | undefined,
setValue: UseFormSetValue<GcpSetupFormValues>,
): void {
useEffect(() => {
if (!connectionParams) {
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
}

View File

@@ -3,13 +3,15 @@ import { useQueryClient } from 'react-query';
import { Save } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Form, Select } from 'antd';
import { Form } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import ProjectIdsInput from '../ProjectIdsInput';
import styles from './AccountSettingsDrawer.module.scss';
@@ -40,6 +42,10 @@ function AccountSettingsDrawer({
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
// The backend rejects updates to agent accounts, so their settings are read-only.
const isAgentAccount =
gcpConfig?.connection_method ===
CloudintegrationtypesGCPConnectionMethodDTO.agent;
return (
<DrawerWrapper
@@ -65,20 +71,23 @@ function AccountSettingsDrawer({
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
gcpConnectionMethod={gcpConfig?.connection_method}
/>
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
{!isAgentAccount && (
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
)}
</div>
}
>
@@ -122,32 +131,42 @@ function AccountSettingsDrawer({
<div className={styles.regionSelector}>
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
<div className={styles.regionSelectorDescription}>
Update the GCP project IDs that should be monitored.
</div>
{isAgentAccount ? (
<div
className={styles.regionSelectorDescription}
data-testid="gcp-agent-monitored-projects"
>
{gcpConfig?.project_ids.join(', ')}
</div>
) : (
<>
<div className={styles.regionSelectorDescription}>
Update the GCP project IDs that should be monitored.
</div>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<Select
mode="tags"
value={projectIds}
tokenSeparators={[',']}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
data-testid="gcp-edit-project-ids-select"
/>
</Form.Item>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<ProjectIdsInput
id="gcp-edit-project-ids-select"
testId="gcp-edit-project-ids-select"
value={projectIds}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
/>
</Form.Item>
</>
)}
</div>
</div>
</Form>

View File

@@ -0,0 +1,5 @@
.projectIds {
// The multi-value trigger is content-box, so full width needs its padding counted in.
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,43 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import cx from 'classnames';
import styles from './ProjectIdsInput.module.scss';
interface ProjectIdsInputProps {
id: string;
testId: string;
value: string[];
onChange: (projectIds: string[]) => void;
className?: string;
}
/** Free-form list of GCP project IDs: type an ID and press Enter to add it. */
function ProjectIdsInput({
id,
testId,
value,
onChange,
className,
}: ProjectIdsInputProps): JSX.Element {
return (
<ComboboxSimple
id={id}
testId={testId}
className={cx(styles.projectIds, className)}
multiple
allowCreate
value={value}
onChange={(next): void => onChange(Array.isArray(next) ? next : [])}
placeholder="Add project IDs…"
inputPlaceholder="Type a project ID and press Enter"
emptyPlaceholder="Type a project ID to add it"
withPortal={false}
/>
);
}
ProjectIdsInput.defaultProps = {
className: undefined,
};
export default ProjectIdsInput;

View File

@@ -1,19 +1,18 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { toast } from '@signozhq/ui/sonner';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
import {
GCP_ACCOUNT_ID,
GCP_ACCOUNT_URL,
GCP_ACCOUNTS_URL,
gcpAccount,
gcpAccountConfig,
listAccountsResponse,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
@@ -31,31 +30,29 @@ jest.mock('@signozhq/ui/sonner', () => ({
},
}));
const onClose = jest.fn();
const setActiveAccount = jest.fn();
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
const onClose = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['onClose']
>;
const setActiveAccount = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['setActiveAccount']
>;
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>
</MockQueryClientProvider>,
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>,
);
};
/** The antd tags Select renders its text input inside the testId wrapper. */
const getProjectIdsInput = (): HTMLElement =>
within(screen.getByTestId('gcp-edit-project-ids-select')).getByRole(
'combobox',
);
/** Each selected tag carries an antd "close" icon that removes it. */
const getProjectIdTagRemoveButtons = (): HTMLElement[] =>
/** Each selected project ID renders as a pill with its own remove button. */
const getProjectIdRemoveButtons = (): HTMLElement[] =>
within(screen.getByTestId('gcp-edit-project-ids-select')).queryAllByLabelText(
'close',
/^Remove /,
);
describe('GCP AccountSettingsDrawer', () => {
@@ -86,8 +83,9 @@ describe('GCP AccountSettingsDrawer', () => {
screen.getByText(gcpAccountConfig.deployment_region),
).toBeInTheDocument();
const projectIdsInput = screen.getByTestId('gcp-edit-project-ids-select');
gcpAccountConfig.project_ids.forEach((projectId) => {
expect(screen.getByTitle(projectId)).toBeInTheDocument();
expect(projectIdsInput).toHaveTextContent(projectId);
});
});
@@ -100,7 +98,7 @@ describe('GCP AccountSettingsDrawer', () => {
'true',
);
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
@@ -114,7 +112,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -141,8 +139,7 @@ describe('GCP AccountSettingsDrawer', () => {
expect(setActiveAccount).toHaveBeenCalledWith({
...gcpAccount,
config: {
deployment_region: gcpAccountConfig.deployment_region,
deployment_project_id: gcpAccountConfig.deployment_project_id,
...gcpAccountConfig,
project_ids: ['project-a', 'project-b', 'project-c'],
},
});
@@ -159,9 +156,9 @@ describe('GCP AccountSettingsDrawer', () => {
renderDrawer();
// Strip every tag via its remove icon; the list shrinks as we go.
while (getProjectIdTagRemoveButtons().length > 0) {
while (getProjectIdRemoveButtons().length > 0) {
// eslint-disable-next-line no-await-in-loop
await user.click(getProjectIdTagRemoveButtons()[0]);
await user.click(getProjectIdRemoveButtons()[0]);
}
await waitFor(() => {
@@ -194,7 +191,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -212,31 +209,4 @@ describe('GCP AccountSettingsDrawer', () => {
expect(setActiveAccount).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('disconnects the account with the GCP-specific confirmation copy', async () => {
let disconnectedId: string | null = null;
server.use(
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
disconnectedId = req.params.id as string;
return res(ctx.status(204));
}),
);
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/manually tear down/i),
).resolves.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /remove account/i }));
await waitFor(() => {
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
});
expect(setActiveAccount).toHaveBeenCalledWith(null);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,104 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { CloudAccount } from 'container/Integrations/types';
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
import {
GCP_ACCOUNT_ID,
GCP_ACCOUNTS_URL,
gcpAccount,
gcpAgentAccount,
listAccountsResponse,
} from './mockData';
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
const onClose = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['onClose']
>;
const setActiveAccount = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['setActiveAccount']
>;
const renderDrawer = (account: CloudAccount = gcpAccount): void => {
render(
<AccountSettingsDrawer
onClose={onClose}
account={account}
setActiveAccount={setActiveAccount}
/>,
);
};
describe('GCP AccountSettingsDrawer by connection method', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listAccountsResponse)),
),
);
});
it('disconnects the account with the GCP-specific confirmation copy', async () => {
let disconnectedId: string | null = null;
server.use(
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
disconnectedId = req.params.id as string;
return res(ctx.status(204));
}),
);
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/manually tear down/i),
).resolves.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /remove account/i }));
await waitFor(() => {
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
});
expect(setActiveAccount).toHaveBeenCalledWith(null);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('shows agent accounts read-only, since the backend rejects updates to them', () => {
renderDrawer(gcpAgentAccount);
expect(screen.getByTestId('gcp-agent-monitored-projects')).toHaveTextContent(
'my-agent-project',
);
expect(
screen.queryByTestId('gcp-edit-project-ids-select'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-update-account-btn'),
).not.toBeInTheDocument();
});
it('explains the agent-specific cleanup when disconnecting an agent account', async () => {
const user = userEvent.setup();
renderDrawer(gcpAgentAccount);
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/signoz-integration-bootstrap Infrastructure/i),
).resolves.toBeInTheDocument();
expect(screen.queryByText(/manually tear down/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,128 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
connectedAgentAccountResponse,
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from './mockData';
// The setup hooks import logEvent by relative path, which the jest.config
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
// intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
const generateAgentCommand = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'my-deployment-project-123',
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-command');
};
describe('GCP setup drawer close confirmation', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
});
it('closes right away when no setup command has been generated', async () => {
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await user.click(screen.getByTestId('gcp-cancel-btn'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('gcp-close-setup-btn')).not.toBeInTheDocument();
});
it('asks before cancelling a pending agent setup and can continue it', async () => {
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await user.click(screen.getByTestId('gcp-cancel-btn'));
await screen.findByText(/start the installation process again/i);
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('gcp-continue-setup-btn'));
await waitFor(() => {
expect(
screen.queryByText(/start the installation process again/i),
).not.toBeInTheDocument();
});
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the drawer once the user confirms', async () => {
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await user.click(screen.getByRole('button', { name: 'Close' }));
await user.click(await screen.findByTestId('gcp-close-setup-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('closes without asking once the agent connects', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(
screen.queryByText(/start the installation process again/i),
).not.toBeInTheDocument();
});
});

View File

@@ -1,8 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
@@ -15,8 +14,9 @@ import {
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `useCloudAccountSetupDrawer` imports logEvent by relative path, which the
// `useManualAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept — so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
@@ -24,14 +24,12 @@ jest.mock('../../../../../api/common/logEvent', () => ({
default: jest.fn(),
}));
const onClose = jest.fn();
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<CloudAccountSetupDrawer onClose={onClose} />
</MockQueryClientProvider>,
);
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
describe('GCP CloudAccountSetupDrawer', () => {
@@ -109,6 +107,13 @@ describe('GCP CloudAccountSetupDrawer', () => {
});
it('creates the account, checks the agent in, and closes the drawer', async () => {
// A slow check-in keeps the connection in flight long enough to observe it.
server.use(
rest.post(GCP_CHECK_IN_URL, async (req: RestRequest, res, ctx) => {
checkInPayload = await req.json();
return res(ctx.delay(300), ctx.status(200), ctx.json(checkInResponse));
}),
);
const user = userEvent.setup();
renderDrawer();
@@ -127,13 +132,21 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,project-b,');
await addProjectIds(user, 'gcp-project-ids-select', [
'project-a',
'project-b',
]);
await user.click(screen.getByTestId('gcp-connect-account-btn'));
// Switching away mid-request would let its success close the other form.
await waitFor(() => {
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await waitFor(() => {
expect(createAccountPayload).not.toBeNull();
});
@@ -141,6 +154,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
expect(createAccountPayload).toStrictEqual({
config: {
gcp: {
connectionMethod: 'manual',
deploymentRegion: 'asia-south1',
deploymentProjectId: 'my-deployment-project-123',
projectIds: ['project-a', 'project-b'],
@@ -193,10 +207,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,');
await addProjectIds(user, 'gcp-project-ids-select', ['project-a']);
await user.click(screen.getByTestId('gcp-connect-account-btn'));

View File

@@ -0,0 +1,101 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from './mockData';
// The setup hooks import logEvent by relative path, which the jest.config
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
// intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
describe('GCP setup drawer connection method switch', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
});
it('switches between the manual and agent connection methods inside the same drawer', async () => {
const user = userEvent.setup();
renderDrawer();
const drawer = screen.getByTestId('gcp-setup-drawer');
expect(screen.getByTestId('gcp-account-name-input')).toBeInTheDocument();
expect(screen.getByTestId('gcp-connect-account-btn')).toBeInTheDocument();
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await screen.findByTestId('gcp-generate-command-btn');
expect(
screen.queryByTestId('gcp-account-name-input'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-connect-account-btn'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('gcp-connection-method-manual'));
await screen.findByTestId('gcp-account-name-input');
expect(
screen.queryByTestId('gcp-generate-command-btn'),
).not.toBeInTheDocument();
// The drawer itself stays mounted, so it does not replay its open animation.
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
});
it('keeps the drawer width the same across connection methods and setup steps', async () => {
const user = userEvent.setup();
renderDrawer();
const drawer = screen.getByTestId('gcp-setup-drawer');
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'my-deployment-project-123',
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-command');
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
});
});

View File

@@ -1,4 +1,7 @@
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
CloudAccount,
GCPCloudAccountConfig,
@@ -17,6 +20,7 @@ export const GCP_ACCOUNT_ID = 'acc-gcp-1';
export const GCP_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${GCP_ACCOUNT_ID}`;
export const gcpAccountConfig: GCPCloudAccountConfig = {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.manual,
deployment_region: 'asia-south1',
deployment_project_id: 'my-deployment-project-123',
project_ids: ['project-a', 'project-b'],
@@ -30,6 +34,19 @@ export const gcpAccount: CloudAccount = {
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
};
export const gcpAgentAccount: CloudAccount = {
id: 'acc-gcp-agent-1',
cloud_account_id: 'acc-gcp-agent-1',
providerAccountId: 'my-agent-project',
config: {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deployment_region: 'us-central1',
deployment_project_id: 'my-agent-project',
project_ids: ['my-agent-project'],
},
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
};
export const listAccountsResponse = {
status: 'success',
data: { accounts: [] },
@@ -63,3 +80,53 @@ export const checkInResponse = {
status: 'success',
data: {},
};
export const gcpConnectionCommand = {
cliCommand:
"curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform --gcp-project 'my-deployment-project-123'",
};
export const createAgentAccountResponse = {
status: 'success',
data: {
id: CLOUD_INTEGRATION_ID,
connectionArtifact: { gcp: gcpConnectionCommand },
},
};
export const GCP_AGENT_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${CLOUD_INTEGRATION_ID}`;
const pendingAgentAccount = {
id: CLOUD_INTEGRATION_ID,
orgId: 'org-1',
provider: 'gcp',
providerAccountId: null,
removedAt: null,
agentReport: null,
config: {
gcp: {
connectionMethod: 'agent',
deploymentProjectId: 'my-deployment-project-123',
deploymentRegion: 'asia-south1',
projectIds: ['my-deployment-project-123'],
},
},
};
export const pendingAgentAccountResponse = {
status: 'success',
data: pendingAgentAccount,
};
export const connectedAgentAccountResponse = {
status: 'success',
data: {
...pendingAgentAccount,
providerAccountId: 'my-deployment-project-123',
agentReport: {
timestampMillis: 1_700_000_000_000,
data: {},
syncState: null,
},
},
};

View File

@@ -0,0 +1,17 @@
import { screen, userEvent } from 'tests/test-utils';
/** Opens the project IDs input and adds each ID the way a user does: type, then Enter. */
export const addProjectIds = async (
user: ReturnType<typeof userEvent.setup>,
testId: string,
projectIds: string[],
): Promise<void> => {
await user.click(screen.getByTestId(testId));
const input = await screen.findByPlaceholderText(
'Type a project ID and press Enter',
);
for (const projectId of projectIds) {
// eslint-disable-next-line no-await-in-loop
await user.type(input, `${projectId}{Enter}`);
}
};

View File

@@ -3,6 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Modal } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import {
INTEGRATION_TELEMETRY_EVENTS,
@@ -17,10 +18,12 @@ function RemoveIntegrationAccount({
cloudProvider,
accountId,
onRemoveIntegrationAccountSuccess,
gcpConnectionMethod,
}: {
cloudProvider: string;
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO;
}): JSX.Element {
const { notifications } = useNotifications();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -44,7 +47,7 @@ function RemoveIntegrationAccount({
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
void logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
accountId,
integration: cloudProvider,
});
@@ -73,6 +76,20 @@ function RemoveIntegrationAccount({
manually when connecting this account.
</>
);
} else if (
cloudProvider === INTEGRATION_TYPES.GCP &&
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
) {
modalDescription = (
<>
Removing this account will stop SigNoz from monitoring it, and the agent
will disable the telemetry resources it created in your GCP project. <br />
<br />
After that, you can delete the signoz-integration-bootstrap Infrastructure
Manager deployment, the signoz-integration-bootstrap service account and the
signoz-integration secrets that were created while connecting this account.
</>
);
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
modalDescription = (
<>
@@ -129,4 +146,8 @@ function RemoveIntegrationAccount({
);
}
RemoveIntegrationAccount.defaultProps = {
gcpConnectionMethod: undefined,
};
export default RemoveIntegrationAccount;

View File

@@ -0,0 +1,51 @@
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapAccountDtoToGcpCloudAccount } from '../mapCloudAccountFromDto';
const gcpAccountDto: CloudintegrationtypesAccountDTO = {
id: 'acc-gcp-1',
orgId: 'org-1',
provider: 'gcp',
providerAccountId: 'my-project',
removedAt: null,
agentReport: { timestampMillis: 1_700_000_000_000, data: {}, syncState: null },
config: {
gcp: {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentProjectId: 'my-project',
deploymentRegion: 'us-central1',
projectIds: ['my-project'],
},
},
};
describe('mapAccountDtoToGcpCloudAccount', () => {
it('maps the GCP config, including its connection method', () => {
const account = mapAccountDtoToGcpCloudAccount(gcpAccountDto);
expect(account?.config).toStrictEqual({
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deployment_region: 'us-central1',
deployment_project_id: 'my-project',
project_ids: ['my-project'],
});
});
it('skips an account that has no GCP config', () => {
expect(
mapAccountDtoToGcpCloudAccount({ ...gcpAccountDto, config: {} }),
).toBeNull();
});
it('skips an account the agent has not checked in for yet', () => {
expect(
mapAccountDtoToGcpCloudAccount({
...gcpAccountDto,
providerAccountId: null,
}),
).toBeNull();
});
});

View File

@@ -51,7 +51,8 @@ export function mapAccountDtoToAzureCloudAccount(
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
const gcpConfig = account.config.gcp;
if (!account.providerAccountId || !gcpConfig) {
return null;
}
@@ -59,9 +60,10 @@ export function mapAccountDtoToGcpCloudAccount(
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
project_ids: account.config?.gcp?.projectIds ?? [],
connection_method: gcpConfig.connectionMethod,
deployment_region: gcpConfig.deploymentRegion,
deployment_project_id: gcpConfig.deploymentProjectId,
project_ids: gcpConfig.projectIds,
},
status: {
integration: {

View File

@@ -1,3 +1,4 @@
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import {
AWSCloudAccountConfig,
AWSServiceConfig,
@@ -102,6 +103,7 @@ export interface AzureCloudAccountConfig {
}
export interface GCPCloudAccountConfig {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO;
deployment_region: string;
deployment_project_id: string;
project_ids: string[];

View File

@@ -65,8 +65,7 @@ import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { isModifierKeyPressed } from 'utils/app';
import { checkVersionState, isModifierKeyPressed } from 'utils/app';
import { showErrorNotification } from 'utils/error';
import { openInNewTab } from 'utils/navigation';
@@ -714,7 +713,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
onClickHandler(ROUTES.BILLING, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
registerShortcut(GlobalShortcuts.NavigateToNotificationChannels, () =>
onClickHandler(ROUTES.ALL_CHANNELS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
@@ -744,7 +743,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);

View File

@@ -343,14 +343,6 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: true,
itemKey: 'account',
},
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',
icon: <FileKey2 size={16} />,
isEnabled: true,
itemKey: 'notification-channels',
},
{
key: ROUTES.BILLING,
label: 'Billing',

View File

@@ -0,0 +1,67 @@
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
type PollingProps = Parameters<typeof useAgentConnectionPolling>[0];
export const onConnected = jest.fn() as jest.MockedFunction<
PollingProps['onConnected']
>;
export const onTimeout = jest.fn() as jest.MockedFunction<
PollingProps['onTimeout']
>;
export const onError = jest.fn() as jest.MockedFunction<
PollingProps['onError']
>;
export const callbacks = { onConnected, onTimeout, onError };
export const ACCOUNT_ID = 'acc-gcp-1';
export const ACCOUNT_URL = `http://localhost/api/v1/cloud_integrations/gcp/accounts/${ACCOUNT_ID}`;
const agentReport = {
timestampMillis: 1_700_000_000_000,
data: {},
syncState: null,
};
export const pendingAccount: CloudintegrationtypesAccountDTO = {
id: ACCOUNT_ID,
orgId: 'org-1',
provider: 'gcp',
providerAccountId: null,
removedAt: null,
agentReport: null,
config: {
gcp: {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentProjectId: 'my-deployment-project-123',
deploymentRegion: 'us-central1',
projectIds: ['my-deployment-project-123'],
},
},
};
export const connectedAccount: CloudintegrationtypesAccountDTO = {
...pendingAccount,
providerAccountId: 'my-deployment-project-123',
agentReport,
};
export const mockAccountResponses = (
...accounts: CloudintegrationtypesAccountDTO[]
): { requestCount: () => number } => {
let count = 0;
server.use(
rest.get(ACCOUNT_URL, (_req, res, ctx) => {
const account = accounts[Math.min(count, accounts.length - 1)];
count += 1;
return res(ctx.status(200), ctx.json({ status: 'success', data: account }));
}),
);
return { requestCount: (): number => count };
};

View File

@@ -0,0 +1,94 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
ACCOUNT_URL,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onError,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (): void => {
renderHook(
() =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled: true,
...callbacks,
}),
{ wrapper: AllTheProviders },
);
};
describe('useAgentConnectionPolling outcomes', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('keeps polling until the agent checks in, then reports the connection', async () => {
const { requestCount } = mockAccountResponses(
pendingAccount,
pendingAccount,
connectedAccount,
);
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() => expect(requestCount()).toBe(2));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() =>
expect(onConnected).toHaveBeenCalledWith(connectedAccount),
);
expect(onTimeout).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
it('does not treat a removed account as connected', async () => {
const { requestCount } = mockAccountResponses({
...connectedAccount,
removedAt: '2026-01-01T00:00:00Z',
});
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
it('reports an error when the account request fails', async () => {
server.use(
rest.get(ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderPolling();
await waitFor(() => expect(onError).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (
initialEnabled: boolean,
): ((enabled: boolean) => void) => {
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled,
...callbacks,
}),
{ wrapper: AllTheProviders, initialProps: { enabled: initialEnabled } },
);
return (enabled: boolean): void => rerender({ enabled });
};
describe('useAgentConnectionPolling polling window', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('does not poll while disabled', async () => {
const { requestCount } = mockAccountResponses(connectedAccount);
renderPolling(false);
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS * 3);
});
expect(requestCount()).toBe(0);
expect(onConnected).not.toHaveBeenCalled();
});
it('reports a timeout once the deadline passes without a check-in', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
renderPolling(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
it('starts the timeout clock when polling is enabled, not on mount', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
const setEnabled = renderPolling(false);
// Longer than the whole window passes before polling is turned on.
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS + POLL_INTERVAL_MS);
});
setEnabled(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
});
});

View File

@@ -87,8 +87,7 @@ export function useAccountSettingsDrawer({
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig.deployment_region,
deployment_project_id: accountConfig.deployment_project_id,
...accountConfig,
project_ids: values.projectIds || [],
};

View File

@@ -0,0 +1,138 @@
import { useCallback, useState } from 'react';
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
import { useGcpAccountSetup } from './useGcpAccountSetup';
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
interface UseAgentAccountSetupProps {
onClose: () => void;
onCommandGenerated: () => void;
}
interface UseAgentAccountSetup {
isLoading: boolean;
generateCommand: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
connectionCommand: string | null;
/** The project the generated command targets, as submitted. */
commandProjectId: string;
modalState: ModalStateEnum;
checkConnectionAgain: () => void;
}
export function useAgentAccountSetup({
onClose,
onCommandGenerated,
}: UseAgentAccountSetupProps): UseAgentAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const [connectionCommand, setConnectionCommand] = useState<string | null>(
null,
);
const [commandProjectId, setCommandProjectId] = useState('');
const [accountId, setAccountId] = useState<string | null>(null);
const [modalState, setModalState] = useState<ModalStateEnum>(
ModalStateEnum.FORM,
);
const generateCommand = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
// The agent monitors only the project it is deployed in.
projectIds: [values.deploymentProjectId],
},
values,
);
const command = account.connectionArtifact.gcp?.cliCommand;
if (!command) {
setSubmitError(GENERATE_COMMAND_ERROR);
return;
}
setAccountId(account.id);
setCommandProjectId(values.deploymentProjectId);
setConnectionCommand(command);
setModalState(ModalStateEnum.WAITING);
onCommandGenerated();
void logEvent('GCP Integration: Account connection command generated', {
id: account.id,
});
}, GENERATE_COMMAND_ERROR),
[createGcpAccount, onCommandGenerated, runSubmit, setSubmitError],
);
const handleAgentConnected = useCallback(
(account: CloudintegrationtypesAccountDTO): void => {
handleConnected({
cloudIntegrationId: account.id,
providerAccountId: account.providerAccountId,
});
},
[handleConnected],
);
const handleTimeout = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('GCP Integration: Account connection attempt timed out', {
id: accountId,
});
}, [accountId]);
const handlePollingError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
const checkConnectionAgain = useCallback((): void => {
setModalState(ModalStateEnum.WAITING);
}, []);
useAgentConnectionPolling({
accountId: accountId ?? '',
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
onConnected: handleAgentConnected,
onTimeout: handleTimeout,
onError: handlePollingError,
});
return {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
};
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useRef } from 'react';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
export const POLL_INTERVAL_MS = 10 * 1000;
export const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
interface UseAgentConnectionPollingProps {
accountId: string;
/** Polls exactly while true; the caller decides when there is an account to poll. */
enabled: boolean;
onConnected: (account: CloudintegrationtypesAccountDTO) => void;
onTimeout: () => void;
onError: () => void;
}
/**
* Polls a GCP agent account until the agent checks in. The timeout is measured
* from when polling gets enabled, so re-enabling it starts a fresh window.
*/
export function useAgentConnectionPolling({
accountId,
enabled,
onConnected,
onTimeout,
onError,
}: UseAgentConnectionPollingProps): void {
const startTimeRef = useRef<number | null>(null);
useEffect(() => {
startTimeRef.current = enabled ? Date.now() : null;
}, [enabled]);
useGetAccount(
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
{
query: {
enabled,
refetchInterval: POLL_INTERVAL_MS,
select: (response): CloudintegrationtypesAccountDTO => response.data,
onSuccess: (account) => {
const isConnected =
Boolean(account.providerAccountId) && account.removedAt === null;
if (isConnected) {
onConnected(account);
return;
}
const startTime = startTimeRef.current ?? Date.now();
if (Date.now() - startTime >= CONNECTION_TIMEOUT_MS) {
onTimeout();
}
},
onError: () => {
onError();
},
},
},
);
}

View File

@@ -2,16 +2,15 @@ import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useAgentCheckIn,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
CloudintegrationtypesGCPPostableAccountConfigDTO,
CloudintegrationtypesGettableAccountWithConnectionArtifactDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
@@ -22,23 +21,36 @@ import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
interface UseGcpAccountSetupProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
interface UseGcpAccountSetup {
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
isLoading: boolean;
submitError: string | null;
setSubmitError: (message: string | null) => void;
clearSubmitError: () => void;
createGcpAccount: (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
) => Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO>;
/** Runs a submit, showing the backend's message inline (or `fallbackMessage`) when it fails. */
runSubmit: (
work: () => Promise<void>,
fallbackMessage: string,
) => Promise<void>;
handleConnected: (payload: {
cloudIntegrationId: string;
providerAccountId: string | null;
}) => void;
}
export function useCloudAccountSetupDrawer({
/** What both GCP connection methods share: credentials, account creation, submit state and the connected toast. */
export function useGcpAccountSetup({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
}: UseGcpAccountSetupProps): UseGcpAccountSetup {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -48,7 +60,6 @@ export function useCloudAccountSetupDrawer({
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
@@ -63,44 +74,15 @@ export function useCloudAccountSetupDrawer({
},
);
const handleClose = useCallback((): void => {
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: {
cloudIntegrationId: string;
providerAccountId: string;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId: payload.cloudIntegrationId,
providerAccountId: payload.providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
handleClose();
},
[handleClose, queryClient],
);
const connectAccount = useCallback(
async (values: GcpSetupFormValues): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
gcp: {
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
},
const createGcpAccount = useCallback(
async (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
): Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO> => {
const response = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
config: { gcp: gcpConfig },
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
@@ -110,56 +92,69 @@ export function useCloudAccountSetupDrawer({
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
},
});
// Step 1: create the integration account.
const createResponse: CreateAccountMutationResult = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: payload,
});
return response.data;
},
[connectionParams, createAccount],
);
const cloudIntegrationId = createResponse.data.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
const runSubmit = useCallback(
async (work: () => Promise<void>, fallbackMessage: string): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
await work();
} catch (error) {
// Surface the backend's message inline in the drawer instead of a
// generic failure string.
const message = toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to connect GCP account',
fallbackMessage,
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
[],
);
const handleConnected = useCallback(
({
cloudIntegrationId,
providerAccountId,
}: {
cloudIntegrationId: string;
providerAccountId: string | null;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId,
providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
onClose();
},
[onClose, queryClient],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback } from 'react';
import { useAgentCheckIn } from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import logEvent from '../../../api/common/logEvent';
import { useGcpAccountSetup } from './useGcpAccountSetup';
interface UseManualAccountSetupProps {
onClose: () => void;
}
interface UseManualAccountSetup {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useManualAccountSetup({
onClose,
}: UseManualAccountSetupProps): UseManualAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const { mutateAsync: checkIn } = useAgentCheckIn();
const connectAccount = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
// Step 1: create the integration account.
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
values,
);
const cloudIntegrationId = account.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnected({ cloudIntegrationId, providerAccountId });
}, 'Failed to connect GCP account'),
[checkIn, createGcpAccount, handleConnected, runSubmit],
);
return {
isLoading,
connectAccount,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

@@ -4,7 +4,11 @@
*/
import { rest } from 'msw';
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudintegrationtypesGCPConnectionMethodDTO,
type CloudintegrationtypesPostableAccountDTO,
type CloudintegrationtypesUpdatableServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
@@ -18,9 +22,14 @@ import {
CLOUD_PROVIDERS,
CLOUD_SERVICE_CAP,
type CloudProvider,
GCP_CONNECTION_METHODS,
MUTATION_STATES,
type MutationState,
NEW_ACCOUNT_CHECK_INS,
type NewAccountCheckIn,
createdAccountResponse,
newAccountId,
newAccountResponse,
BUILT_IN_INTEGRATION_NOT_FOUND,
credentialsResponse,
seedServiceSignals,
@@ -36,6 +45,11 @@ import {
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
const FAILED_REQUEST = {
status: 'error',
error: { code: 'internal', message: 'Request failed' },
};
/**
* Answers a mutation by the control's state: never, with a failure, or with the
* success body the caller builds.
@@ -48,16 +62,33 @@ const mutationResolver =
}
return state === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Request failed' },
}),
)
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: ok(req, res, ctx, ...rest);
};
/**
* The account the connect flow just created answers by the check-in control;
* every other account is the page's data.
*/
const accountResolver =
(
provider: CloudProvider,
checkIn: NewAccountCheckIn,
existing: MockResolver,
): MockResolver =>
(req, res, ctx, ...rest) => {
if (String(req.params.id) !== newAccountId(provider)) {
return existing(req, res, ctx, ...rest);
}
return checkIn === 'error'
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: res(
ctx.status(200),
ctx.json(newAccountResponse(provider, checkIn === 'checked-in')),
);
};
const PROVIDER = 'Cloud integration · provider';
const MUTATIONS = 'Cloud integration · mutations';
const SERVICES = 'Cloud integration · services';
@@ -71,6 +102,17 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: CLOUD_PROVIDERS,
value: 'aws',
}),
gcpConnectionMethod:
choiceControl<CloudintegrationtypesGCPConnectionMethodDTO>(
'GCP connection method',
{
group: PROVIDER,
description:
'How the connected GCP accounts were set up. `agent` makes the account settings drawer read-only and switches the Disconnect copy to the agent cleanup.',
options: GCP_CONNECTION_METHODS,
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
},
),
accounts: countControl('Connected accounts', {
group: PROVIDER,
description:
@@ -99,6 +141,13 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: MUTATION_STATES,
value: 'success',
}),
newAccountCheckIn: choiceControl<NewAccountCheckIn>('New account check-in', {
group: MUTATIONS,
description:
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent (the GCP agent setup command stays up), `error` fails the poll so the drawer reports it could not connect.',
options: NEW_ACCOUNT_CHECK_INS,
value: 'checked-in',
}),
accountUpdate: choiceControl<MutationState>('Account settings update', {
group: MUTATIONS,
description:
@@ -172,9 +221,19 @@ export const cloudIntegrationMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
mutationResolver(values.accountCreate, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createdAccountResponse(values.provider))),
),
mutationResolver(values.accountCreate, async (req, res, ctx) => {
const body = (await req.json()) as CloudintegrationtypesPostableAccountDTO;
return res(
ctx.status(201),
ctx.json(
createdAccountResponse(
values.provider,
body.config?.gcp?.connectionMethod,
),
),
);
}),
),
rest.put(
@@ -182,21 +241,41 @@ export const cloudIntegrationMocks = defineStoryMocks({
mutationResolver(values.accountUpdate, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(accountResponse(values.provider, String(req.params.id))),
ctx.json(
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
response.json((req) =>
accountResponse(values.provider, String(req.params.id)),
accountResolver(
values.provider,
values.newAccountCheckIn,
response.json((req) =>
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
response.json(() => accountsResponse(values.provider, values.accounts)),
response.json(() =>
accountsResponse(
values.provider,
values.accounts,
values.gcpConnectionMethod,
),
),
),
],
config: (values) => ({
@@ -264,6 +343,17 @@ export const connectAzureAccount = async (
);
};
const addGcpProjectId = async (
testId: string,
projectId: string,
): Promise<void> => {
await userEvent.click(screen.getByTestId(testId));
await userEvent.type(
await screen.findByPlaceholderText('Type a project ID and press Enter'),
`${projectId}{Enter}`,
);
};
export const connectGcpAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
@@ -278,14 +368,40 @@ export const connectGcpAccount = async (
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Mumbai (asia-south1)'));
await userEvent.type(
document.querySelector<HTMLElement>('#gcp-project-ids-select') as HTMLElement,
'project-a,',
);
await addGcpProjectId('gcp-project-ids-select', 'project-a');
await userEvent.click(screen.getByTestId('gcp-connect-account-btn'));
};
const openEditAccount = async (canvasElement: HTMLElement): Promise<void> => {
export const openGcpAgentSetup = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.click(
await screen.findByTestId(
'gcp-connection-method-agent',
undefined,
untilLoaded,
),
);
await screen.findByTestId('gcp-generate-command-btn', undefined, untilLoaded);
};
export const generateGcpAgentCommand = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openGcpAgentSetup(canvasElement);
await userEvent.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'signoz-prod-4471',
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Council Bluffs (us-central1)'));
await userEvent.click(screen.getByTestId('gcp-generate-command-btn'));
};
export const openEditAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/edit account/i,
@@ -308,6 +424,17 @@ export const updateAwsAccount = async (
);
};
export const updateGcpProjects = async (
canvasElement: HTMLElement,
projectId: string,
): Promise<void> => {
await openEditAccount(canvasElement);
await addGcpProjectId('gcp-edit-project-ids-select', projectId);
await userEvent.click(
await screen.findByRole('button', { name: /update changes/i }),
);
};
export const updateTaggedAccount = async (
canvasElement: HTMLElement,
tag: string,

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import { screen, userEvent, within } from 'storybook/test';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -12,9 +13,13 @@ import {
connectAzureAccount,
connectGcpAccount,
expectToast,
generateGcpAgentCommand,
openConnectFlow,
openEditAccount,
openGcpAgentSetup,
untilLoaded,
updateAwsAccount,
updateGcpProjects,
updateTaggedAccount,
} from './CloudIntegration.stories.mocks';
@@ -251,6 +256,111 @@ export const GcpRegionOpen: Story = {
},
};
/**
* The GCP connect drawer on its agent connection method: only the deployment
* project, region and credentials, since the agent monitors the project it is
* deployed in.
*/
export const GcpAgentSetup: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await openGcpAgentSetup(canvasElement);
},
};
/**
* The setup command the agent connection generates, with the drawer waiting for
* the agent to check in. Set New account check-in to
* `checked-in` or `error` to see it connect or fail.
*/
export const GcpAgentCommand: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId('gcp-agent-command', undefined, untilLoaded);
},
};
/** The agent checks in on the first poll, so the drawer closes with a toast. */
export const GcpAgentConnectedToast: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'checked-in' },
play: async ({ canvasElement, args }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
if (args.newAccountCheckIn === 'checked-in') {
await expectToast(/gcp account connected successfully/i);
}
},
};
/**
* The connection poll fails, so the drawer reports it could not connect and
* offers Check Again.
*/
export const GcpAgentConnectionError: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'error' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId(
'gcp-agent-connection-error',
undefined,
untilLoaded,
);
},
};
/**
* Leaving the drawer while the agent has not connected yet, which asks first
* because closing means starting the installation again.
*/
export const GcpAgentCloseConfirm: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await userEvent.click(
await screen.findByTestId('gcp-cancel-btn', undefined, untilLoaded),
);
await screen.findByTestId('gcp-close-setup-btn', undefined, untilLoaded);
},
};
/**
* The settings of a GCP account connected through the agent: read-only, since
* the backend rejects updates to agent accounts.
*/
export const GcpAgentAccountSettings: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await screen.findByTestId(
'gcp-agent-monitored-projects',
undefined,
untilLoaded,
);
},
};
/** Disconnecting an agent account, which names what is left to clean up. */
export const GcpAgentDisconnect: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await userEvent.click(
await screen.findByRole('button', { name: /disconnect/i }, untilLoaded),
);
await screen.findByText(
/signoz-integration-bootstrap Infrastructure/i,
undefined,
untilLoaded,
);
},
};
/**
* Saving the regions of a connected AWS account. Set Account settings update
* to `error` for the failure toast.
@@ -301,7 +411,7 @@ export const AzureAccountSettingsUpdateErrorToast: Story = {
export const GcpAccountSettingsUpdatedToast: Story = {
args: { provider: 'gcp' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'success') {
await expectToast(/account settings updated successfully/i);
}
@@ -312,7 +422,7 @@ export const GcpAccountSettingsUpdatedToast: Story = {
export const GcpAccountSettingsUpdateErrorToast: Story = {
args: { provider: 'gcp', accountUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'error') {
await expectToast(/failed to update account settings/i);
}

View File

@@ -3,6 +3,7 @@
* Do not hand-edit: regenerate instead.
*/
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import type {
CloudintegrationtypesAccountConfigDTO,
CloudintegrationtypesAccountDTO,
@@ -23,6 +24,19 @@ export type CloudProvider = (typeof CLOUD_PROVIDERS)[number];
export const MUTATION_STATES = ['success', 'loading', 'error'] as const;
export type MutationState = (typeof MUTATION_STATES)[number];
export const GCP_CONNECTION_METHODS = [
CloudintegrationtypesGCPConnectionMethodDTO.manual,
CloudintegrationtypesGCPConnectionMethodDTO.agent,
] as const;
/** How the poll for the account the connect flow just created answers. */
export const NEW_ACCOUNT_CHECK_INS = [
'checked-in',
'pending',
'error',
] as const;
export type NewAccountCheckIn = (typeof NEW_ACCOUNT_CHECK_INS)[number];
interface CloudServiceSeed {
id: string;
title: string;
@@ -266,6 +280,7 @@ const ACCOUNTS: Record<CloudProvider, readonly string[]> = {
const accountConfig = (
provider: CloudProvider,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountConfigDTO => {
if (provider === 'aws') {
return { aws: { regions: ['us-east-1', 'eu-west-1', 'ap-south-1'] } };
@@ -280,8 +295,23 @@ const accountConfig = (
};
}
// The backend only accepts the deployment project as an agent account's scope.
if (
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
) {
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471'],
},
};
}
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471', 'signoz-analytics-8820'],
@@ -292,12 +322,13 @@ const accountConfig = (
const account = (
provider: CloudProvider,
index: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountDTO => ({
id: `${provider}-account-${index + 1}`,
orgId: 'story-org',
provider,
providerAccountId: ACCOUNTS[provider][index],
config: accountConfig(provider),
config: accountConfig(provider, gcpConnectionMethod),
agentReport: {
timestampMillis: Date.now() - 45 * 1000,
data: null,
@@ -311,11 +342,12 @@ const account = (
export const accountsResponse = (
provider: CloudProvider,
count: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): ListAccounts200 => ({
status: 'success',
data: {
accounts: Array.from({ length: count }, (_, index) =>
account(provider, index),
account(provider, index, gcpConnectionMethod),
),
},
});
@@ -323,11 +355,52 @@ export const accountsResponse = (
export const accountResponse = (
provider: CloudProvider,
id: string,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): GetAccount200 => {
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
return { status: 'success', data: account(provider, index) };
return {
status: 'success',
data: account(provider, index, gcpConnectionMethod),
};
};
/** The backend's command template, filled in for the story's project and region. */
const GCP_AGENT_COMMAND = {
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform \\
--region 'us-central1' \\
--gcp-project 'signoz-prod-4471' \\
--integration-account-id 'gcp-account-new' \\
--signoz-api-url 'https://story.us.signoz.cloud' \\
--signoz-api-key 'story-api-key-8b7d02' \\
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
--ingestion-key 'story-ingestion-key-2f9c41' \\
--agent-version 'v0.0.8'`,
};
export const newAccountId = (provider: CloudProvider): string =>
`${provider}-account-new`;
/**
* The account the connect flow just created, as its connection poll sees it. Only
* GCP's agent connection polls, so a GCP account here is an agent one.
*/
export const newAccountResponse = (
provider: CloudProvider,
isCheckedIn: boolean,
): GetAccount200 => {
const created = {
...account(provider, 0, CloudintegrationtypesGCPConnectionMethodDTO.agent),
id: newAccountId(provider),
};
return {
status: 'success',
data: isCheckedIn
? created
: { ...created, providerAccountId: null, agentReport: null },
};
};
/**
@@ -541,14 +614,16 @@ export const credentialsResponse = (): GetConnectionCredentials200 => ({
/**
* The connection URL is what the setup flow opens in a new tab, which the story
* reports through the navigation overlay rather than following.
* reports through the navigation overlay rather than following. GCP returns its
* setup command only for an agent connection; a manual one gets none.
*/
export const createdAccountResponse = (
provider: CloudProvider,
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO,
): CreateAccount201 => ({
status: 'success',
data: {
id: `${provider}-account-new`,
id: newAccountId(provider),
connectionArtifact: {
aws: {
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
@@ -559,6 +634,10 @@ export const createdAccountResponse = (
cloudPowerShellCommand:
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
},
...(gcpConnectionMethod ===
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
gcp: GCP_AGENT_COMMAND,
}),
},
},
});

View File

@@ -44,7 +44,6 @@ describe('SettingsPage nav sections', () => {
'settings-page-sidenav',
'workspace',
'account',
'notification-channels',
'billing',
'roles',
'members',
@@ -56,6 +55,12 @@ describe('SettingsPage nav sections', () => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it('does not render "notification-channels" element', () => {
expect(
screen.queryByTestId('notification-channels'),
).not.toBeInTheDocument();
});
it.each(['Identity & Access', 'Authentication'])(
'renders "%s" section title',
(text) => {

View File

@@ -1,3 +1,12 @@
import anselmEickhoffUrl from '@/assets/Images/users/anselm.jpg';
import danielSchellUrl from '@/assets/Images/users/daniel.webp';
import dhruvGargUrl from '@/assets/Images/users/dhruv-garg.webp';
import goFrendiGunawanUrl from '@/assets/Images/users/go-frendi.webp';
import pranayNarangUrl from '@/assets/Images/users/pranay-narang.webp';
import sheheryarSewaniUrl from '@/assets/Images/users/shey.webp';
import subomiOluwalanaUrl from '@/assets/Images/users/subomi-oluwalana.webp';
import vivekBhaktaUrl from '@/assets/Images/users/vivek-bhakta.webp';
export const infoData = [
{
id: 'infoBlock-1',
@@ -43,7 +52,7 @@ export const enterpriseGradeValuesData = [
export const customerStoriesData = [
{
key: 'story-subomi-oluwalana',
avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp',
avatar: subomiOluwalanaUrl,
personName: 'Subomi Oluwalana',
role: 'Founder & CEO at Convoy',
customerName: 'Convoy',
@@ -54,7 +63,7 @@ export const customerStoriesData = [
},
{
key: 'story-dhruv-garg',
avatar: 'https://signoz.io/img/users/dhruv-garg.webp',
avatar: dhruvGargUrl,
personName: 'Dhruv Garg',
role: 'Tech Lead at Nudge',
customerName: 'Nudge',
@@ -65,7 +74,7 @@ export const customerStoriesData = [
},
{
key: 'story-vivek-bhakta',
avatar: 'https://signoz.io/img/users/vivek-bhakta.webp',
avatar: vivekBhaktaUrl,
personName: 'Vivek Bhakta',
role: 'CTO at Wombo AI',
customerName: 'Wombo AI',
@@ -75,7 +84,7 @@ export const customerStoriesData = [
},
{
key: 'story-pranay-narang',
avatar: 'https://signoz.io/img/users/pranay-narang.webp',
avatar: pranayNarangUrl,
personName: 'Pranay Narang',
role: 'Engineering at Azodha',
customerName: 'Azodha',
@@ -85,7 +94,7 @@ export const customerStoriesData = [
},
{
key: 'story-Sheheryar-Sewani',
avatar: 'https://signoz.io/img/users/shey.webp',
avatar: sheheryarSewaniUrl,
personName: 'Sheheryar Sewani',
role: 'Seasoned Rails Dev & Founder',
customerName: '',
@@ -96,7 +105,7 @@ export const customerStoriesData = [
},
{
key: 'story-daniel-schell',
avatar: 'https://signoz.io/img/users/daniel.webp',
avatar: danielSchellUrl,
personName: 'Daniel Schell',
role: 'Founder & CTO at Airlockdigital',
customerName: 'Airlockdigital',
@@ -106,7 +115,7 @@ export const customerStoriesData = [
},
{
key: 'c-story-6',
avatar: 'https://signoz.io/img/users/go-frendi.webp',
avatar: goFrendiGunawanUrl,
personName: 'Go Frendi Gunawan',
role: 'Data Engineer at Ctlyst.id',
customerName: 'Ctlyst.id',
@@ -116,7 +125,7 @@ export const customerStoriesData = [
},
{
key: 'story-anselm-eickhoff',
avatar: 'https://signoz.io/img/users/anselm.jpg',
avatar: anselmEickhoffUrl,
personName: 'Anselm Eickhoff',
role: 'Software Architect',
customerName: '',

View File

@@ -74,7 +74,9 @@
"meter-metrics",
"logs-field",
"traces-field",
"metrics-field"
"metrics-field",
"span-mapper-group",
"span-mapper"
],
"type": "string"
},

2
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -112,6 +111,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

View File

@@ -4,14 +4,15 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/gorilla/mux"
)
func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/span_mapper_groups", handler.New(
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.ListGroups),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.ListGroups, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListSpanMapperGroups",
Tags: []string{"spanmapper"},
@@ -25,14 +26,20 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapperGroup,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.CreateGroup),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.CreateGroup, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateSpanMapperGroup",
Tags: []string{"spanmapper"},
@@ -45,19 +52,26 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapperGroup,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/test", handler.New(
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.TestMappers),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.TestMappers, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "TestSpanMappers",
Tags: []string{"spanmapper"},
Summary: "Test span mappers against sample spans",
Description: "Tests how span mappers would transform sample spans",
Description: "Tests how span mappers would transform sample spans. Groups sent without mappers are read from the store by name.",
Request: new(spantypes.PostableSpanMapperTest),
RequestContentType: "application/json",
Response: new(spantypes.GettableSpanMapperTest),
@@ -65,14 +79,20 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapperGroup,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.UpdateGroup),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.UpdateGroup, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateSpanMapperGroup",
Tags: []string{"spanmapper"},
@@ -83,14 +103,21 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapperGroup,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("groupId"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.DeleteGroup),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.DeleteGroup, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteSpanMapperGroup",
Tags: []string{"spanmapper"},
@@ -103,14 +130,21 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapperGroup,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("groupId"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers", handler.New(
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.ListMappers),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.ListMappers, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListSpanMappers",
Tags: []string{"spanmapper"},
@@ -123,14 +157,21 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapperGroup,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("groupId"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.CreateMapper),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.CreateMapper, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateSpanMapper",
Tags: []string{"spanmapper"},
@@ -143,14 +184,32 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapper.Scope(coretypes.VerbCreate), coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapper,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
ParentResource: coretypes.ResourceMetaResourceSpanMapperGroup,
ParentID: coretypes.PathParam("groupId"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceSpanMapper,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers/{mapperId}", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.UpdateMapper),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.UpdateMapper, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateSpanMapper",
Tags: []string{"spanmapper"},
@@ -161,14 +220,21 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapper.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapper,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("mapperId"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers/{mapperId}", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.DeleteMapper),
provider.authzMiddleware.CheckResources(provider.spanMapperHandler.DeleteMapper, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteSpanMapper",
Tags: []string{"spanmapper"},
@@ -181,8 +247,26 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSpanMapper.Scope(coretypes.VerbDelete), coretypes.ResourceMetaResourceSpanMapperGroup.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSpanMapper,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("mapperId"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
ParentResource: coretypes.ResourceMetaResourceSpanMapperGroup,
ParentID: coretypes.PathParam("groupId"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceSpanMapper,
ChildIDs: coretypes.OneID(coretypes.PathParam("mapperId")),
},
),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.15",
Version: "v0.0.16",
},
}
}

View File

@@ -40,11 +40,19 @@ func stripKeyAlias(name string) string {
return keyAliasRe.ReplaceAllString(name, "")
}
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
// Dynamic column — a JSON path such as body_v2.level — into.
func unwrapVariant(val any) any {
if v, ok := val.(chcol.Variant); ok {
// unwrapVariant decodes a scan envelope: the chcol.Variant the driver scans a Dynamic column (a JSON
// path such as body_v2.level) into, and the JSONValue a whole JSON column scans into. The attributes
// bag decodes flat so its dotted keys merge with the legacy attribute maps and a key stored at both a
// scalar and an object path stays two keys; every other JSON column decodes nested.
func unwrapVariant(name string, val any) any {
switch v := val.(type) {
case chcol.Variant:
return v.Any()
case telemetrystoretypes.JSONValue:
if name == "attributes" {
return v.Flatten()
}
return v.Nest()
}
return val
}
@@ -54,11 +62,11 @@ func unwrapVariant(val any) any {
// series. JSON goes through encoding/json for its sorted map keys: ClickHouse groups documents by
// structure, so two rows it considers equal have to produce the same label.
func labelValue(val any) string {
val = unwrapVariant(val)
val = unwrapVariant("", val)
if val == nil {
return ""
}
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
if v, ok := val.(map[string]any); ok {
if raw, err := json.Marshal(v); err == nil {
return string(raw)
}
@@ -478,7 +486,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
// 2. deref each slot into the output row
row := make([]any, len(scan))
for i, cell := range scan {
row[i] = unwrapVariant(derefValue(cell))
row[i] = unwrapVariant(stripKeyAlias(colNames[i]), derefValue(cell))
}
data = append(data, row)
}
@@ -536,7 +544,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
name := stripKeyAlias(colNames[i])
// de-reference the typed pointer to any
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
val := unwrapVariant(name, reflect.ValueOf(cellPtr).Elem().Interface())
// special-case: timestamp column
if name == "timestamp" || name == "timestamp_datetime" {
@@ -575,12 +583,15 @@ func mergeSpanAttributeColumns(data map[string]any) {
attrStr, hasStr := data["attributes_string"]
attrNum, hasNum := data["attributes_number"]
attrBool, hasBool := data["attributes_bool"]
attrJSON, _ := data["attributes"].(telemetrystoretypes.JSONValue)
// readAsRaw already decoded the JSON column into a flat bag of dotted keys via unwrapVariant.
attrJSON, _ := data["attributes"].(map[string]any)
// todo(nitya): move to resource json
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
attributes := make(map[string]any)
attrJSON.FlattenInto("", attributes)
for k, v := range attrJSON {
attributes[k] = v
}
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v

View File

@@ -3,16 +3,13 @@ package querier
import (
"reflect"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
@@ -83,101 +80,40 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
}
}
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
func TestConsume_JSONColumn(t *testing.T) {
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
body := `{"level":"error","attrs":{"code":500}}`
wantBody := telemetrystoretypes.JSONValue{
"level": "error",
"attrs": map[string]any{"code": float64(500)},
// jsonColumn builds a JSONValue from dotted leaf paths, as ClickHouse stores them.
func jsonColumn(paths map[string]any) telemetrystoretypes.JSONValue {
j := chcol.NewJSON()
for path, value := range paths {
j.SetValueAtPath(path, value)
}
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
t.Run("scalar", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.ScalarData)
require.Len(t, data.Data, 2)
assert.Equal(t, wantBody, data.Data[0][0])
assert.Equal(t, uint64(3), data.Data[0][1])
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
assert.Equal(t, uint64(1), data.Data[1][1])
})
t.Run("time series", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{ts, body, uint64(3)}}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 1)
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
})
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
// series rather than being dropped, which would merge every group into one
t.Run("time series grouped by the JSON column", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{
{ts, `{"level":"error"}`, uint64(7)},
{ts, `{"level":"warn"}`, uint64(2)},
}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 2)
got := map[string]float64{}
for _, series := range data.Aggregations[0].Series {
require.Len(t, series.Labels, 1)
require.Len(t, series.Values, 1)
got[series.Labels[0].Value.(string)] = series.Values[0].Value
}
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
})
t.Run("raw", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "timestamp", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
}, [][]any{{ts, body}}))
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.RawData)
require.Len(t, data.Rows, 1)
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
})
return telemetrystoretypes.NewJSONValue(*j)
}
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
// into a chcol.Variant envelope rather than the value itself.
// A JSON path (e.g. body_v2.level) comes back as a Dynamic column scanned into a chcol.Variant
// envelope; a whole JSON column scans into a JSONValue, which decodes flat for the attributes bag and
// nested for every other column.
func TestUnwrapVariant(t *testing.T) {
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
jsonCol := jsonColumn(map[string]any{"level": "error", "attrs.code": int64(500)})
nullCol := jsonColumn(map[string]any{"level": "error", "absent": chcol.Variant{}})
testCases := []struct {
name string
column string
input any
want any
}{
{name: "VariantScalar", column: "", input: chcol.NewDynamicWithType("error", "String"), want: "error"},
{name: "EmptyDynamic", column: "", input: chcol.Dynamic{}, want: nil},
{name: "PlainValuePassthrough", column: "", input: uint64(3), want: uint64(3)},
{name: "JSONColumnNested", column: "body_v2", input: jsonCol, want: map[string]any{"level": "error", "attrs": map[string]any{"code": float64(500)}}},
{name: "AttributesColumnFlat", column: "attributes", input: jsonCol, want: map[string]any{"level": "error", "attrs.code": float64(500)}},
{name: "AttributesColumnDropsNullPaths", column: "attributes", input: nullCol, want: map[string]any{"level": "error"}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, unwrapVariant(testCase.column, testCase.input))
})
}
}
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
@@ -196,8 +132,8 @@ func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
}
}
// Arrays stay native leaves: the collector stringifies top-level arrays and explodes nested ones
// into indexed keys in the legacy maps; the JSON home keeps them whole and we do not mimic either.
// readAsRaw decodes the JSON column into a flat bag of dotted keys before this runs; mergeSpanAttributeColumns
// copies that bag and overlays the legacy string/number/bool maps, which win on collision.
func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
testCases := []struct {
name string
@@ -205,12 +141,9 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
want map[string]any
}{
{
name: "JSONOnly_FlattensNestedPaths_PreservesTypes",
name: "JSONOnly_FlatBagCopied_PreservesTypes",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{
"http": map[string]any{"route": "/api/pay", "retry": map[string]any{"count": float64(3)}},
"cache.hit": true,
},
"attributes": map[string]any{"http.route": "/api/pay", "http.retry.count": float64(3), "cache.hit": true},
},
want: map[string]any{"http.route": "/api/pay", "http.retry.count": float64(3), "cache.hit": true},
},
@@ -219,17 +152,17 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/old", "only.map": "m"},
"attributes_number": map[string]float64{"http.status": 500},
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/new"}, "only.json": "j"},
"attributes": map[string]any{"http.route": "/new", "only.json": "j"},
},
want: map[string]any{"http.route": "/old", "only.map": "m", "http.status": float64(500), "only.json": "j"},
},
{
name: "MapOnly_EmptyJSONDoc_KeepsMapValues",
name: "MapOnly_EmptyJSONBag_KeepsMapValues",
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/map"},
"attributes_number": map[string]float64{"http.status": 200},
"attributes_bool": map[string]bool{"cache.hit": true},
"attributes": telemetrystoretypes.JSONValue{},
"attributes": map[string]any{},
},
want: map[string]any{"http.route": "/map", "http.status": float64(200), "cache.hit": true},
},
@@ -237,60 +170,39 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
name: "MapOnly_NilJSON_BehavesAsAbsent",
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/map"},
"attributes": telemetrystoretypes.JSONValue(nil),
"attributes": map[string]any(nil),
},
want: map[string]any{"http.route": "/map"},
},
{
name: "Arrays_StayLeafValues",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"tags": []any{"a", "b"}, "codes": []any{float64(1), float64(2)}}},
"attributes": map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
},
want: map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
},
{
name: "TopLevelArrayOfMaps_StaysNativeLeaf",
name: "ScalarAndObjectKey_BothSurvive",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
"attributes": map[string]any{"db.function": "refresh", "db.function.arg": float64(2)},
},
want: map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
want: map[string]any{"db.function": "refresh", "db.function.arg": float64(2)},
},
{
name: "NestedArrayOfMaps_StaysNativeLeaf_NoIndexPaths",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
},
want: map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
},
{
name: "DualWritten_NestedArray_IndexKeysAndJSONArrayCoexist",
name: "DualWritten_IndexKeysAndJSONArrayCoexist",
data: map[string]any{
"attributes_number": map[string]float64{"http.items.0.a": 1},
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
"attributes": map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
},
want: map[string]any{"http.items.0.a": float64(1), "http.items": []any{map[string]any{"a": float64(1)}}},
},
{
name: "JSONNull_KeptAsNil",
name: "ScalarValueStaysScalar",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"k": nil},
},
want: map[string]any{"k": nil},
},
{
name: "KeyIsLeafValue_NotFlattened",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": "plaintext"},
"attributes": map[string]any{"http": "plaintext"},
},
want: map[string]any{"http": "plaintext"},
},
{
name: "KeyIsParent_FlattensToDottedPath",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/a"}},
},
want: map[string]any{"http.route": "/a"},
},
}
for _, testCase := range testCases {

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -1208,7 +1207,7 @@ func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, res
// carried one. Anything that is not a decoded document — the legacy string body, a NULL cell —
// is legal under these names and left alone.
func stripEmptyBodyMessage(val any) {
bodyMap, ok := val.(telemetrystoretypes.JSONValue)
bodyMap, ok := val.(map[string]any)
if !ok {
return
}

View File

@@ -264,6 +264,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
sqlmigration.NewAddGCPConnectionMethodFactory(),
sqlmigration.NewAddSpanMapperTuplesFactory(sqlstore),
)
}

View File

@@ -0,0 +1,85 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addGCPConnectionMethod struct {
logger *slog.Logger
}
type gcpConnectionMethodMigrationRow struct {
bun.BaseModel `bun:"table:cloud_integration"`
ID string `bun:"id"`
Config string `bun:"config"`
}
func NewAddGCPConnectionMethodFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_gcp_connection_method"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addGCPConnectionMethod{logger: ps.Logger}, nil
})
}
func (migration *addGCPConnectionMethod) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addGCPConnectionMethod) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var accounts []*gcpConnectionMethodMigrationRow
if err := tx.NewSelect().
Model(&accounts).
Where("provider = ?", "gcp").
Scan(ctx); err != nil {
return err
}
for _, account := range accounts {
// Preserve config fields that are not known to this migration.
var config map[string]json.RawMessage
if err := json.Unmarshal([]byte(account.Config), &config); err != nil {
migration.logger.WarnContext(ctx, "skipping GCP account with unreadable config", slog.String("account_id", account.ID), errors.Attr(err))
continue
}
if config == nil {
migration.logger.WarnContext(ctx, "skipping GCP account with null config", slog.String("account_id", account.ID))
continue
}
if _, exists := config["connectionMethod"]; exists {
continue
}
config["connectionMethod"] = json.RawMessage(`"manual"`)
encoded, err := json.Marshal(config)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*gcpConnectionMethodMigrationRow)(nil)).
Set("config = ?", string(encoded)).
Where("id = ?", account.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addGCPConnectionMethod) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,168 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addSpanMapperTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddSpanMapperTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_span_mapper_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSpanMapperTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addSpanMapperTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addSpanMapperTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper-group", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper-group", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper-group", "delete"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper-group", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper-group", "list"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper-group", "attach"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper-group", "detach"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper", "delete"},
{authtypes.SigNozAdminRoleName, "metaresource", "span-mapper", "create"},
{authtypes.SigNozEditorRoleName, "metaresource", "span-mapper-group", "read"},
{authtypes.SigNozEditorRoleName, "metaresource", "span-mapper-group", "list"},
{authtypes.SigNozViewerRoleName, "metaresource", "span-mapper-group", "read"},
{authtypes.SigNozViewerRoleName, "metaresource", "span-mapper-group", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addSpanMapperTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -97,8 +97,10 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
options.MaxIdleConns = config.Connection.MaxIdleConns
options.MaxOpenConns = config.Connection.MaxOpenConns
options.DialTimeout = config.Connection.DialTimeout
// This is to avoid the driver decoding issues with JSON columns
options.Settings["output_format_native_write_json_as_string"] = 1
// Read JSON columns natively: the driver mis-decodes JSON(max_dynamic_paths=0) columns without
// the flattened serialization, and reading them as a string collapses a key stored at both a
// scalar and an object path.
options.Settings["output_format_native_use_flattened_dynamic_and_json_serialization"] = 1
chConn, err := clickhouse.Open(options)
if err != nil {

View File

@@ -1,23 +0,0 @@
package telemetrystoretest
import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/telemetrystore"
)
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
// type they do in production.
type conn struct {
clickhouse.Conn
}
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
rows, err := c.Conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
return telemetrystore.WrapRows(rows), nil
}

View File

@@ -32,7 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
// ClickhouseDB returns the mock Clickhouse connection.
func (p *Provider) ClickhouseDB() clickhouse.Conn {
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
return p.clickhouseDB.(clickhouse.Conn)
}
// Cluster returns the cluster name.

View File

@@ -150,7 +150,7 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
}
account.Config.Azure = azureConfig
case CloudProviderTypeGCP:
gcpConfig := new(GCPAccountConfig)
gcpConfig := &GCPAccountConfig{ConnectionMethod: GCPConnectionMethodManual}
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
if err != nil {
return nil, err
@@ -240,25 +240,16 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
}
if config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
gcpConfig := &GCPAccountConfig{
ConnectionMethod: config.GCP.ConnectionMethod,
DeploymentProjectID: config.GCP.DeploymentProjectID,
ProjectIDs: config.GCP.ProjectIDs,
DeploymentRegion: config.GCP.DeploymentRegion,
}
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
if err := gcpConfig.ErrIfInvalid(); err != nil {
return nil, err
}
if len(config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
}
return &AccountConfig{
GCP: &GCPAccountConfig{
DeploymentProjectID: config.GCP.DeploymentProjectID,
ProjectIDs: config.GCP.ProjectIDs,
DeploymentRegion: config.GCP.DeploymentRegion,
},
}, nil
return &AccountConfig{GCP: gcpConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -301,12 +292,12 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
return nil, err
}
if len(config.Config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
if err := validateGCPProjectIDs(config.Config.GCP.ProjectIDs); err != nil {
return nil, err
}
if config.Config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
if err := validateGCPDeploymentProjectID(config.Config.GCP.DeploymentProjectID); err != nil {
return nil, err
}
return &AccountConfig{
@@ -355,6 +346,13 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
}
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
if provider == CloudProviderTypeGCP {
if account.Config.GCP.ConnectionMethod == GCPConnectionMethodAgent {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP agent account configuration cannot be updated")
}
config.GCP.ConnectionMethod = account.Config.GCP.ConnectionMethod
}
// deployment region can not be updated once set for Azure
if provider == CloudProviderTypeAzure {
config.Azure.DeploymentRegion = account.Config.Azure.DeploymentRegion
@@ -366,6 +364,18 @@ func (account *Account) Update(provider CloudProviderType, config *AccountConfig
return nil
}
func (account *Account) ErrIfInvalidProviderAccountID(providerAccountID string) error {
if account.Provider != CloudProviderTypeGCP || account.Config.GCP.ConnectionMethod != GCPConnectionMethodAgent {
return nil
}
if providerAccountID != account.Config.GCP.DeploymentProjectID {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "providerAccountId must match deploymentProjectId for GCP agent setup")
}
return nil
}
func (account *Account) UpdateAgentReport(providerAccountID *string, agentReport *AgentReport) {
account.ProviderAccountID = providerAccountID
account.AgentReport = agentReport

View File

@@ -1,32 +1,69 @@
package cloudintegrationtypes
import (
"bytes"
"slices"
"strings"
"text/template"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
GCPConnectionMethodManual = GCPConnectionMethod{valuer.NewString("manual")}
GCPConnectionMethodAgent = GCPConnectionMethod{valuer.NewString("agent")}
foundryScriptURL = "https://signoz.io/foundry.sh"
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
"shellQuote": func(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
},
}).Parse(gcpCLITemplateStr()))
)
type GCPConnectionMethod struct{ valuer.String }
func (GCPConnectionMethod) Enum() []any {
return []any{GCPConnectionMethodManual, GCPConnectionMethodAgent}
}
type GCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Project ID where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// Agent accounts store exactly the deployment project; manual accounts may monitor multiple projects.
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPPostableAccountConfig = GCPAccountConfig
type UpdatableGCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Compute service region where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ProjectIDs []string `json:"projectIds" required:"true"`
type GCPPostableAccountConfig struct {
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// Agent setup requires exactly DeploymentProjectID; manual setup may select multiple projects.
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPConnectionArtifact struct{}
// UpdatableGCPAccountConfig contains settings for manual integrations only.
type UpdatableGCPAccountConfig struct {
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPIntegrationConfig struct{}
type GCPConnectionArtifact struct {
CLICommand string `json:"cliCommand" required:"true"`
}
type GCPIntegrationConfig struct {
GCPAccountConfig
TelemetryCollectionStrategy *GCPTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
type GCPTelemetryCollectionStrategy struct {
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"false" nullable:"false"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"false" nullable:"false"`
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"true" nullable:"true"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"true" nullable:"true"`
}
type GCPMetricsCollectionStrategy struct {
@@ -50,3 +87,120 @@ type GCPServiceLogsConfig struct {
type GCPServiceMetricsConfig struct {
Enabled bool `json:"enabled" required:"true"`
}
func (config *GCPAccountConfig) ErrIfInvalid() error {
if config.ConnectionMethod.IsZero() {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "connectionMethod is required for GCP provider")
}
if config.ConnectionMethod != GCPConnectionMethodManual && config.ConnectionMethod != GCPConnectionMethodAgent {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "unsupported GCP connectionMethod: %q", config.ConnectionMethod.StringValue())
}
if err := validateGCPDeploymentProjectID(config.DeploymentProjectID); err != nil {
return err
}
if err := validateGCPRegion(config.DeploymentRegion); err != nil {
return err
}
if err := validateGCPProjectIDs(config.ProjectIDs); err != nil {
return err
}
if config.ConnectionMethod == GCPConnectionMethodAgent && (len(config.ProjectIDs) != 1 || config.ProjectIDs[0] != config.DeploymentProjectID) {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain only deploymentProjectId for GCP agent setup")
}
return nil
}
func NewGCPConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
data := gcpTemplateData{
FoundryScriptURL: foundryScriptURL,
ProjectID: cfg.DeploymentProjectID,
Region: cfg.DeploymentRegion,
IntegrationAccountID: integrationAccountID.StringValue(),
AgentVersion: agentVersion,
Credentials: creds,
}
var command bytes.Buffer
if err := gcpCLITemplate.Execute(&command, data); err != nil {
return nil, err
}
return &GCPConnectionArtifact{
CLICommand: command.String(),
}, nil
}
// NewGCPIntegrationConfig sorts and deduplicates the input slices in place and formats sink filters for joining with OR.
func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters []string) *GCPIntegrationConfig {
// Parentheses preserve each service filter's meaning when combined with OR.
for i, filter := range sinkFilters {
sinkFilters[i] = "(" + strings.TrimSpace(filter) + ")"
}
// Keep the generated config stable and avoid collecting shared metrics more than once.
slices.Sort(metricNames)
metricNames = slices.Compact(metricNames)
slices.Sort(sinkFilters)
sinkFilters = slices.Compact(sinkFilters)
strategy := new(GCPTelemetryCollectionStrategy)
if len(metricNames) > 0 {
strategy.Metrics = &GCPMetricsCollectionStrategy{MetricNames: metricNames}
}
if len(sinkFilters) > 0 {
strategy.Logs = &GCPLogsCollectionStrategy{SinkFilter: strings.Join(sinkFilters, " OR ")}
}
return &GCPIntegrationConfig{
GCPAccountConfig: *config,
TelemetryCollectionStrategy: strategy,
}
}
type gcpTemplateData struct {
FoundryScriptURL string
ProjectID string
Region string
IntegrationAccountID string
AgentVersion string
Credentials *Credentials
}
func gcpCLITemplateStr() string {
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration gcp terraform \
--region {{.Region | shellQuote}} \
--gcp-project {{.ProjectID | shellQuote}} \
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
--agent-version {{.AgentVersion | shellQuote}}`
}
func validateGCPDeploymentProjectID(projectID string) error {
if strings.TrimSpace(projectID) == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId is required for GCP provider")
}
if projectID != strings.TrimSpace(projectID) {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId cannot contain leading or trailing whitespace")
}
return nil
}
func validateGCPProjectIDs(projectIDs []string) error {
if len(projectIDs) == 0 {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain at least one project ID for GCP provider")
}
for _, projectID := range projectIDs {
if strings.TrimSpace(projectID) == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain an empty or whitespace-only project ID")
}
if projectID != strings.TrimSpace(projectID) {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain leading or trailing whitespace")
}
}
return nil
}

View File

@@ -80,6 +80,8 @@ func (name Kind) Enum() []any {
KindLogsField,
KindTracesField,
KindMetricsField,
KindSpanMapperGroup,
KindSpanMapper,
}
}

View File

@@ -40,6 +40,8 @@ var Kinds = []Kind{
KindLogsField,
KindTracesField,
KindMetricsField,
KindSpanMapperGroup,
KindSpanMapper,
}
var (
@@ -82,4 +84,6 @@ var (
KindLogsField = MustNewKind("logs-field")
KindTracesField = MustNewKind("traces-field")
KindMetricsField = MustNewKind("metrics-field")
KindSpanMapperGroup = MustNewKind("span-mapper-group")
KindSpanMapper = MustNewKind("span-mapper")
)

View File

@@ -218,6 +218,17 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
// span-mapper-group and span-mapper — admin manages, viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapper}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapper}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapper}, WildCardSelectorString)},
},
SigNozEditorRoleName: {
// cloud-integration — editor can read accounts
@@ -317,6 +328,9 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
// span-mapper-group — read only (admin manages)
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
},
SigNozViewerRoleName: {
// cloud-integration — viewer can read accounts
@@ -381,6 +395,9 @@ var ManagedRoleToTransactions = map[string][]Transaction{
// metrics-field — viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
// span-mapper-group — read only
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSpanMapperGroup}, WildCardSelectorString)},
},
SigNozAnonymousRoleName: {
// public-dashboard — anonymous read

View File

@@ -40,6 +40,8 @@ var Resources = []Resource{
ResourceMetaResourceLogsField,
ResourceMetaResourceTracesField,
ResourceMetaResourceMetricsField,
ResourceMetaResourceSpanMapperGroup,
ResourceMetaResourceSpanMapper,
}
var (
@@ -82,4 +84,6 @@ var (
ResourceMetaResourceLogsField = NewResourceMetaResource(KindLogsField)
ResourceMetaResourceTracesField = NewResourceMetaResource(KindTracesField)
ResourceMetaResourceMetricsField = NewResourceMetaResource(KindMetricsField, VerbList, VerbRead, VerbUpdate)
ResourceMetaResourceSpanMapperGroup = NewResourceMetaResource(KindSpanMapperGroup)
ResourceMetaResourceSpanMapper = NewResourceMetaResource(KindSpanMapper, VerbCreate, VerbUpdate, VerbDelete)
)

View File

@@ -178,9 +178,8 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
attributes := threadAttributes(storable)
// Spans from before the JSON rollout have no document and are not decoded: the legacy maps
// hold an object split into one key per field.
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON)
// Spans from before the JSON rollout have an empty document and format to nothing.
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON.Nest())
return &ThreadSpan{
SpanID: storable.SpanID,
TraceID: traceID,
@@ -206,10 +205,9 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
// threadAttributes flattens the JSON column into dotted keys, as the querier does for list
// responses, and falls back to the legacy maps for spans written before the JSON rollout.
func threadAttributes(storable *StorableSpan) map[string]any {
if len(storable.AttributesJSON) == 0 {
attributes := storable.AttributesJSON.Flatten()
if len(attributes) == 0 {
return storable.Attributes()
}
attributes := make(map[string]any, len(storable.AttributesJSON))
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
}

View File

@@ -3,11 +3,23 @@ package spantypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
// jsonAttrs builds a JSONValue from dotted leaf paths, as ClickHouse stores them.
func jsonAttrs(paths map[string]any) telemetrystoretypes.JSONValue {
j := chcol.NewJSON()
for path, value := range paths {
j.SetValueAtPath(path, value)
}
return telemetrystoretypes.NewJSONValue(*j)
}
func TestNewThreadQuery_Cursor(t *testing.T) {
testCases := []struct {
name string
@@ -48,7 +60,7 @@ func TestThreadAttributes(t *testing.T) {
{
name: "JSONColumnPresent_LegacyMapsIgnored",
span: StorableSpan{
AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"request": map[string]any{"model": "json"}}},
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "json"}),
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
},
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
@@ -66,6 +78,22 @@ func TestThreadAttributes(t *testing.T) {
"gen_ai.stream": true,
},
},
{
// ClickHouse materializes a row's absent paths as null from the part's path schema.
name: "JSONColumn_DropsNullPaths",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
},
wantAttrs: map[string]any{"gen_ai.request.model": "gpt-4o"},
},
{
name: "JSONColumn_OnlyNullPaths_FallsBackToLegacyMaps",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.output.messages": chcol.Variant{}}),
AttributesString: map[string]string{"gen_ai.input.messages": "legacy"},
},
wantAttrs: map[string]any{"gen_ai.input.messages": "legacy"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
@@ -73,3 +101,15 @@ func TestThreadAttributes(t *testing.T) {
})
}
}
func TestNewThreadSpan_FormatsJSONMessages(t *testing.T) {
storable := StorableSpan{
SpanID: "f1fa1bc863e94dd0",
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.input.messages": `[{"role":"user","content":"hi"}]`}),
}
span := newThreadSpan("trace", &storable)
require.Len(t, span.FormattedInput, 1)
assert.Equal(t, aiobservabilitytypes.RoleUser, span.FormattedInput[0].Role)
}

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