Compare commits
40 Commits
fix/qb-bar
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff4535868 | ||
|
|
878fb4f0df | ||
|
|
b6b1d5b6fc | ||
|
|
c39b3ff578 | ||
|
|
4aebec8cab | ||
|
|
2fa39a85bc | ||
|
|
3f0eea7a3d | ||
|
|
aaf35323c3 | ||
|
|
9d35c60906 | ||
|
|
ed6289f7ba | ||
|
|
e761e07f74 | ||
|
|
8b0bbf54f7 | ||
|
|
a5c1a3f1eb | ||
|
|
c3ec2f8320 | ||
|
|
33473551e6 | ||
|
|
04de506a9d | ||
|
|
d052063398 | ||
|
|
59f227d368 | ||
|
|
aaca29d26d | ||
|
|
2139580419 | ||
|
|
9ba2a25eb9 | ||
|
|
30839d390f | ||
|
|
2483c41452 | ||
|
|
216c1ef5de | ||
|
|
24dac5fa28 | ||
|
|
27be58cfef | ||
|
|
3e31ac0c1a | ||
|
|
6ee8af9f1f | ||
|
|
5cb2ed97cf | ||
|
|
407be7d267 | ||
|
|
089de88bf0 | ||
|
|
e5a39e3489 | ||
|
|
50b48649dd | ||
|
|
9fc6fbb9fa | ||
|
|
93d054ee02 | ||
|
|
fcb685bf37 | ||
|
|
e69d3fcc8c | ||
|
|
354214ca87 | ||
|
|
e3488c1592 | ||
|
|
574bfa62b8 |
15
.claude/rules/storybook.md
Normal 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.
|
||||
@@ -5,15 +5,9 @@ description: Shoot Storybook stories with sbshot at two git refs and produce a d
|
||||
|
||||
# sbshot visual diffs
|
||||
|
||||
`npx -y github:SigNoz/sbshot` shoots Storybook stories and pixel-diffs two runs.
|
||||
Use it. Do not hand-roll a Playwright screenshot loop or a canvas pixel-compare:
|
||||
sbshot already handles story selection, themes, settling, retries, diffing,
|
||||
tagging, a viewer and a static export, and its runs are the format the rest of
|
||||
the team can open.
|
||||
`npx -y github:SigNoz/sbshot` shoots Storybook stories and pixel-diffs two runs. Use it. Do not hand-roll a Playwright screenshot loop or a canvas pixel-compare: sbshot already handles story selection, themes, settling, retries, diffing, tagging, a viewer and a static export, and its runs are the format the rest of the team can open.
|
||||
|
||||
sbshot is not installed by any package. It is run through `npx`, so nothing it
|
||||
knows travels into this repo on its own. This file is where that knowledge
|
||||
lives.
|
||||
sbshot is not installed by any package. It is run through `npx`, so nothing it knows travels into this repo on its own. This file is where that knowledge lives.
|
||||
|
||||
## The shape of a run
|
||||
|
||||
@@ -24,58 +18,31 @@ tag <diff> <tag> <story-id>... # what the reviewer should look at
|
||||
export <diff> --out <dir> # a static site, hostable
|
||||
```
|
||||
|
||||
`<storybook>` is a running dev server (`http://localhost:6006`), a build
|
||||
directory holding `index.json`, or a project directory with `.storybook/`. The
|
||||
frontend has **no `build-storybook` script**, only `storybook dev -p 6006`, so
|
||||
point `capture` at a dev server.
|
||||
`<storybook>` is a running dev server (`http://localhost:6006`), a build directory holding `index.json`, or a project directory with `.storybook/`. The frontend has **no `build-storybook` script**, only `storybook dev -p 6006`, so point `capture` at a dev server.
|
||||
|
||||
## Cover the change with stories first
|
||||
|
||||
**A diff can only show what a story renders.** A screen, state or variant with no
|
||||
story is invisible to sbshot: it produces no pair, so it reads as "nothing
|
||||
changed" rather than "never looked at". This is the failure mode that makes a
|
||||
clean-looking diff worthless.
|
||||
**A diff can only show what a story renders.** A screen, state or variant with no story is invisible to sbshot: it produces no pair, so it reads as "nothing changed" rather than "never looked at". This is the failure mode that makes a clean-looking diff worthless.
|
||||
|
||||
Before capturing, list the screens and states the change touches and check each
|
||||
has a story. For anything missing, write it first — the `signoz-page-story`
|
||||
skill covers how. New states reached only by an interaction need a `play`
|
||||
function, or the capture shoots the page at rest and the state never appears.
|
||||
Before capturing, list the screens and states the change touches and check each has a story. For anything missing, write it first — the `signoz-page-story` skill covers how. New states reached only by an interaction need a `play` function, or the capture shoots the page at rest and the state never appears.
|
||||
|
||||
Then confirm it worked: the two runs' story counts should differ by exactly the
|
||||
stories the change adds. A new screen that produced no new story id is a screen
|
||||
nobody is reviewing. Say the count in the summary, so a reader can tell coverage
|
||||
from silence.
|
||||
Then confirm it worked: the two runs' story counts should differ by exactly the stories the change adds. A new screen that produced no new story id is a screen nobody is reviewing. Say the count in the summary, so a reader can tell coverage from silence.
|
||||
|
||||
## Rules that are not optional
|
||||
|
||||
**One freshly started Storybook per ref.** Never start one server and switch git
|
||||
branches under it. Vite's HMR reloads the page, but some modules do not
|
||||
re-initialise, so the shot differs silently from a clean boot and the difference
|
||||
looks like a real change. Settle time does not fix this and is not the variable.
|
||||
Use one worktree and one port per ref, and stop each server before the next.
|
||||
**One freshly started Storybook per ref.** Never start one server and switch git branches under it. Vite's HMR reloads the page, but some modules do not re-initialise, so the shot differs silently from a clean boot and the difference looks like a real change. Settle time does not fix this and is not the variable. Use one worktree and one port per ref, and stop each server before the next.
|
||||
|
||||
**Capture both themes: `--theme dark,light`.** Without it sbshot shoots the
|
||||
story's own default and everything lands in a single `default/` folder, so every
|
||||
regression in the other theme is invisible. Themes double the shot count, so
|
||||
scope with `--stories` or `--title`, never by dropping a theme.
|
||||
**Capture both themes: `--theme dark,light`.** Without it sbshot shoots the story's own default and everything lands in a single `default/` folder, so every regression in the other theme is invisible. Themes double the shot count, so scope with `--stories` or `--title`, never by dropping a theme.
|
||||
|
||||
**Baseline is the merge-base, not `origin/main`.** `git merge-base origin/main
|
||||
<branch>`. Against a moved main, every commit main gained since the branch's base
|
||||
shows up reversed, as though the branch reverted it.
|
||||
**Baseline is the merge-base, not `origin/main`.** `git merge-base origin/main <branch>`. Against a moved main, every commit main gained since the branch's base shows up reversed, as though the branch reverted it.
|
||||
|
||||
**The percentage sorts the list, it does not tell you what matters.** A dialog
|
||||
that dims the page scores high for a one-line change, and a whole new form
|
||||
section can score low. Open the shots and say what changed on each screen. The
|
||||
biggest number is usually the least interesting one.
|
||||
**The percentage sorts the list, it does not tell you what matters.** A dialog that dims the page scores high for a one-line change, and a whole new form section can score low. Open the shots and say what changed on each screen. The biggest number is usually the least interesting one.
|
||||
|
||||
**If you cannot explain a pair, assume the capture is at fault, not the code.**
|
||||
Open it, crop both sides and compare them before calling it noise. A pixel count
|
||||
and a good guess are not evidence.
|
||||
**If you cannot explain a pair, assume the capture is at fault, not the code.** Open it, crop both sides and compare them before calling it noise. A pixel count and a good guess are not evidence.
|
||||
|
||||
## Noise, and the floor that proves it
|
||||
|
||||
The same story does not always render identically twice. Pairs often move for
|
||||
reasons that have nothing to do with the change:
|
||||
The same story does not always render identically twice. Pairs often move for reasons that have nothing to do with the change:
|
||||
|
||||
- a focus ring left on whatever a `play` function clicked last
|
||||
- a scrollbar appearing, or its thumb resting a few pixels off
|
||||
@@ -86,9 +53,7 @@ reasons that have nothing to do with the change:
|
||||
- hover or active styling from where the pointer happened to rest
|
||||
- a late-loading font or image shifting a line of text
|
||||
|
||||
Do not argue about these one at a time. **Measure them**: shoot the same commit
|
||||
twice and diff those two runs against each other. Anything that moves there moved
|
||||
for no reason, so it is noise.
|
||||
Do not argue about these one at a time. **Measure them**: shoot the same commit twice and diff those two runs against each other. Anything that moves there moved for no reason, so it is noise.
|
||||
|
||||
```
|
||||
capture <storybook> --name-run noise-a
|
||||
@@ -97,60 +62,35 @@ diff noise-a noise-b --name-diff noise
|
||||
diff <baseline> <after> --noise noise # marks the movers flaky
|
||||
```
|
||||
|
||||
**The noise run has to cover the same stories as the real diff.** Do not scope it
|
||||
down to the few pairs that moved, however tempting that is. `--noise` marks a
|
||||
pair flaky when the noise diff does not show it as unchanged, so every story the
|
||||
noise run leaves out is marked flaky too, including the new screens the reviewer
|
||||
is there to look at. Shooting two narrow runs and diffing those against each
|
||||
other has the same problem. Either shoot the full set twice, or skip `--noise`
|
||||
and explain the few movers by hand.
|
||||
**The noise run has to cover the same stories as the real diff.** Do not scope it down to the few pairs that moved, however tempting that is. `--noise` marks a pair flaky when the noise diff does not show it as unchanged, so every story the noise run leaves out is marked flaky too, including the new screens the reviewer is there to look at. Shooting two narrow runs and diffing those against each other has the same problem. Either shoot the full set twice, or skip `--noise` and explain the few movers by hand.
|
||||
|
||||
That is the trade: a second full capture costs about as long as the first, so on
|
||||
a small diff it is often quicker to crop the two or three movers and say what
|
||||
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
|
||||
into CI, where something has to decide without a person looking.
|
||||
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run hides a real 8% change in the real one. Compare the two numbers before trusting the mark. When most of the flaky marks are hiding real changes, run the diff without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing real is filtered out of the reviewer's view.
|
||||
|
||||
If a pair is flaky every run and the state it captures matters, fix the story
|
||||
rather than living with it: blur what the `play` clicked, freeze the clock, or
|
||||
assert the state instead of shooting it.
|
||||
That is the trade: a second full capture costs about as long as the first, so on a small diff it is often quicker to crop the two or three movers and say what each one is. Use `--noise` when the diff is large, or when wiring `--fail-over` into CI, where something has to decide without a person looking.
|
||||
|
||||
If a pair is flaky every run and the state it captures matters, fix the story rather than living with it: blur what the `play` clicked, freeze the clock, or assert the state instead of shooting it.
|
||||
|
||||
## Making it readable for a reviewer
|
||||
|
||||
A reviewer cannot open `localhost`, so `sbshot ui` is for you, not for them.
|
||||
|
||||
1. `tag` the pairs that matter, with `--note` saying why. The viewer filters by
|
||||
tag, so the reviewer lands on the real changes instead of scrolling past
|
||||
noise that scored higher.
|
||||
2. `export <diff> --out <dir>`. It writes a static site carrying only the pairs
|
||||
that are not identical, converted to WebP, so a large diff stays small.
|
||||
3. Host it. A Claude artifact works: publish the export's files and give the
|
||||
reviewer the link. `index.html` is reserved for the artifact's own page, so
|
||||
publish sbshot's entry as `viewer.html` and link to
|
||||
`viewer.html#/job/diff/<diff>`.
|
||||
1. `tag` the pairs that matter, with `--note` saying why. The viewer filters by tag, so the reviewer lands on the real changes instead of scrolling past noise that scored higher.
|
||||
2. `export <diff> --out <dir>`. It writes a static site carrying only the pairs that are not identical, converted to WebP, so a large diff stays small.
|
||||
3. Host it. A Claude artifact works: publish the export's files and give the reviewer the link. `index.html` is reserved for the artifact's own page, so publish sbshot's entry as `viewer.html` and link to `viewer.html#/job/diff/<diff>`.
|
||||
4. Put the link in the PR body under **Screenshots / Screen Recordings**.
|
||||
|
||||
Say the numbers plainly: how many screens changed, how many are new, how many are
|
||||
pixel-identical, how many are flaky. The count of unchanged screens is often the
|
||||
most useful claim in the diff, because it is the one a reviewer cannot check by
|
||||
eye.
|
||||
Say the numbers plainly: how many screens changed, how many are new, how many are pixel-identical, how many are flaky. The count of unchanged screens is often the most useful claim in the diff, because it is the one a reviewer cannot check by eye.
|
||||
|
||||
## Operational traps
|
||||
|
||||
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
|
||||
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
|
||||
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
|
||||
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
|
||||
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.
|
||||
- `pnpm test:storybook` starts its own server on 6006 and hangs silently if
|
||||
something already holds that port. Free it first.
|
||||
- ImageMagick on macOS may have no default font, so `--mode green-parallel` fails
|
||||
with ``unable to read font `' ``. Map Helvetica to a real file in
|
||||
`~/.config/ImageMagick/type.xml`.
|
||||
- `sbshot link <dir> <name>` adds a run from elsewhere into the workspace, but it
|
||||
symlinks. Keep the source directory alive until you have exported.
|
||||
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or `--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
|
||||
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how many to shoot at once by sampling free memory, and off Linux it reads `os.freemem()`, which counts only truly free pages. macOS parks almost everything in "inactive" instead, which is reclaimable on demand, so the sampler can read ~100 MB on a 25 GB machine and shrink to one forever. `--jobs` does not help: it is only an upper cap. Check `parallelism` in `<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes inactive, that is this. A 124-shot run takes about 90 minutes pinned and about 6 minutes at four at a time, so it is worth spotting before a long capture.
|
||||
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is gitignored and generated. Run `node ./i18-generate-hash.cjs` in `<worktree>/frontend`, and symlink `node_modules` from the main checkout.
|
||||
- `pnpm test:storybook` starts its own server on 6006 and hangs silently if something already holds that port. Free it first.
|
||||
- ImageMagick on macOS may have no default font, so `--mode green-parallel` fails with ``unable to read font `' ``. Map Helvetica to a real file in `~/.config/ImageMagick/type.xml`.
|
||||
- `sbshot link <dir> <name>` adds a run from elsewhere into the workspace, but it symlinks. Keep the source directory alive until you have exported.
|
||||
- The export directory is build output. Delete it when done, or gitignore it.
|
||||
- Running the whole Storybook test suite locally fails broadly with
|
||||
`page.goto: Timeout` from resource starvation. Scope it; CI is the real signal.
|
||||
- Running the whole Storybook test suite locally fails broadly with `page.goto: Timeout` from resource starvation. Scope it; CI is the real signal.
|
||||
|
||||
## Done means
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
@@ -75,6 +75,7 @@ jobs:
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
- metricsexplorer
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -41,7 +41,6 @@ linters:
|
||||
no-global: all
|
||||
context: all
|
||||
static-msg: true
|
||||
key-naming-case: snake
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
|
||||
@@ -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
|
||||
@@ -3581,6 +3658,7 @@ components:
|
||||
- meter-metrics
|
||||
- logs-field
|
||||
- traces-field
|
||||
- metrics-field
|
||||
type: string
|
||||
CoretypesObject:
|
||||
properties:
|
||||
@@ -20637,9 +20715,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics-field:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics-field:list
|
||||
summary: List metric names
|
||||
tags:
|
||||
- metrics
|
||||
@@ -20706,9 +20784,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
summary: Get metric alerts
|
||||
tags:
|
||||
- metrics
|
||||
@@ -20790,9 +20868,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics-field:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics-field:read
|
||||
summary: Get metric attributes
|
||||
tags:
|
||||
- metrics
|
||||
@@ -20860,9 +20938,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: Get metric highlights
|
||||
tags:
|
||||
- metrics
|
||||
@@ -20919,9 +20997,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: Inspect raw metric data points
|
||||
tags:
|
||||
- metrics
|
||||
@@ -20989,9 +21067,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics-field:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics-field:read
|
||||
summary: Get metric metadata
|
||||
tags:
|
||||
- metrics
|
||||
@@ -21035,9 +21113,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- metrics-field:update
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- metrics-field:update
|
||||
summary: Update metric metadata
|
||||
tags:
|
||||
- metrics
|
||||
@@ -21083,9 +21161,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: Check if non-SigNoz metrics have been received
|
||||
tags:
|
||||
- metrics
|
||||
@@ -21142,9 +21220,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: Get metrics statistics
|
||||
tags:
|
||||
- metrics
|
||||
@@ -21201,9 +21279,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: Get metrics treemap
|
||||
tags:
|
||||
- metrics
|
||||
@@ -28342,9 +28420,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- dashboard:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- dashboard:list
|
||||
summary: Get metric dashboards (v2)
|
||||
tags:
|
||||
- metrics
|
||||
|
||||
@@ -88,7 +88,10 @@ tests/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ └── routing.ts
|
||||
│ ├── routing.ts
|
||||
│ ├── dashboards.ts # dashboard seeding + query_range mocks; panels → panel-editor → panel-config extend it
|
||||
│ ├── panels.ts
|
||||
│ └── panel-kinds.ts # one row per panel kind; drives tests/dashboards/contract
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -128,7 +131,8 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- Drivers layer: `drivers/panels.ts` exports `withPanels(dashboardsTest)` and re-exports `drivers/dashboards.ts`, so a spec still imports one module.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -116,7 +116,7 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
reducedExpr := "1"
|
||||
reducedExpr := "toUInt64(1)"
|
||||
if len(keptLabels) > 0 {
|
||||
reducedExpr = "uniq(("
|
||||
for i, label := range keptLabels {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"section_connection": "Connection",
|
||||
"section_message": "Message",
|
||||
"section_advanced": "Advanced",
|
||||
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
|
||||
"section_recipients": "Recipients",
|
||||
"field_channel_name": "Name",
|
||||
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
|
||||
@@ -186,6 +187,49 @@
|
||||
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
|
||||
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
|
||||
"api_key_required": "API Key is mandatory",
|
||||
"to_required": "To field is mandatory",
|
||||
"channel_name_required": "Channel name is mandatory"
|
||||
"to_required": "To field is mandatory",
|
||||
"channel_name_required": "Channel name is mandatory",
|
||||
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
|
||||
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
|
||||
"button_discard_channel": "Discard",
|
||||
"button_discard_confirm": "Discard",
|
||||
"discard_changes_title": "Discard your changes?",
|
||||
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
|
||||
"tooltip_save_no_changes": "Nothing has changed yet.",
|
||||
"entry_slack_field": "Field",
|
||||
"entry_slack_action": "Action",
|
||||
"entry_incidentio_metadata": "Entry",
|
||||
"label_slack_field_title": "Title",
|
||||
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
|
||||
"label_slack_field_value": "Value",
|
||||
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
|
||||
"label_incidentio_metadata_key": "Key",
|
||||
"label_incidentio_metadata_value": "Value",
|
||||
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
|
||||
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
|
||||
"tooltip_slack_action_text": "The label on the button.",
|
||||
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
|
||||
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
|
||||
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
|
||||
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
|
||||
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
|
||||
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
|
||||
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
|
||||
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
|
||||
"telegram_tip_link": "Learn how",
|
||||
"field_telegram_bot_token": "Bot token",
|
||||
"tooltip_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA...",
|
||||
"field_telegram_chat_id": "Chat ID",
|
||||
"tooltip_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
|
||||
"placeholder_telegram_chat_id": "e.g. -1001234567890",
|
||||
"field_telegram_message_thread_id": "Message thread ID",
|
||||
"tooltip_telegram_message_thread_id": "Posts into one message thread of a forum group. Leave empty to post in the group itself.",
|
||||
"field_telegram_title": "Title",
|
||||
"tooltip_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped.",
|
||||
"field_telegram_message": "Message",
|
||||
"tooltip_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
|
||||
"field_telegram_api_url": "Bot API URL",
|
||||
"tooltip_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
|
||||
"field_telegram_disable_notifications": "Send silently",
|
||||
"tooltip_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices."
|
||||
}
|
||||
|
||||
@@ -3291,6 +3291,7 @@ export enum CoretypesKindDTO {
|
||||
'meter-metrics' = 'meter-metrics',
|
||||
'logs-field' = 'logs-field',
|
||||
'traces-field' = 'traces-field',
|
||||
'metrics-field' = 'metrics-field',
|
||||
}
|
||||
export enum CoretypesTypeDTO {
|
||||
user = 'user',
|
||||
@@ -3791,7 +3792,12 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesGCPConnectionMethodDTO {
|
||||
manual = 'manual',
|
||||
agent = 'agent',
|
||||
}
|
||||
export interface CloudintegrationtypesGCPAccountConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4038,7 +4044,10 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
[key: string]: unknown;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
@@ -4077,8 +4086,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 {
|
||||
@@ -4233,7 +4296,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -4376,9 +4439,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[] | null;
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
|
||||
BIN
frontend/src/assets/Images/users/anselm.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/src/assets/Images/users/daniel.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/Images/users/dhruv-garg.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/src/assets/Images/users/go-frendi.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/Images/users/pranay-narang.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/src/assets/Images/users/shey.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/src/assets/Images/users/subomi-oluwalana.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/Images/users/vivek-bhakta.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -15,12 +15,12 @@
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,18 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
|
||||
24
frontend/src/components/BackHeader/BackHeader.module.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
/* The space sits between the way back and the title, rather than above both of
|
||||
them, so the two read as separate steps. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-height: 26px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--l1-border);
|
||||
--divider-margin: 10px 0 16px 0;
|
||||
}
|
||||
56
frontend/src/components/BackHeader/BackHeader.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import styles from './BackHeader.module.scss';
|
||||
|
||||
export interface BackHeaderProps {
|
||||
/** Where the page came from, named as the place rather than the action. */
|
||||
backLabel: string;
|
||||
backRoute: string;
|
||||
title: ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A way back on its own line, then the page's own title. The two rows keep the
|
||||
* title clear of whatever sits beside it, such as a copy button.
|
||||
*/
|
||||
function BackHeader({
|
||||
backLabel,
|
||||
backRoute,
|
||||
title,
|
||||
testId,
|
||||
}: BackHeaderProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header} data-testid={testId}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<ArrowLeft size={12} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
safeNavigate(backRoute, { newTab: isModifierKeyPressed(e) });
|
||||
}}
|
||||
testId="back-link"
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
<div className={styles.title}>{title}</div>
|
||||
</div>
|
||||
<Divider className={styles.divider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BackHeader.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default BackHeader;
|
||||
@@ -2,8 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
LogViewMode,
|
||||
FontSize,
|
||||
OptionsMenuConfig,
|
||||
} from 'container/OptionsMenu/types';
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Formula } from 'container/QueryBuilder/components/Formula';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getBuilderVariant } from 'lib/query/queryMode';
|
||||
import {
|
||||
BuilderVariant,
|
||||
IBuilderTraceOperator,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from './queryBuilderFields.types';
|
||||
@@ -59,8 +64,20 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
[config],
|
||||
);
|
||||
|
||||
const isAIQuery =
|
||||
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
|
||||
// AI queries are traces-only; BAR must not force metrics on them.
|
||||
if (isAIQuery) {
|
||||
handleSetConfig(newPanelType, DataSource.TRACES);
|
||||
return;
|
||||
}
|
||||
if (newPanelType === PANEL_TYPES.BAR) {
|
||||
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
|
||||
return;
|
||||
}
|
||||
handleSetConfig(newPanelType, currentDataSource);
|
||||
}
|
||||
}, [
|
||||
@@ -69,6 +86,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
initialDataSource,
|
||||
currentDataSource,
|
||||
newPanelType,
|
||||
isAIQuery,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderV2 } from '../QueryBuilderV2';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('../QueryV2/QueryV2', () => ({ QueryV2: (): null => null }));
|
||||
jest.mock('../QueryV2/QueryFooter/QueryFooter', () => (): null => null);
|
||||
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
|
||||
const aiQuery: Query = {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: initialQueriesMap.traces.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
function mockContext({
|
||||
panelType = null,
|
||||
initialDataSource = null,
|
||||
currentQuery = initialQueriesMap.metrics,
|
||||
}: {
|
||||
panelType?: PANEL_TYPES | null;
|
||||
initialDataSource?: DataSource | null;
|
||||
currentQuery?: Query;
|
||||
}): jest.Mock {
|
||||
const handleSetConfig = jest.fn();
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
currentQuery,
|
||||
panelType,
|
||||
initialDataSource,
|
||||
handleSetConfig,
|
||||
addNewBuilderQuery: jest.fn(),
|
||||
addNewFormula: jest.fn(),
|
||||
addTraceOperator: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
} as unknown as ReturnType<typeof useQueryBuilder>);
|
||||
return handleSetConfig;
|
||||
}
|
||||
|
||||
describe('QueryBuilderV2 provider config sync', () => {
|
||||
it('leaves the data source unset for a bar panel without a static config', () => {
|
||||
const handleSetConfig = mockContext({});
|
||||
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.BAR} version="v3" />);
|
||||
|
||||
expect(handleSetConfig).toHaveBeenCalledWith(PANEL_TYPES.BAR, null);
|
||||
});
|
||||
|
||||
it('syncs the static data source', () => {
|
||||
const handleSetConfig = mockContext({});
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
version="v3"
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(handleSetConfig).toHaveBeenCalledWith(
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an AI query on traces for a bar panel', () => {
|
||||
const handleSetConfig = mockContext({ currentQuery: aiQuery });
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
version="v3"
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(handleSetConfig).toHaveBeenCalledWith(
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.TRACES,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reset the config when the provider already matches', () => {
|
||||
const handleSetConfig = mockContext({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
initialDataSource: DataSource.TRACES,
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
version="v3"
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(handleSetConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,23 +3,21 @@ import { Columns3 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import {
|
||||
hideColumn,
|
||||
showColumn,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
|
||||
import styles from './ColumnSelector.module.scss';
|
||||
import type { TableColumnDef } from './types';
|
||||
import { hideColumn, showColumn, useHiddenColumnIds } from './useColumnStore';
|
||||
|
||||
interface ColumnSelectorProps<TData> {
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Shared with the table's `columnStorageKey`, which is what it writes to. */
|
||||
storageKey: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ColumnSelector<TData>({
|
||||
columns,
|
||||
storageKey,
|
||||
testId = 'column-selector-button',
|
||||
}: ColumnSelectorProps<TData>): JSX.Element {
|
||||
const hiddenColumnIds = useHiddenColumnIds(storageKey);
|
||||
|
||||
@@ -47,7 +45,7 @@ function ColumnSelector<TData>({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
testId="alert-columns-button"
|
||||
testId={testId}
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
@@ -77,4 +75,8 @@ function ColumnSelector<TData>({
|
||||
);
|
||||
}
|
||||
|
||||
ColumnSelector.defaultProps = {
|
||||
testId: 'column-selector-button',
|
||||
};
|
||||
|
||||
export default ColumnSelector;
|
||||
@@ -2,6 +2,7 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
|
||||
import { TanStackTableBase } from './TanStackTable';
|
||||
import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
export * from './TanStackHoverTooltip';
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
|
||||
@@ -24,7 +24,7 @@ export const REACT_QUERY_KEY = {
|
||||
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
|
||||
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
|
||||
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
|
||||
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
|
||||
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
|
||||
GET_HOST_LIST: 'GET_HOST_LIST',
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -101,6 +101,16 @@ const SPECS: Record<string, Record<string, unknown>> = {
|
||||
metadata: { team: 'ops' },
|
||||
sendResolved: true,
|
||||
},
|
||||
telegram: {
|
||||
apiUrl: 'https://api.telegram.org',
|
||||
botToken: '123456789:AA-token',
|
||||
chatId: -1001234567890,
|
||||
messageThreadId: 42,
|
||||
title: 't',
|
||||
message: 'm',
|
||||
disableNotifications: true,
|
||||
sendResolved: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe('load then save round trip', () => {
|
||||
|
||||
@@ -22,13 +22,26 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Laid out like the alert pages: discard on the left, test and save together on
|
||||
the right. Sticky rather than fixed, so it stays inside the page's own scroll
|
||||
instead of adding another offset over the bottom strip. */
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
|
||||
@@ -36,3 +49,21 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The @signozhq inputs leave their surface transparent and take the card's
|
||||
colour. antd paints its textareas instead, so beside them they read as a
|
||||
different control, most visibly in light mode. */
|
||||
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
|
||||
.fields :global(textarea.ant-input),
|
||||
.fields :global(textarea.ant-input:hover),
|
||||
.fields :global(textarea.ant-input:focus),
|
||||
.fields :global(textarea.ant-input-focused) {
|
||||
background: var(--input-background, transparent);
|
||||
border-width: var(--input-border-width, 1px);
|
||||
border-style: var(--input-border-style, solid);
|
||||
border-color: var(--input-border-color, var(--border));
|
||||
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
|
||||
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
|
||||
box-shadow: var(--input-box-shadow, none);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-4);
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
@@ -10,6 +10,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormCard from '../FormCard';
|
||||
import FormField from '../FormField';
|
||||
import RowEntry from './RowEntry';
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
const { TextArea } = AntdInput;
|
||||
@@ -139,10 +140,19 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
help={t('help_incidentio_metadata')}
|
||||
>
|
||||
{metadataRows.map((row, index) => (
|
||||
// rows have no stable identity beyond their position
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index} className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<RowEntry
|
||||
// rows have no stable identity beyond their position
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
noun={t('entry_incidentio_metadata')}
|
||||
removeLabel={t('button_incidentio_remove_metadata')}
|
||||
removeTestId={`incidentio-metadata-remove-${index}`}
|
||||
onRemove={(): void =>
|
||||
syncMetadata(metadataRows.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<FormField label={t('label_incidentio_metadata_key')}>
|
||||
<Input
|
||||
placeholder={t('placeholder_incidentio_metadata_key')}
|
||||
value={row.key}
|
||||
@@ -155,8 +165,9 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
}
|
||||
data-testid={`incidentio-metadata-key-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('label_incidentio_metadata_value')}>
|
||||
<Input
|
||||
placeholder={t('placeholder_incidentio_metadata_value')}
|
||||
value={row.value}
|
||||
@@ -169,22 +180,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
}
|
||||
data-testid={`incidentio-metadata-value-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('button_incidentio_remove_metadata')}
|
||||
onClick={(): void =>
|
||||
syncMetadata(metadataRows.filter((_, i) => i !== index))
|
||||
}
|
||||
testId={`incidentio-metadata-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
</RowEntry>
|
||||
))}
|
||||
|
||||
<div className={styles.add}>
|
||||
<Button
|
||||
variant="dashed"
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/* One height for every control a repeating row renders, so the remove button,
|
||||
the add button and the inputs beside them agree. */
|
||||
.entry,
|
||||
.add {
|
||||
--row-control-height: 28px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23,7 +30,9 @@
|
||||
}
|
||||
|
||||
.add {
|
||||
margin-top: var(--spacing-1);
|
||||
/* Sits with the blocks it adds to, not against the next field's label. */
|
||||
margin-top: var(--spacing-2);
|
||||
--button-height: var(--row-control-height);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -35,3 +44,52 @@
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* One entry per block, every input labelled. A row of bare boxes gave no way to
|
||||
tell which column was which once a value was typed. The block keeps a readable
|
||||
width while the rest of the form fills the page, so its two columns do not
|
||||
stretch into a row of six. */
|
||||
.entry {
|
||||
max-width: 760px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.entryTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
/* The remove control is an icon button, so give it the height the design
|
||||
system gives a small control rather than letting the glyph set it. */
|
||||
--button-height: var(--row-control-height);
|
||||
--button-icon-size: 14px;
|
||||
}
|
||||
|
||||
.entryIndex {
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.entryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 0 var(--spacing-6);
|
||||
}
|
||||
|
||||
/* The toggle reads as a sentence, so it takes the whole width rather than
|
||||
sitting in a column sized for an input. */
|
||||
.entryWide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.entryGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Minus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
interface RowEntryProps {
|
||||
/** Zero-based; shown to the reader as a count from one. */
|
||||
index: number;
|
||||
/** Singular noun for one entry, such as "Field" or "Action". */
|
||||
noun: string;
|
||||
removeLabel: string;
|
||||
removeTestId: string;
|
||||
onRemove: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function RowEntry({
|
||||
index,
|
||||
noun,
|
||||
removeLabel,
|
||||
removeTestId,
|
||||
onRemove,
|
||||
children,
|
||||
}: RowEntryProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.entry}>
|
||||
<div className={styles.entryTop}>
|
||||
<Typography.Text size="sm" className={styles.entryIndex}>
|
||||
{noun} {index + 1}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={removeLabel}
|
||||
onClick={onRemove}
|
||||
testId={removeTestId}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.entryGrid}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RowEntry;
|
||||
@@ -42,7 +42,9 @@ function Slack(): JSX.Element {
|
||||
>
|
||||
<Input {...register('channel')} data-testid="slack-channel-textbox" />
|
||||
</FormField>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title={t('section_message')}>
|
||||
<FormField label={t('field_slack_title')} error={errors.title?.message}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -82,7 +84,12 @@ function Slack(): JSX.Element {
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
|
||||
<FormCard
|
||||
title={t('section_advanced')}
|
||||
description={t('description_slack_advanced')}
|
||||
>
|
||||
<FormField
|
||||
label={t('field_slack_color')}
|
||||
help={t('help_slack_color')}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormField from '../FormField';
|
||||
import RowEntry from './RowEntry';
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
/** `confirm` is an object on the DTO, so its one editable field is reached by path. */
|
||||
@@ -29,49 +29,37 @@ function SlackActions(): JSX.Element {
|
||||
const { control, register } = useFormContext<ChannelSpecFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
|
||||
|
||||
const labelFor = (column: ActionColumn): string =>
|
||||
t(`label_slack_action_${column.replace('.text', '')}`);
|
||||
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
|
||||
|
||||
return (
|
||||
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
|
||||
<FormField
|
||||
label={t('field_slack_actions')}
|
||||
tooltip={t('tooltip_slack_actions')}
|
||||
>
|
||||
<div>
|
||||
{fields.length > 0 && (
|
||||
<div className={styles.row}>
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column} className={styles.field}>
|
||||
<Typography.Text size="sm" className={styles.heading}>
|
||||
{labelFor(column)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.map((row, index) => (
|
||||
<div key={row.id} className={styles.row}>
|
||||
<RowEntry
|
||||
key={row.id}
|
||||
index={index}
|
||||
noun={t('entry_slack_action')}
|
||||
removeLabel={t('remove_slack_action')}
|
||||
removeTestId={`slack-action-remove-${index}`}
|
||||
onRemove={(): void => remove(index)}
|
||||
>
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column} className={styles.field}>
|
||||
<FormField
|
||||
key={column}
|
||||
label={t(`label_slack_action_${keyFor(column)}`)}
|
||||
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
|
||||
>
|
||||
<Input
|
||||
{...register(`actions.${index}.${column}` as const)}
|
||||
placeholder={t(
|
||||
`placeholder_slack_action_${column.replace('.text', '')}`,
|
||||
)}
|
||||
testId={`slack-action-${column.replace('.text', '')}-${index}`}
|
||||
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
|
||||
testId={`slack-action-${keyFor(column)}-${index}`}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('remove_slack_action')}
|
||||
onClick={(): void => remove(index)}
|
||||
testId={`slack-action-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</RowEntry>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -8,6 +8,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormField from '../FormField';
|
||||
import RowEntry from './RowEntry';
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
// Slack renders these as the attachment's table of short or full-width entries.
|
||||
@@ -17,52 +18,61 @@ function SlackFields(): JSX.Element {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
|
||||
|
||||
return (
|
||||
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
|
||||
<FormField
|
||||
label={t('field_slack_fields')}
|
||||
tooltip={t('tooltip_slack_fields')}
|
||||
>
|
||||
<div>
|
||||
{fields.map((row, index) => (
|
||||
<div key={row.id} className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<RowEntry
|
||||
key={row.id}
|
||||
index={index}
|
||||
noun={t('entry_slack_field')}
|
||||
removeLabel={t('remove_slack_field')}
|
||||
removeTestId={`slack-field-remove-${index}`}
|
||||
onRemove={(): void => remove(index)}
|
||||
>
|
||||
<FormField
|
||||
label={t('label_slack_field_title')}
|
||||
tooltip={t('tooltip_slack_field_title')}
|
||||
>
|
||||
<Input
|
||||
{...register(`fields.${index}.title`)}
|
||||
placeholder={t('placeholder_slack_field_title')}
|
||||
testId={`slack-field-title-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('label_slack_field_value')}
|
||||
tooltip={t('tooltip_slack_field_value')}
|
||||
>
|
||||
<Input
|
||||
{...register(`fields.${index}.value`)}
|
||||
placeholder={t('placeholder_slack_field_value')}
|
||||
testId={`slack-field-value-${index}`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.entryWide}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`fields.${index}.short`}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={!!field.value}
|
||||
onChange={(checked): void => field.onChange(checked === true)}
|
||||
testId={`slack-field-short-${index}`}
|
||||
>
|
||||
<Typography.Text size="sm">
|
||||
{t('field_slack_field_short')}
|
||||
</Typography.Text>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`fields.${index}.short`}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={!!field.value}
|
||||
onChange={(checked): void => field.onChange(checked === true)}
|
||||
testId={`slack-field-short-${index}`}
|
||||
>
|
||||
<Typography.Text size="sm">
|
||||
{t('field_slack_field_short')}
|
||||
</Typography.Text>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('remove_slack_field')}
|
||||
onClick={(): void => remove(index)}
|
||||
testId={`slack-field-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</RowEntry>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
174
frontend/src/container/FormAlertChannels/Settings/Telegram.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormCard from '../FormCard';
|
||||
import FormField from '../FormField';
|
||||
|
||||
const { TextArea } = AntdInput;
|
||||
|
||||
/**
|
||||
* The API models the chat and the topic as integers. An empty or half-typed box
|
||||
* has to become `undefined`, not `NaN`: `omitBlank` keeps `NaN`, which
|
||||
* serialises as `null` and the API rejects it. react-hook-form also runs this
|
||||
* over the value already in the form, which arrives as a number, not a string.
|
||||
*/
|
||||
const asOptionalNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isNaN(value) ? undefined : value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
function TelegramSettings(): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormCard title={t('section_connection')}>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="telegram-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
>
|
||||
{t('telegram_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('telegram_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
|
||||
<FormField
|
||||
label={t('field_telegram_bot_token')}
|
||||
tooltip={t('tooltip_telegram_bot_token')}
|
||||
error={errors.botToken?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('botToken')}
|
||||
aria-required
|
||||
type="password"
|
||||
data-testid="telegram-bot-token-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_telegram_chat_id')}
|
||||
tooltip={t('tooltip_telegram_chat_id')}
|
||||
error={errors.chatId?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('chatId', { setValueAs: asOptionalNumber })}
|
||||
aria-required
|
||||
type="number"
|
||||
placeholder={t('placeholder_telegram_chat_id')}
|
||||
data-testid="telegram-chat-id-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_telegram_message_thread_id')}
|
||||
tooltip={t('tooltip_telegram_message_thread_id')}
|
||||
error={errors.messageThreadId?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('messageThreadId', { setValueAs: asOptionalNumber })}
|
||||
type="number"
|
||||
data-testid="telegram-message-thread-id-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title={t('section_message')}>
|
||||
<FormField
|
||||
label={t('field_telegram_title')}
|
||||
tooltip={t('tooltip_telegram_title')}
|
||||
error={errors.title?.message}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field }): JSX.Element => (
|
||||
<TextArea
|
||||
rows={2}
|
||||
data-testid="telegram-title-textarea"
|
||||
value={(field.value as string) ?? ''}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_telegram_message')}
|
||||
tooltip={t('tooltip_telegram_message')}
|
||||
error={errors.message?.message}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="message"
|
||||
render={({ field }): JSX.Element => (
|
||||
<TextArea
|
||||
rows={6}
|
||||
data-testid="telegram-message-textarea"
|
||||
value={(field.value as string) ?? ''}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title={t('section_advanced')}>
|
||||
<FormField
|
||||
label={t('field_telegram_api_url')}
|
||||
tooltip={t('tooltip_telegram_api_url')}
|
||||
error={errors.apiUrl?.message}
|
||||
>
|
||||
<Input {...register('apiUrl')} data-testid="telegram-api-url-textbox" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_telegram_disable_notifications')}
|
||||
tooltip={t('tooltip_telegram_disable_notifications')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="disableNotifications"
|
||||
render={({ field }): JSX.Element => (
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
testId="telegram-disable-notifications-switch"
|
||||
value={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TelegramSettings;
|
||||
@@ -24,6 +24,7 @@ describe('FormAlertChannels', () => {
|
||||
onTypeChangeHandler={jest.fn()}
|
||||
onSaveHandler={jest.fn()}
|
||||
onTestHandler={jest.fn()}
|
||||
onDiscardHandler={jest.fn()}
|
||||
testingState={false}
|
||||
savingState={false}
|
||||
editing
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
@@ -20,6 +21,7 @@ import MsTeamsSettings from './Settings/MsTeams';
|
||||
import OpsgenieSettings from './Settings/Opsgenie';
|
||||
import PagerSettings from './Settings/Pager';
|
||||
import SlackSettings from './Settings/Slack';
|
||||
import TelegramSettings from './Settings/Telegram';
|
||||
import WebhookSettings from './Settings/Webhook';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
@@ -38,6 +40,7 @@ const CHANNEL_TYPE_OPTIONS = [
|
||||
{ value: ChannelKind.jira, label: 'Jira' },
|
||||
{ value: ChannelKind.jsmops, label: 'Jira Service Management Ops' },
|
||||
{ value: ChannelKind.incidentio, label: 'incident.io' },
|
||||
{ value: ChannelKind.telegram, label: 'Telegram' },
|
||||
];
|
||||
|
||||
function FormAlertChannels({
|
||||
@@ -46,10 +49,12 @@ function FormAlertChannels({
|
||||
onTypeChangeHandler,
|
||||
onTestHandler,
|
||||
onSaveHandler,
|
||||
onDiscardHandler,
|
||||
savingState,
|
||||
testingState,
|
||||
editing = false,
|
||||
readOnly = false,
|
||||
hasChanges = true,
|
||||
saveChecks = [],
|
||||
testChecks = [],
|
||||
}: FormAlertChannelsProps): JSX.Element {
|
||||
@@ -80,6 +85,8 @@ function FormAlertChannels({
|
||||
return <IncidentIOSettings />;
|
||||
case ChannelKind.opsgenie:
|
||||
return <OpsgenieSettings />;
|
||||
case ChannelKind.telegram:
|
||||
return <TelegramSettings />;
|
||||
case ChannelKind.email:
|
||||
return <EmailSettings />;
|
||||
default:
|
||||
@@ -111,7 +118,10 @@ function FormAlertChannels({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('field_send_resolved')}>
|
||||
<FormField
|
||||
label={t('field_send_resolved')}
|
||||
tooltip={t('tooltip_send_resolved')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sendResolved"
|
||||
@@ -127,7 +137,11 @@ function FormAlertChannels({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('field_channel_type')} htmlFor="channel-type">
|
||||
<FormField
|
||||
label={t('field_channel_type')}
|
||||
tooltip={t('tooltip_channel_type')}
|
||||
htmlFor="channel-type"
|
||||
>
|
||||
<SelectSimple
|
||||
id="channel-type"
|
||||
items={CHANNEL_TYPE_OPTIONS}
|
||||
@@ -144,30 +158,45 @@ function FormAlertChannels({
|
||||
</fieldset>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<AuthZTooltip checks={saveChecks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="save-channel-button"
|
||||
loading={savingState}
|
||||
onClick={onSaveHandler}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={testChecks}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="test-channel-button"
|
||||
loading={testingState}
|
||||
onClick={onTestHandler}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="discard-channel-button"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onDiscardHandler}
|
||||
>
|
||||
{t('button_discard_channel')}
|
||||
</Button>
|
||||
|
||||
<div className={styles.actionGroup}>
|
||||
<AuthZTooltip checks={testChecks}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="test-channel-button"
|
||||
loading={testingState}
|
||||
onClick={onTestHandler}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={saveChecks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="save-channel-button"
|
||||
loading={savingState}
|
||||
disabled={!hasChanges}
|
||||
disabledTooltip={t('tooltip_save_no_changes')}
|
||||
onClick={onSaveHandler}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
@@ -180,12 +209,15 @@ interface FormAlertChannelsProps {
|
||||
onTypeChangeHandler: (value: ChannelKind) => void;
|
||||
onSaveHandler: () => void;
|
||||
onTestHandler: () => void;
|
||||
onDiscardHandler: () => void;
|
||||
testingState: boolean;
|
||||
savingState: boolean;
|
||||
// editing indicates if the form is opened in edit mode
|
||||
editing?: boolean;
|
||||
/** The reader has `read` but not `update`, so the fields render disabled. */
|
||||
readOnly?: boolean;
|
||||
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
|
||||
hasChanges?: boolean;
|
||||
/** Gates saving; a denial disables the button and names the missing scope. */
|
||||
saveChecks?: BrandedPermission[];
|
||||
/** Gates a test send, which the API allows with `create`. */
|
||||
@@ -195,6 +227,7 @@ interface FormAlertChannelsProps {
|
||||
FormAlertChannels.defaultProps = {
|
||||
editing: undefined,
|
||||
readOnly: false,
|
||||
hasChanges: true,
|
||||
saveChecks: [],
|
||||
testChecks: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.waitingMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -21,10 +21,6 @@
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { formUrlParams } from 'utils/traceUtils';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
@@ -20,6 +21,7 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
}
|
||||
|
||||
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
|
||||
tab: TraceDetailsTab.Thread,
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -44,7 +45,10 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
to={{
|
||||
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
|
||||
search: `?tab=${TraceDetailsTab.Thread}`,
|
||||
}}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
@@ -121,22 +122,26 @@ describe('ListAlertRules — actions menu', () => {
|
||||
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
|
||||
});
|
||||
|
||||
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
|
||||
let capturedPostBody: unknown = null;
|
||||
it('clone action: sends POST to clone endpoint and opens the cloned rule returned by the API', async () => {
|
||||
let capturedRuleId: string | null = null;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
capturedPostBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...(capturedPostBody as Record<string, unknown>),
|
||||
id: 'cloned-from-server',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/rules/:id/clone',
|
||||
async (req, res, ctx) => {
|
||||
capturedRuleId = req.params.id as string;
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...alertRulesFixture[0],
|
||||
id: 'cloned-from-server',
|
||||
alert: 'High CPU Alert - Copy',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
@@ -145,9 +150,7 @@ describe('ListAlertRules — actions menu', () => {
|
||||
await clickMenuItem('Clone');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPostBody).toStrictEqual(
|
||||
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
|
||||
);
|
||||
expect(capturedRuleId).toBe('rule-1');
|
||||
});
|
||||
|
||||
// The id from the server response round-trips into the navigate URL — this
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
invalidateListRules,
|
||||
patchRuleByID,
|
||||
cloneRuleByID,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
@@ -62,10 +62,9 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
|
||||
const handleClone = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
|
||||
toast.promise(
|
||||
createRule({
|
||||
...rule,
|
||||
alert: `${rule.alert} - Copy`,
|
||||
} as RuletypesPostableRuleDTO).then(async (response) => {
|
||||
cloneRuleByID({
|
||||
id: rule.id,
|
||||
}).then(async (response) => {
|
||||
await invalidateListRules(queryClient);
|
||||
const newRule = response.data;
|
||||
if (newRule) {
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -15,7 +15,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
|
||||
import { ActionsMenu, ColumnSelector } from './components';
|
||||
import { ActionsMenu } from './components';
|
||||
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
@@ -134,7 +134,11 @@ function ListAlertRules(): JSX.Element {
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.refreshRow}>
|
||||
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
|
||||
<ColumnSelector
|
||||
columns={columns}
|
||||
storageKey="alert-rules-columns"
|
||||
testId="alert-columns-button"
|
||||
/>
|
||||
{addNewAlert && (
|
||||
<Button
|
||||
variant="solid"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||