mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 16:30:42 +00:00
Compare commits
21 Commits
feat/input
...
feat/distr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d6181bf4 | ||
|
|
754bd6bd85 | ||
|
|
cd4c937524 | ||
|
|
277c30183d | ||
|
|
6049122a32 | ||
|
|
cedc15a291 | ||
|
|
10b476f6f1 | ||
|
|
c9af301ab8 | ||
|
|
3096c38438 | ||
|
|
572e700ed2 | ||
|
|
753c26bc00 | ||
|
|
c500b2204f | ||
|
|
6a0fff2492 | ||
|
|
3fa8901c7c | ||
|
|
34ceb50c65 | ||
|
|
9ab9b8f7e0 | ||
|
|
b31c1d7488 | ||
|
|
1b3298fc4c | ||
|
|
16efc6a01c | ||
|
|
d36c4ba843 | ||
|
|
85ef5e8e3f |
@@ -4,9 +4,9 @@ Applies to everything in the repo — code, config, workflows.
|
||||
|
||||
- **No unnecessary comments.** Do not comment where the code is self-explanatory; never restate what the code already says.
|
||||
- **Document only** non-obvious behavior, constraints, formats, and edge cases.
|
||||
- **Rationale goes in prose, not source.** Why a job exists, how a subsystem fits together: that belongs in the README or the PR. Exception: a config entry that would look like a mistake without its reason (a pinned version, a trust or lint exclusion) keeps a short reason next to it.
|
||||
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there. When the code a comment describes changes, update the comment; when that code is deleted, delete the comment with it.
|
||||
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges. A banner a skill requires (the AI-owned banner on story mocks and `tests/e2e/drivers/`) tells maintainers how the file is made; keep it.
|
||||
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
|
||||
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
|
||||
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
|
||||
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
|
||||
|
||||
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "frontend/src/**/*.ts"
|
||||
- "frontend/src/**/*.tsx"
|
||||
---
|
||||
|
||||
# Frontend state
|
||||
|
||||
Pick the store by what the state is. Never add Redux or React Context for shared state; to move existing Redux or Context state, use the `migrate-state-management` skill ([`frontend/.claude/skills/migrate-state-management/SKILL.md`](../../frontend/.claude/skills/migrate-state-management/SKILL.md)).
|
||||
|
||||
| State | Use | Never |
|
||||
|---|---|---|
|
||||
| Server / API data | React Query, generated hooks from `src/api/generated` | a copy in Redux, Context or Zustand |
|
||||
| Shareable: filters, time range, selection, page | nuqs (`useQueryState` with a parser) | Redux, Context, hand-parsed `useSearchParams` |
|
||||
| Global client state | a small Zustand store per domain | Redux, Context |
|
||||
| One component's UI state | `useState` / `useReducer` | any store |
|
||||
|
||||
## Server state
|
||||
|
||||
- Expose a query through a hook that returns its result plus memoized derived values. Never dispatch a response into another store.
|
||||
- **The query key holds every value the request reads.** A field change that alters the request (name, context, data type) must change the key; a lossy signature serves stale rows.
|
||||
|
||||
## URL state
|
||||
|
||||
- Keep the URL small (Chrome trims near 2k characters). No large datasets, no sensitive data.
|
||||
- **A rewrite touches only the params it owns.** Repairing or normalising one param keeps the rest (`activeLogId`, pagination) as they were.
|
||||
- **Links carry the current time range.** A row link, "open in new tab" and "copy link" land on the same period as an in-app click.
|
||||
- **A preset read from the URL applies once.** A later user change or reset must not re-apply it.
|
||||
|
||||
## Zustand
|
||||
|
||||
- One `create()` per module; split a large store into slice factories combined in that one `create()`.
|
||||
- Always read through a selector: `useDashboardStore((s) => s.isDashboardLocked)`, never `useDashboardStore()`.
|
||||
- Update only through `set`; never mutate state. State fields before actions in the store object.
|
||||
- Hooks never call `useStore.getState()` (`signoz/no-zustand-getstate-in-hooks`); export standalone actions instead.
|
||||
|
||||
## Local and form state
|
||||
|
||||
- **Editable state starts from saved data once.** Initialise the form from the saved value, then let the user own it; never re-derive it from saved data on each render.
|
||||
- **A mode switch owns its in-flight work.** While a request from one mode runs, disable the switch or drop the result when the mode changes, so it can't close or overwrite the new mode.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "frontend/src/**/*.test.ts"
|
||||
- "frontend/src/**/*.test.tsx"
|
||||
---
|
||||
|
||||
# Frontend tests
|
||||
|
||||
Examples and the harness API live in [`frontend/src/tests/README.md`](../../frontend/src/tests/README.md).
|
||||
|
||||
- **Import from the harness.** `render`, `screen`, `userEvent`, `waitFor` and the rest come from `tests/test-utils`, never from `@testing-library/react`. The harness mounts the router, React Query, Redux, app context and preferences. When it can't do what a test needs, extend `tests/test-utils` instead of importing around it.
|
||||
- **Find elements by test ID.** Query with `getByTestId`/`findByTestId` on the `data-testid` or `testId` the component exposes, and add one to the component when it has none. Fall back to role or text only for markup the app doesn't own. A test ID matches exactly one element and survives copy and markup changes; `getByRole` computes the accessibility tree on every call and is the slowest query.
|
||||
- **Interact like a user.** `const user = userEvent.setup({ pointerEventsCheck: 0 })`, then an awaited `user.click`, `hover`, `keyboard` or `type`. `fireEvent` only for events `userEvent` has no equivalent for: scroll, resize, setting `scrollTop`.
|
||||
- **Fake timers per test only**, for debounce or throttle, with `userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) })`. Never global.
|
||||
- **Network through MSW.** The global server runs for every test; override per test with `server.use(...)` from `mocks-server/server`. Large fixtures go in `mocks-server/__mockdata__`.
|
||||
- **Router through the harness.** Pass `initialRoute`/`routePath` to `render` and assert with `getCurrentLocation()` from `lib/router/navigation`. Never mock `react-router`, `react-router-dom` or `lib/history`; when a test can't render at a route, use the `lib/router` spies in `tests/router-mocks`.
|
||||
- **Mock only business boundaries.** Local `jest.mock` for API modules, custom hooks and domain components. Never re-mock what the harness provides or what `jest.config.ts` maps (`uplot`, `@signozhq/icons`, `useSafeNavigate`, `logEvent`).
|
||||
- **Type mocks and fixtures.** `jest.mocked(fn)` for module mocks, `jest.fn() as jest.MockedFunction<T>` for a standalone mock you configure or assert on. Fixtures carry their real type, from `src/api/generated` where it exists. No `any`, no `as unknown as`. An inline `onX={jest.fn()}` on a typed prop that is never asserted needs no annotation.
|
||||
- **A test fails without its change.** Revert the change, or break the line under test, and run the test before you finish: it must fail. Assert the specific outcome (exact text, request payload, call arguments), never only that an element exists.
|
||||
- **No limit on tests per file.** Split a file when it mixes unrelated behaviour, not to hit a count.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "frontend/src/**/*.ts"
|
||||
- "frontend/src/**/*.tsx"
|
||||
---
|
||||
|
||||
# Log body
|
||||
|
||||
`ILog.body` is `string | ILogBody`. On `use_json_body` tenants the API sends an object, and the collector wraps a plain-string body into `{ message }`. Every read goes through `frontend/src/lib/logs/logBody.ts`.
|
||||
|
||||
- **Read with a getter, never directly.** `getBodyDisplay` for a list cell or a log line, `getBodyMessage` for the drawer header. Both accept `undefined` and collapse a `{message}`-only object to its message.
|
||||
- **Never cast or stringify a body inline.** No `as string`, no `String(log.body)`, no `JSON.stringify(log.body)`, no `typeof body === 'string' ? … : …` outside the module. The v5 converter collapses `{ message }` once with `extractOnlyMessageBody`; live logs and the dashboard list panel skip the converter, so their rows carry the raw object and only a getter renders them right.
|
||||
- **Resolve a selected field with `getLogFieldValue`** from `lib/logs/flatLogData.ts`, so a `body.<key>` column works on both string and object bodies. An object value renders as JSON and is not offered as an add-to-query filter.
|
||||
- **The drawer tree and field lookup are the only places that use the raw value.** Everything else renders a string from a getter.
|
||||
- **Review check.** A raw body value used outside a getter call, `extractOnlyMessageBody` in the converter, `flatLogData.ts` and `LogDetailedView/utils.tsx` is a finding.
|
||||
@@ -9,6 +9,3 @@
|
||||
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
|
||||
- **Use convensional commit format** for commits and PR title.
|
||||
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.
|
||||
- **Review fixes are new commits too.** A force push hides what changed from human reviewers and makes Greptile post its findings again. Bring in `main` with a merge; only a stacked branch (`gh stack`) rebases, since the stack moves as a unit.
|
||||
- **Answer every Greptile thread.** Reply with the commit that fixes it (`Fixed in 4df6f3198d: <what changed>`) or the reason it stays, then resolve. Never resolve one with neither: Greptile withdraws a finding, and remembers it, only when told why.
|
||||
- **Leave human review threads to the author.** Fix the code they ask for, but never reply to or resolve a human reviewer's thread.
|
||||
|
||||
@@ -10,9 +10,6 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
|
||||
- **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.
|
||||
- **Plays live with the mocks.** A `play` is a named function exported from `stories/__story_mockdata__/<page>.play.ts`, which is collapsed for review like the rest of the AI-owned mocks. The story file only names it: `play: openEditDrawer`. A story file that still holds play bodies moves them there the first time you edit it.
|
||||
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, row count, hidden header). A value the page supports must never fail the play.
|
||||
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
|
||||
- **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.
|
||||
|
||||
@@ -115,8 +115,8 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- **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`, play functions in `stories/__story_mockdata__/<page>.play.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. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. 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).
|
||||
- **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
|
||||
@@ -143,8 +143,6 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- [ ] 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
|
||||
- [ ] every play passes with each control value its story exposes, and the story file holds no play body
|
||||
- [ ] every request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
|
||||
- [ ] 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
|
||||
|
||||
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -24,7 +24,7 @@ go.mod @therealpandey
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey @srikanthccv
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
@@ -136,7 +136,7 @@ go.mod @therealpandey
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @therealpandey @srikanthccv
|
||||
/tests/integration/ @therealpandey
|
||||
|
||||
# e2e tests
|
||||
/tests/e2e/ @AshwinBhatkal
|
||||
@@ -302,8 +302,3 @@ go.mod @therealpandey
|
||||
/frontend/.storybook/ @H4ad
|
||||
/frontend/src/storybook/ @H4ad
|
||||
/.claude/skills/signoz-page-story/ @H4ad
|
||||
|
||||
## AI Assistant
|
||||
/frontend/src/container/AIAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
|
||||
/frontend/src/hooks/aiAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
|
||||
/frontend/src/pages/AIAssistantPage/ @SigNoz/nerve @SigNoz/frontend-maintainers
|
||||
11
.github/workflows/e2eci.yaml
vendored
11
.github/workflows/e2eci.yaml
vendored
@@ -2,9 +2,6 @@ name: e2eci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -16,12 +13,8 @@ jobs:
|
||||
outputs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
steps:
|
||||
- name: merge-queue-pass
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "::notice::Merge queue run. E2E already gated the PR, so the required e2e check auto-passes."
|
||||
- name: changes
|
||||
id: changes
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -67,7 +60,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
shard: [1, 2, 3, 4]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -162,7 +155,7 @@ jobs:
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
@@ -2205,10 +2205,10 @@ components:
|
||||
type: object
|
||||
CloudintegrationtypesAWSConnectionArtifact:
|
||||
properties:
|
||||
cliCommand:
|
||||
connectionUrl:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
- connectionUrl
|
||||
type: object
|
||||
CloudintegrationtypesAWSIntegrationConfig:
|
||||
properties:
|
||||
@@ -2364,8 +2364,11 @@ components:
|
||||
properties:
|
||||
cliCommand:
|
||||
type: string
|
||||
cloudPowerShellCommand:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
- cloudPowerShellCommand
|
||||
type: object
|
||||
CloudintegrationtypesAzureIntegrationConfig:
|
||||
properties:
|
||||
@@ -4479,6 +4482,104 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDistributionAxes:
|
||||
properties:
|
||||
x:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
|
||||
description: The bucket axis.
|
||||
"y":
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
|
||||
description: The count axis.
|
||||
type: object
|
||||
DashboardtypesDistributionChartAppearance:
|
||||
properties:
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
|
||||
description: Percentile markers drawn across the bucket axis.
|
||||
stack:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
description: 'How groups combine in a bucket: normal (default) stacks them,
|
||||
none draws them side by side, percent stacks them as shares of the bucket.'
|
||||
required:
|
||||
- stack
|
||||
type: object
|
||||
DashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
|
||||
description: The X (bucket) and Y (count) axis scales.
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
|
||||
description: Stacking and percentile markers.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
description: Unit and decimal precision of the bucket bounds; counts take
|
||||
no unit.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement and series colors.
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the bucket counts are summed over.
|
||||
type: object
|
||||
DashboardtypesDistributionPercentiles:
|
||||
properties:
|
||||
enabled:
|
||||
description: Whether percentile markers are drawn. Defaults to true.
|
||||
type: boolean
|
||||
values:
|
||||
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
|
||||
Defaults to [50, 90, 99].
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- enabled
|
||||
- values
|
||||
type: object
|
||||
DashboardtypesDistributionXAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXScale'
|
||||
description: 'How bucket bounds are spaced: auto (default), linear, log,
|
||||
symlog, or ordinal (equal widths labelled by range).'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionXScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
- ordinal
|
||||
type: string
|
||||
DashboardtypesDistributionYAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
|
||||
description: 'How counts are spaced: linear (default) or log.'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionYScale:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
DashboardtypesDynamicVariableSignal:
|
||||
enum:
|
||||
- traces
|
||||
@@ -5249,6 +5350,7 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5271,6 +5373,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5285,6 +5388,7 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5316,6 +5420,21 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5469,9 +5588,9 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
|
||||
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
|
||||
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
|
||||
every default).'
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
|
||||
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
|
||||
options ({} accepts every default).'
|
||||
queries:
|
||||
description: The queries the panel runs. Use exactly one entry for a data
|
||||
panel and [] for signoz/TextPanel. To run several named queries or formulas,
|
||||
@@ -5607,8 +5726,8 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
description: 'The result shape the panel requests: time_series for time
|
||||
series, bar, area and histogram panels, scalar for number, table and pie
|
||||
panels, raw for list panels, heatmap for heatmap panels, trace for trace
|
||||
panels.'
|
||||
panels, raw for list panels, heatmap for heatmap panels, distribution
|
||||
for distribution panels, trace for trace panels.'
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
@@ -5765,9 +5884,9 @@ components:
|
||||
(builder query over AI observability traces), signoz/CompositeQuery (several
|
||||
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
|
||||
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
|
||||
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
|
||||
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
|
||||
and signoz/AIBuilderQuery.'
|
||||
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
|
||||
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
|
||||
signoz/BuilderQuery and signoz/AIBuilderQuery.'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
@@ -9562,9 +9681,9 @@ components:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
buckets:
|
||||
description: The ascending upper bounds of the value bands, set only for
|
||||
heatmap results; each point then carries one count per bound plus a trailing
|
||||
overflow count in values.
|
||||
description: The ascending upper bounds of the value buckets, set for heatmap
|
||||
and distribution results; each point then carries one count per bound
|
||||
plus a trailing overflow count in values.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -9719,6 +9838,56 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Distribution:
|
||||
properties:
|
||||
counts:
|
||||
description: One count per bound in meta.buckets plus a trailing overflow
|
||||
count, summed over every step of the requested time range.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
labels:
|
||||
description: The group-by field values that identify this distribution.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5DistributionAggregation:
|
||||
properties:
|
||||
alias:
|
||||
description: The alias of the aggregation, or the internal name __result_N,
|
||||
with N its index, when no alias was given.
|
||||
type: string
|
||||
distributions:
|
||||
description: One distribution per group-by combination.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
|
||||
nullable: true
|
||||
type: array
|
||||
index:
|
||||
description: The zero-based position of the aggregation in the query.
|
||||
type: integer
|
||||
meta:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
description: Display metadata for the aggregation, including the bucket
|
||||
upper bounds the counts are positional against.
|
||||
type: object
|
||||
Querybuildertypesv5DistributionData:
|
||||
properties:
|
||||
aggregations:
|
||||
description: One entry per aggregation of the query. The order is not guaranteed;
|
||||
match entries to aggregations by index.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
queryName:
|
||||
description: The name of the query that produced this result.
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5ExecStats:
|
||||
description: 'Execution cost of the request: rows and bytes scanned and execution
|
||||
time. A measure of cost, not of whether data exists.'
|
||||
@@ -10542,10 +10711,12 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
|
||||
properties:
|
||||
results:
|
||||
description: 'One result per enabled query, typed by the request type: TimeSeriesData
|
||||
for time_series, ScalarData for scalar and RawData for raw requests.'
|
||||
for time_series and heatmap, ScalarData for scalar, RawData for raw and
|
||||
DistributionData for distribution requests.'
|
||||
items: {}
|
||||
nullable: true
|
||||
type: array
|
||||
@@ -10735,8 +10906,9 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeResponse:
|
||||
description: 'Response from the v5 query range endpoint. The data.results array
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series,
|
||||
ScalarData for scalar, or RawData for raw requests.'
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series
|
||||
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
|
||||
distribution requests.'
|
||||
properties:
|
||||
data:
|
||||
allOf:
|
||||
@@ -10833,6 +11005,7 @@ components:
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
- distribution
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -10944,8 +11117,8 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
values:
|
||||
description: The per-band counts for heatmap results, in the order of meta.buckets
|
||||
plus a trailing overflow count.
|
||||
description: The per-bucket counts for heatmap results, in the order of
|
||||
meta.buckets plus a trailing overflow count.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -28287,12 +28460,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28301,15 +28468,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- VIEWER
|
||||
summary: Get rule history filter keys
|
||||
tags:
|
||||
- rules
|
||||
@@ -28477,12 +28638,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28491,15 +28646,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- VIEWER
|
||||
summary: Get rule history filter values
|
||||
tags:
|
||||
- rules
|
||||
@@ -28572,9 +28721,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get rule overall status timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28644,9 +28793,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get rule history stats
|
||||
tags:
|
||||
- rules
|
||||
@@ -28728,12 +28877,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28742,19 +28885,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
summary: Get rule history timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28819,12 +28952,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28833,19 +28960,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
summary: Get top contributors to rule firing
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -28,7 +28,7 @@ func ConvertConfig(src ExternalConfig) (InternalConfig, error)
|
||||
// Over this:
|
||||
type ConfigAdapter struct { ... }
|
||||
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
|
||||
func (adapter *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
```
|
||||
|
||||
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
|
||||
@@ -66,10 +66,10 @@ A method on a structured type should not strip meaning from the structure it bel
|
||||
|
||||
```go
|
||||
// Problematic: callers cannot distinguish how items were related.
|
||||
func (order *Order) AllLineItems() []LineItem { ... }
|
||||
func (o *Order) AllLineItems() []LineItem { ... }
|
||||
|
||||
// Better: the validation logic operates on the full structure.
|
||||
func ValidateOrder(order *Order) error { ... }
|
||||
func ValidateOrder(o *Order) error { ... }
|
||||
```
|
||||
|
||||
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.
|
||||
|
||||
@@ -74,7 +74,7 @@ The `*Visitor` passed in provides everything needed to build predicates. Use the
|
||||
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
|
||||
|
||||
```go
|
||||
func (resolver sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
switch key {
|
||||
case "created_by":
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)
|
||||
|
||||
@@ -82,7 +82,7 @@ In the module implementation, implement the new method. A typical implementation
|
||||
- Uses the `render` package to write responses or errors
|
||||
|
||||
```go
|
||||
func (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
// Extract authentication and organization context from req.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
@@ -98,7 +98,7 @@ func (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
// Call module functions
|
||||
out, err := handler.module.CreateThing(req.Context(), claims.OrgID, &in)
|
||||
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -59,8 +59,7 @@ Follow these rules:
|
||||
|
||||
### Unexported symbols
|
||||
|
||||
- Struct receivers: the lowercase name of the role the type plays, the same on every method of the type (`provider`, `module`, `handler`, `store`, `middleware`, `migration`). Never one or two characters: `c`, `h`, `m` say nothing at the call site and collide with local variables.
|
||||
- When the receiver name would shadow an imported package, alias the import, not the receiver (`pkghandler "github.com/SigNoz/signoz/pkg/http/handler"` with a `handler` receiver).
|
||||
- Struct receivers: one or two characters (`c`, `f`, `br`).
|
||||
- Helper functions: descriptive lowercase names (`parseToken`, `buildQuery`).
|
||||
|
||||
### Constants
|
||||
|
||||
@@ -70,16 +70,16 @@ The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but
|
||||
```go
|
||||
// pkg/instrumentation/sdk.go
|
||||
|
||||
func (sdk *SDK) Start(ctx context.Context) error {
|
||||
<-sdk.startCh
|
||||
func (i *SDK) Start(ctx context.Context) error {
|
||||
<-i.startCh
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sdk *SDK) Stop(ctx context.Context) error {
|
||||
close(sdk.startCh)
|
||||
func (i *SDK) Stop(ctx context.Context) error {
|
||||
close(i.startCh)
|
||||
return errors.Join(
|
||||
sdk.sdk.Shutdown(ctx),
|
||||
sdk.meterProviderShutdownFunc(ctx),
|
||||
i.sdk.Shutdown(ctx),
|
||||
i.meterProviderShutdownFunc(ctx),
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -197,8 +197,8 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error { ... }
|
||||
func (service *service) Stop(ctx context.Context) error { ... }
|
||||
func (s *service) Start(ctx context.Context) error { ... }
|
||||
func (s *service) Stop(ctx context.Context) error { ... }
|
||||
```
|
||||
|
||||
### Provider that is also a service
|
||||
|
||||
@@ -17,7 +17,7 @@ Two rules shape how the core type behaves:
|
||||
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
|
||||
|
||||
// Receiver form
|
||||
func (maintenance *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
```
|
||||
- **`X` can double as the storage row** when the DB shape would be identical. `Channel` embeds `bun.BaseModel` directly, and there is no `StorableChannel`. This is the preferred shape when it works.
|
||||
|
||||
@@ -175,10 +175,10 @@ The rules that make the envelope work:
|
||||
|
||||
```go
|
||||
// Domain invariants: every write path re-runs these.
|
||||
func (entity *X) Validate() error { ... }
|
||||
func (x *X) Validate() error { ... }
|
||||
|
||||
// Request-shape-only: checks that do not apply once the value is persisted.
|
||||
func (postable *PostableX) Validate() error { ... }
|
||||
func (p *PostableX) Validate() error { ... }
|
||||
```
|
||||
- **Type aliases, not wrappers**, when two shapes are identical. `type GettableChannels = []*Channel` is correct because it adds no semantics beyond the underlying type.
|
||||
- **Serialization tags** follow [handler.md](handler.md): `required:"true"` means the JSON key must be present, `nullable:"true"` is required on any slice or map that may serialize as `null`, and types with a fixed value set must implement `Enum() []any`.
|
||||
|
||||
@@ -2,6 +2,8 @@ package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
@@ -16,14 +18,29 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
|
||||
return &awscloudprovider{serviceDefinitions: defStore}, nil
|
||||
}
|
||||
|
||||
// TODO: move URL construction logic to cloudintegrationtypes and add unit tests for it.
|
||||
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
connectionArtifact, err := cloudintegrationtypes.NewAWSConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.AWS.DeploymentRegion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
|
||||
u, _ := url.Parse(baseURL)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("region", req.Config.AWS.DeploymentRegion)
|
||||
u.Fragment = "/stacks/quickcreate"
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
q = u.Query()
|
||||
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
|
||||
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
|
||||
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
|
||||
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
|
||||
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
|
||||
q.Set("param_SigNozAccountId", account.ID.StringValue())
|
||||
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
|
||||
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
|
||||
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
AWS: connectionArtifact,
|
||||
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
97
frontend/.cursor/rules/state-management.mdc
Normal file
97
frontend/.cursor/rules/state-management.mdc
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
globs: **/*.store.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
# State Management: React Query, nuqs, Zustand
|
||||
|
||||
Use the following stack. Do **not** introduce or recommend Redux or React Context for shared/global state.
|
||||
|
||||
## Server state → React Query
|
||||
|
||||
- **Use for:** API responses, time-series data, caching, background refetch, retries, stale/refresh.
|
||||
- **Do not use Redux/Context** to store or mirror data that comes from React Query (e.g. do not dispatch API results into Redux).
|
||||
- Prefer generated React Query hooks from `frontend/src/api/generated` when available.
|
||||
- Keep server state in React Query; expose it via hooks that return the query result (and optionally memoized derived values). Do not duplicate it in Redux or Context.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: single source of truth from React Query
|
||||
export function useAppStateHook() {
|
||||
const { data, isError } = useQuery(...)
|
||||
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
|
||||
return { configs: memoizedConfigs, isError, ... }
|
||||
}
|
||||
|
||||
// ❌ BAD: copying React Query result into Redux
|
||||
dispatch({ type: UPDATE_LATEST_VERSION, payload: queryResponse.data })
|
||||
```
|
||||
|
||||
## URL state → nuqs
|
||||
|
||||
- **Use for:** shareable state, filters, time range, selected values, pagination, view state that belongs in the URL.
|
||||
- **Do not use Redux/Context** for state that should be shareable or reflected in the URL.
|
||||
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed, type-safe URL search params. Avoid ad-hoc `useSearchParams` encoding/decoding.
|
||||
- Keep URL payload small; respect browser URL length limits (e.g. Chrome ~2k chars). Do not put large datasets or sensitive data in query params.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: nuqs for filters / time range / selection
|
||||
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
|
||||
|
||||
// ❌ BAD: Redux/Context for shareable or URL-synced state
|
||||
const { timeRange } = useContext(SomeContext)
|
||||
```
|
||||
|
||||
## Client state → Zustand
|
||||
|
||||
- **Use for:** global/client state, cross-component state, feature flags, complex or large client objects (e.g. dashboard state, query builder state).
|
||||
- **Do not use Redux or React Context** for global or feature-level client state.
|
||||
- Prefer small, domain-scoped stores (e.g. DashboardStore, QueryBuilderStore).
|
||||
|
||||
### Zustand best practices (align with eslint-plugin-zustand-rules)
|
||||
|
||||
- **One store per module.** Do not define multiple `create()` calls in the same file; use one store per module (or compose slices into one store).
|
||||
- **Always use selectors.** Call the store hook with a selector so only the used slice triggers re-renders. Never use `useStore()` with no selector.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: selector — re-renders only when isDashboardLocked changes
|
||||
const isLocked = useDashboardStore(state => state.isDashboardLocked)
|
||||
|
||||
// ❌ BAD: no selector — re-renders on any store change
|
||||
const state = useDashboardStore()
|
||||
```
|
||||
|
||||
- **Never mutate state directly.** Update only via `set` or `setState` (or `getState()` + `set` for reads). No `state.foo = x` or `state.bears += 1` inside actions.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: use set
|
||||
increment: () => set(state => ({ bears: state.bears + 1 }))
|
||||
|
||||
// ❌ BAD: direct mutation
|
||||
increment: () => { state.bears += 1 }
|
||||
```
|
||||
|
||||
- **State properties before actions.** In the store object, list all state fields first, then action functions.
|
||||
- **Split into slices when state is large.** If a store has many top-level properties (e.g. more than 5–10), split into slice factories and combine with one `create()`.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: slices for large state
|
||||
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
|
||||
const createFishSlice = set => ({ fish: 0, addFish: () => set(s => ({ fish: s.fish + 1 })) })
|
||||
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
|
||||
```
|
||||
|
||||
- **In projects using Zustand:** add `eslint-plugin-zustand-rules` and extend `plugin:zustand-rules/recommended` to enforce these rules automatically.
|
||||
|
||||
## Local state → React state only
|
||||
|
||||
- **Use useState/useReducer** for: component-local UI state, form inputs, toggles, hover state, data that never leaves the component.
|
||||
- Do not use Zustand, Redux, or Context for state that is purely local to one component or a small subtree.
|
||||
|
||||
## Summary
|
||||
|
||||
| State type | Use | Avoid |
|
||||
|-------------------|------------------|--------------------|
|
||||
| Server / API | React Query | Redux, Context |
|
||||
| URL / shareable | nuqs | Redux, Context |
|
||||
| Global client | Zustand | Redux, Context |
|
||||
| Local UI | useState/useReducer | Zustand, Redux, Context |
|
||||
74
frontend/.cursor/rules/testing-conventions.mdc
Normal file
74
frontend/.cursor/rules/testing-conventions.mdc
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
description: Core testing conventions - imports, rendering, MSW, interactions, queries
|
||||
globs: **/*.test.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Testing Conventions
|
||||
|
||||
## Imports
|
||||
|
||||
Always import from the test harness, never directly from `@testing-library/react`:
|
||||
|
||||
```ts
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
```
|
||||
|
||||
## Router
|
||||
|
||||
Use the built-in router in `render`:
|
||||
|
||||
```ts
|
||||
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
|
||||
```
|
||||
|
||||
Only mock `useLocation` / `useParams` if the test depends on their values.
|
||||
|
||||
## MSW
|
||||
|
||||
Global MSW server runs automatically. Override per-test:
|
||||
|
||||
```ts
|
||||
server.use(
|
||||
rest.get('*/api/v1/foo', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ ok: true })))
|
||||
);
|
||||
```
|
||||
|
||||
Keep large response fixtures in `mocks-server/__mockdata_`.
|
||||
|
||||
## Interactions
|
||||
|
||||
- Prefer `userEvent` for real user interactions (click, type, select, tab).
|
||||
- Use `fireEvent` only for low-level events not covered by `userEvent` (e.g., scroll, resize). Wrap in `act(...)` if needed.
|
||||
- Always `await` interactions:
|
||||
|
||||
```ts
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
```
|
||||
|
||||
## Timers
|
||||
|
||||
No global fake timers. Per-test only, for debounce/throttle:
|
||||
|
||||
```ts
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
|
||||
await user.type(screen.getByRole('textbox'), 'query');
|
||||
jest.advanceTimersByTime(400);
|
||||
jest.useRealTimers();
|
||||
```
|
||||
|
||||
## Queries
|
||||
|
||||
Prefer accessible queries: `getByRole` > `findByRole` > `getByLabelText` > visible text > `data-testid` (last resort).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never import from `@testing-library/react` directly
|
||||
- Never use global fake timers
|
||||
- Never wrap `render` in `act(...)`
|
||||
- Never mock infra dependencies locally (router, react-query)
|
||||
- Limit to 3-5 focused tests per file
|
||||
54
frontend/.cursor/rules/testing-mock-strategy.mdc
Normal file
54
frontend/.cursor/rules/testing-mock-strategy.mdc
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: When to use global vs local mocks in tests
|
||||
globs: **/*.test.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Mock Strategy
|
||||
|
||||
## Use Global Mocks For
|
||||
|
||||
High-frequency dependencies (20+ test files):
|
||||
- Core infrastructure: react-router-dom, react-query, antd
|
||||
- Browser APIs: ResizeObserver, matchMedia, localStorage
|
||||
- Utility libraries: date-fns, lodash
|
||||
|
||||
Available global mock files (from jest.config.ts):
|
||||
- `uplot` -> `__mocks__/uplotMock.ts`
|
||||
|
||||
## Use Local Mocks For
|
||||
|
||||
- Business logic dependencies (API endpoints, custom hooks, domain components)
|
||||
- Test-specific behavior (different data per test, error scenarios, loading states)
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Used in 20+ test files?
|
||||
YES -> Global mock
|
||||
NO -> Business logic or test-specific?
|
||||
YES -> Local mock
|
||||
NO -> Consider global if usage grows
|
||||
```
|
||||
|
||||
## Correct Usage
|
||||
|
||||
```ts
|
||||
// Global mocks are already available - just import
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Local mocks for business logic
|
||||
jest.mock('../api/tracesService', () => ({
|
||||
getTraces: jest.fn(() => mockTracesData),
|
||||
}));
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
```ts
|
||||
// Never re-mock globally mocked dependencies locally
|
||||
jest.mock('react-router-dom', () => ({ ... }));
|
||||
|
||||
// Never put test-specific data in global mocks
|
||||
jest.mock('../api/tracesService', () => ({ getTraces: jest.fn(() => specificTestData) }));
|
||||
```
|
||||
54
frontend/.cursor/rules/testing-type-safety.mdc
Normal file
54
frontend/.cursor/rules/testing-type-safety.mdc
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: TypeScript type safety requirements for Jest tests
|
||||
globs: **/*.test.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# TypeScript Type Safety in Tests
|
||||
|
||||
All Jest tests must be fully type-safe. Never use `any`.
|
||||
|
||||
## Mock Function Typing
|
||||
|
||||
```ts
|
||||
// Use jest.mocked for module mocks
|
||||
import useFoo from 'hooks/useFoo';
|
||||
jest.mock('hooks/useFoo');
|
||||
const mockUseFoo = jest.mocked(useFoo);
|
||||
|
||||
// Use jest.MockedFunction for standalone mocks
|
||||
const mockFetch = jest.fn() as jest.MockedFunction<(id: number) => Promise<User>>;
|
||||
```
|
||||
|
||||
## Mock Data
|
||||
|
||||
Define interfaces for all mock data:
|
||||
|
||||
```ts
|
||||
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
|
||||
const mockProps: ComponentProps = {
|
||||
title: 'Test',
|
||||
data: [mockUser],
|
||||
onSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
|
||||
};
|
||||
```
|
||||
|
||||
## Hook Mocking Pattern
|
||||
|
||||
```ts
|
||||
import useFoo from 'hooks/useFoo';
|
||||
jest.mock('hooks/useFoo');
|
||||
const mockUseFoo = jest.mocked(useFoo);
|
||||
mockUseFoo.mockReturnValue(/* minimal shape */);
|
||||
```
|
||||
|
||||
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
|
||||
|
||||
## Checklist
|
||||
|
||||
- All mock functions use `jest.MockedFunction<T>` or `jest.mocked()`
|
||||
- All mock data has proper interfaces
|
||||
- No `any` types in test files
|
||||
- Component props are typed
|
||||
- API response types are defined
|
||||
19
frontend/.cursor/rules/ui-components-and-icons.mdc
Normal file
19
frontend/.cursor/rules/ui-components-and-icons.mdc
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
description: Prefer SigNoz UI and icons across frontend code
|
||||
globs: **/*.{ts,tsx,js,jsx}
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# UI Components and Icons Source of Truth
|
||||
|
||||
For all frontend implementation work in this repository:
|
||||
|
||||
- Always use UI primitives/components from `@signozhq/ui`.
|
||||
- Always use icons from `@signozhq/icons`.
|
||||
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
|
||||
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
|
||||
|
||||
## Migration guidance
|
||||
|
||||
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
|
||||
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.
|
||||
@@ -146,5 +146,5 @@ Do not use Zustand, Redux, or Context for purely local UI state.
|
||||
|
||||
## Additional resources
|
||||
|
||||
- Project rule: [.claude/rules/frontend-state.md](../../../../.claude/rules/frontend-state.md)
|
||||
- Project rule: [.cursor/rules/state-management.mdc](../../rules/state-management.mdc)
|
||||
- Detailed patterns and rationale: [reference.md](reference.md)
|
||||
@@ -45,8 +45,8 @@ const mockAliases = [
|
||||
},
|
||||
{
|
||||
// jest: not replaced, a test asserts on the toast it fires.
|
||||
find: /^@signozhq\/ui\/toast$/,
|
||||
replacement: `${srcPath}/storybook/mocks/toast.mock.tsx`,
|
||||
find: /^@signozhq\/ui\/sonner$/,
|
||||
replacement: `${srcPath}/storybook/mocks/sonner.mock.tsx`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -143,12 +143,7 @@ const preview: Preview = {
|
||||
'Logs',
|
||||
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
|
||||
'Traces',
|
||||
[
|
||||
'Explorer',
|
||||
['Overview', 'Span Drawer'],
|
||||
'Trace Details',
|
||||
'Funnel Details',
|
||||
],
|
||||
['Explorer', 'Trace Details', 'Funnel Details'],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
|
||||
@@ -25,24 +25,15 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- UI primitives come from `@signozhq/ui` (subpath imports) and icons from `@signozhq/icons`. No new `antd` component or direct icon library (`lucide-react`) where `@signozhq/ui` has an equivalent; `signoz/no-antd-components` enforces part of this list.
|
||||
- Exception: a control added beside `antd` siblings of the same kind (a tab in an `antd` tab strip) matches them until that surface migrates. Say so in the PR.
|
||||
- When `@signozhq/ui` or `@signozhq/icons` lacks what you need, say so in the PR before reaching for another library.
|
||||
- Before styling a `@signozhq/ui` component, check its props in `node_modules/@signozhq/ui/dist/<component>/*.d.ts` for `className`/`style` support; some omit them. Avoid overriding the component's CSS vars unless explicitly needed.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
- A flow that gains a request also gates on that request's permission: a rename that now reads the view first needs `read` as well as `update`.
|
||||
- Identity keys (legend groups, color groups, CSV headers, React keys) come from structured values, never from joined display text: `['us, east', 'prod']` and `['us', 'east, prod']` both join to `us, east, prod`.
|
||||
- A payload saved under another kind or schema copies only the fields that schema accepts, and drops bindings to columns the user removed. The server rejects unknown or stale fields, so the preview works and the save fails.
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. Run these checks once all changes of a task are done, never after each edit. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
- Run `pnpm lint:js --quiet` to find critical errors
|
||||
- Run `pnpm oxlint <file1> <file2>` and fix all warnings
|
||||
- Run `pnpm build`
|
||||
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run. Run them before coding the change or after all changes, never in between.
|
||||
- For e2e, run only the specs you added or changed: the stack is heavy. Leave the full jest and e2e suites to CI unless the user asks.
|
||||
- For every test you added or changed, revert the change it covers and confirm the test fails
|
||||
- On a stacked branch (`gh stack`), run `pnpm tsgo --noEmit` on every branch above it before pushing: a renamed prop or moved export breaks them
|
||||
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run.
|
||||
- Fixed ALL resulting errors
|
||||
|
||||
4. BEHAVIOR CHANGE DETECTION: When modifying existing behavior:
|
||||
@@ -50,14 +41,6 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Update test assertions to match new behavior
|
||||
- If no tests exist, add them BEFORE changing behavior
|
||||
|
||||
## Repo Rules
|
||||
|
||||
Claude Code loads `../.claude/rules/` on its own. Other agents read the matching file before editing:
|
||||
- Every file: [comments](../.claude/rules/comments.md), [pull requests](../.claude/rules/pull-requests.md)
|
||||
- `src/**/*.ts(x)` that holds state, a query, URL params or a form: [frontend state](../.claude/rules/frontend-state.md)
|
||||
- `*.test.ts(x)`: [frontend tests](../.claude/rules/frontend-tests.md)
|
||||
- `*.tsx` that changes what a page renders: [storybook](../.claude/rules/storybook.md)
|
||||
|
||||
## Context Management
|
||||
|
||||
1. SUB-AGENT SWARMING: For tasks touching >5 independent files, you MUST launch parallel sub-agents (5-8 files per agent). Each agent gets its own context window. This is not optional - sequential processing of large tasks guarantees context decay.
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# Popover migration analysis
|
||||
|
||||
The new `@signozhq/ui` Popover is click-only. This note maps the current app onto that API so a later migration does not break open state, focus, or the global `.ant-popover` overrides. It does not change any call site.
|
||||
|
||||
The component this describes lives in the design-system repo (`packages/ui/src/popover`). Storybook stories for it are `Playground`, `SaveViewForm`, `ControlledOpen`, `InsideDialog`, `MaxHeightScrolling`, and `AnchoredTypeahead`.
|
||||
|
||||
## The API to migrate onto
|
||||
|
||||
```tsx
|
||||
<Popover
|
||||
nativeButton
|
||||
side="bottom"
|
||||
align="start"
|
||||
trigger={<button type="button">Open</button>}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
{content}
|
||||
</Popover>
|
||||
```
|
||||
|
||||
| Today | New Popover |
|
||||
| --- | --- |
|
||||
| antd `children` (the trigger) | `trigger` (required) |
|
||||
| antd `content`, or Radix `<PopoverContent>` | `children` |
|
||||
| antd `trigger="click"` | the only mode; there is no `openOn` |
|
||||
| antd `trigger="hover"` | not this component; a Tooltip, or a click popover when the body is interactive |
|
||||
| `placement="bottomRight"` | `side="bottom"` `align="end"` |
|
||||
| `placement="bottomLeft"` | `side="bottom"` `align="start"` |
|
||||
| `open` / `onOpenChange` | kept; `onOpenChange` receives Base UI's reason and `details.cancel()` |
|
||||
| `defaultOpen` | removed; nothing in the app starts open |
|
||||
| `overlayClassName`, `overlayStyle`, `overlayInnerStyle`, `className` | rejected on the shell; size is `width` / `maxWidth` / `maxHeight` |
|
||||
| `getPopupContainer` | `container` |
|
||||
| `destroyTooltipOnHide` | the only behavior; the popup unmounts on close |
|
||||
| `arrow` / `showArrow` | rejected; the shell has no arrow |
|
||||
| Radix `PopoverAnchor` | `anchor`, and the trigger is still required |
|
||||
| Radix `withPortal={false}` | `container` (inside a Dialog the popup already portals into the panel) |
|
||||
| Radix `onInteractOutside` | `onOpenChange` + `details.cancel()` when `reason === 'outside-press'` |
|
||||
| `sideOffset` / `alignOffset` | not props; fixed at 4px and 0 |
|
||||
|
||||
`nativeButton` is required. `true` when `trigger` renders a `<button>` (including `Button`). `false` for anything else.
|
||||
|
||||
A node that renders nothing (`null`, `false`, `''`) renders the trigger alone, with no listeners and no portal.
|
||||
|
||||
## What is in the app today
|
||||
|
||||
Counted from `frontend/src` on this branch:
|
||||
|
||||
- **29 files** import `Popover` from `antd`.
|
||||
- **7 files** import `Popover` from `@signozhq/ui` (the Radix wrapper this rework replaces). Those seven break as soon as the package ships, because `children` stops being the trigger.
|
||||
- **18 SCSS files** override `.ant-popover*`.
|
||||
|
||||
antd `Dropdown` is already on `@signozhq/ui/dropdown`. antd `Tooltip` (on the order of 100 importers) is a separate migration.
|
||||
|
||||
### antd files
|
||||
|
||||
`src/components/CustomTimePicker/CustomTimePicker.tsx`
|
||||
`src/components/DownloadOptionsMenu/DownloadOptionsMenu.tsx`
|
||||
`src/components/ErrorPopover/ErrorPopover.tsx`
|
||||
`src/components/ExplorerCard/ExplorerCard.tsx`
|
||||
`src/components/HeaderRightSection/HeaderRightSection.tsx`
|
||||
`src/components/Logs/AddToQueryHOC.tsx`
|
||||
`src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx`
|
||||
`src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx`
|
||||
`src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx`
|
||||
`src/components/QueryBuilderV2/QueryV2/TraceOperator/TraceOperatorEditor.tsx`
|
||||
`src/components/TableRenderer/LabelColumn.tsx`
|
||||
`src/components/WarningPopover/WarningPopover.tsx`
|
||||
`src/container/AlertHistory/AlertPopover/AlertPopover.tsx`
|
||||
`src/container/CreateAlertV2/EvaluationSettings/EvaluationSettings.tsx`
|
||||
`src/container/Home/Home.tsx`
|
||||
`src/container/MetricsExplorer/MetricDetails/AllAttributes.tsx`
|
||||
`src/container/MetricsExplorer/MetricDetails/AllAttributesValue.tsx`
|
||||
`src/container/MetricsExplorer/Summary/MetricNameSearch.tsx`
|
||||
`src/container/OptionsMenu/index.tsx`
|
||||
`src/container/TopNav/AutoRefreshV2/index.tsx`
|
||||
`src/pages/DashboardsListPage/components/ListHeader/ListHeader.tsx`
|
||||
`src/pages/MetricsApplication/ApDex/ApDexApplication.tsx`
|
||||
`src/pages/PublicDashboard/PublicDashboardView/PublicAutoRefresh/PublicAutoRefresh.tsx`
|
||||
`src/pages/TraceDetailsV3/SpanDetailsPanel/Events/components/AttributeWithExpandablePopover.tsx`
|
||||
`src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelStepPopover.tsx`
|
||||
`src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx`
|
||||
`src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx`
|
||||
`src/periscope/components/ContextMenu/index.tsx`
|
||||
`src/periscope/components/SeeMore/SeeMore.tsx`
|
||||
|
||||
### Existing `@signozhq/ui` popovers
|
||||
|
||||
`src/components/ExportMenu/ExportMenu.tsx`
|
||||
`src/components/MarkdownEditor/MarkdownHelp.tsx`
|
||||
`src/components/TanStackTableView/TanStackHeaderRow.tsx`
|
||||
`src/container/AIAssistant/components/ChatInput/ChatInput.tsx`
|
||||
`src/container/ListAlertRules/components/ColumnSelector.tsx`
|
||||
`src/pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/sections/ContextLinksSection/VariablesPopover.tsx`
|
||||
`src/pages/DashboardsListPage/components/ViewsRail/ViewNamePopover.tsx`
|
||||
|
||||
### SCSS that overrides `.ant-popover`
|
||||
|
||||
`src/components/DownloadOptionsMenu/DownloadOptionsMenu.styles.scss`
|
||||
`src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.styles.scss`
|
||||
`src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregation.styles.scss`
|
||||
`src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.styles.scss`
|
||||
`src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss`
|
||||
`src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss`
|
||||
`src/container/CreateAlertV2/EvaluationSettings/styles.scss`
|
||||
`src/container/Home/Home.styles.scss`
|
||||
`src/container/MetricsExplorer/MetricDetails/MetricDetails.styles.scss`
|
||||
`src/container/MetricsExplorer/Summary/Summary.styles.scss`
|
||||
`src/container/TopNav/AutoRefreshV2/AutoRefreshV2.styles.scss`
|
||||
`src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss`
|
||||
`src/container/WidgetCard/WidgetCard.styles.scss`
|
||||
`src/pages/DashboardsListPage/components/ListHeader/ListHeader.module.scss`
|
||||
`src/pages/TracesFunnels/TracesFunnels.styles.scss`
|
||||
`src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss`
|
||||
`src/pages/TracesFunnels/components/SearchBar/SearchBar.styles.scss`
|
||||
`src/periscope/components/ContextMenu/styles.scss`
|
||||
|
||||
Three of these are unscoped and restyle every antd popover on the page, not just their own:
|
||||
|
||||
- `src/container/MetricsExplorer/Summary/Summary.styles.scss`
|
||||
- `src/container/CreateAlertV2/EvaluationSettings/styles.scss`
|
||||
- `src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss`
|
||||
|
||||
Scope or delete those three before moving any nearby popover, or a migrated popup and a leftover antd popup will share rules that only one of them understands.
|
||||
|
||||
Most of the other files repeat one recipe: padding 0, 4px radius, a 1px border, a translucent background, the arrow hidden. The new shell already has zero padding, `--radius-2`, and no arrow. Per-site size (fixed widths around 187–500px, max-width 600px, max-height 254px) becomes `width` / `maxWidth` / `maxHeight`. Do not re-apply the recipe as a `className`; the component rejects it.
|
||||
|
||||
## What should not become this Popover
|
||||
|
||||
**Hover descriptions.** `AddToQueryHOC`, `LabelColumn`, the three Query Builder validation popovers, `SeeMore`, and `AttributeWithExpandablePopover` open on hover. Those are Tooltips. `AttributeWithExpandablePopover` shows a value the user copies; if Tooltip cannot hold that, make it a click popover instead of keeping hover.
|
||||
|
||||
**WarningPopover** (imported from about 10 files) is hover today and contains docs links. A tooltip cannot host those links. Either the links move out and the summary becomes a Tooltip, or the wrapper becomes a click Popover with `maxWidth` around 600.
|
||||
|
||||
**Menus.** Funnel item and funnel step action lists, the sort lists in `ListHeader` and Traces Funnels `SearchBar`, `AutoRefreshV2` / `PublicAutoRefresh` interval lists, and `AlertPopover`'s two links are rows that close when one is picked. Those are Dropdowns. `AllAttributes` per-row actions are the same shape.
|
||||
|
||||
**ContextMenu** (`src/periscope/components/ContextMenu/index.tsx`, imported across the dashboard drilldown) opens at chart-click coordinates. There is no trigger element. The new Popover requires `trigger`, so this stays a context menu. Do not fold the phantom span, `position: fixed` overlay, `zIndex: 10000`, or `calculatePopoverPosition.ts` into `anchor`. `anchor` is for a popover that already has a trigger and should position against something else (the typeahead input row).
|
||||
|
||||
**Inspect charts.** `MetricsExplorer/Inspect` `GraphPopover` and `HoverPopover` are absolutely positioned cards, not antd Popover. Leave them until there is a context-menu or anchor design that matches them.
|
||||
|
||||
**PanelStatusPopover** is a Tooltip despite the name.
|
||||
|
||||
## What should become this Popover
|
||||
|
||||
Click panels whose content is a form, a picker, or a composed layout:
|
||||
|
||||
- `CustomTimePicker` — the most visible one. Controlled `open`, `container` for `getPopupContainer`, and it already unmounts on hide. It opens from both input focus and click; that logic stays in the caller, with `onOpenChange`.
|
||||
- `EvaluationSettings` / `EvaluationWindowPopover` — controlled, wide (`width` / `maxWidth`), and sitting on a global `.ant-popover` override. Migrate the override in the same change.
|
||||
- `HeaderRightSection` — three controlled forms (feedback, announcements, share).
|
||||
- `ExplorerCard` save-view form and `ViewNamePopover` — close only after the save succeeds, so `open` stays controlled. `ViewNamePopover` is already on the library and will not compile against the new props.
|
||||
- `ApDex` threshold form.
|
||||
- `Home` welcome checklist — drop the transparent-shell override; the shell is the component's.
|
||||
- `OptionsMenu`, `DownloadOptionsMenu`, `LogsFormatOptionsMenu` — click panels. `LogsFormatOptionsMenu` nests a submenu; the inner popup must be portalled, and the outer `onOpenChange` must `cancel()` an outside press that lands in the inner popup.
|
||||
- `MetricNameSearch` — controlled search panel. Scope the global padding override in `Summary.styles.scss` in the same change.
|
||||
- `AllAttributesValue` — one popover open at a time in a list. That is controlled `open` keyed by row, which the API still allows.
|
||||
- `ChatInput` context picker — already on the library; `side="top"` `align="end"` plus `width`.
|
||||
- `VariablesPopover` — already on the library. Keep `anchor` on the input row, `initialFocus={false}` so the caret stays, and `details.cancel()` when the outside press is the anchor. `withPortal={false}` becomes the default dialog `container`, not an unportalled popup.
|
||||
- `ColumnSelector`, `MarkdownHelp`, `TanStackHeaderRow`, `ExportMenu` — Radix `asChild` trigger plus content. The trigger moves to the `trigger` prop, content moves to `children`, and `className` on the shell becomes `width` / `maxWidth`.
|
||||
|
||||
`ErrorPopover` is a thin wrapper. Re-point it at the new props and its consumer follows.
|
||||
|
||||
## Order
|
||||
|
||||
Before any change: screenshot every current popover, in both themes, so each call site's pattern is on record and a component swap is reviewable against it. Flag the misusages to design as they turn up — links inside hover surfaces above all. Swapping the component during migration is expected where the old call site was on the wrong one; antd call sites stay untouched until their turn.
|
||||
|
||||
1. Scope the three global `.ant-popover` files so later migrations cannot restyle leftover antd popovers.
|
||||
2. Move the seven existing `@signozhq/ui` call sites first. They break at the package bump; the 29 antd files do not, until each one is switched.
|
||||
3. Migrate shared wrappers that are actually popovers (`ErrorPopover`, then `WarningPopover` once the links decision is made). Leave `ContextMenu` on antd until a context menu exists.
|
||||
4. Migrate click forms (`CustomTimePicker`, evaluation window, header menus, save-view, ApDex).
|
||||
5. Send hover descriptions to Tooltip and row menus to Dropdown, instead of forcing them through Popover.
|
||||
6. Delete the matching `.ant-popover` rules in the same change as each call site, so a half-migrated page is not styled twice.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Controlled `open` is load-bearing.** Save forms close from inside the popup only when the mutation succeeds. A list keeps one popover open at a time. Dropping `open` and driving everything from `onOpenChange` alone cannot express either. Keep the controlled prop at those sites.
|
||||
- **Focus moves in.** Click popovers focus the first tabbable child. Typeaheads (`VariablesPopover`, and any filter that must keep the caret) need `initialFocus={false}`.
|
||||
- **Outside press and anchors.** A press on the anchor looks outside. Call `details.cancel()` or the typeahead closes when the user clicks the input it is anchored to.
|
||||
- **Dialogs.** Inside a Dialog or Drawer the popup portals into the panel so the focus trap can reach it. Do not set `container={document.body}` there.
|
||||
- **Z-index.** The shell starts at `--popover-z-index` (50) and lifts above the layer its trigger sits in. A Tooltip inside a row must still paint above the popup. The app ladder today is tooltip 1070, dropdown 1050, popover 1030, modal 1000. Check a truncated row tooltip inside a popover after the first real migration.
|
||||
- **Scroll locking.** Chart popovers measure the wrong box when a modal has locked scroll. This popover does not lock scroll (`modal` is rejected). Re-check Infrastructure Monitoring charts if a surrounding modal is open.
|
||||
- **`nativeButton` mismatches.** Base UI logs an error when the flag does not match the element. Every non-button trigger (a badge, a custom row, an input) needs `nativeButton={false}`.
|
||||
|
||||
## `--popover-foreground` audit
|
||||
|
||||
The shell reads `--popover-background`, `--popover-foreground`, `--popover-border`, and `--popover-shadow`. Those semantic values belong to the token package, not to call sites.
|
||||
|
||||
Two legacy shadcn variables predate those hooks:
|
||||
|
||||
- `--popover-foreground` is the foreground hook the shell uses. It should stay pointed at the same foreground as the rest of the light and dark themes (`--l1-foreground` today).
|
||||
- `--popover` (no suffix) is the old background. `--calendar-select-background` still reads it. Do not repoint `--popover` at `--popover-background` or at `surface-3` until the calendar token is audited, or date selection changes color with the popover shell.
|
||||
|
||||
Call sites that pass antd's `color` (a gradient string on `AlertPopover`, a raw token on `SeeMore`) do not map to a prop. The shell color comes from the tokens. Content that needs its own background paints it inside `children`.
|
||||
@@ -53,9 +53,9 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.3.0",
|
||||
"@signozhq/design-tokens": "2.2.1",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/ui": "0.3.0",
|
||||
"@signozhq/ui": "0.2.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -80,7 +80,7 @@
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.4.17",
|
||||
"dompurify": "3.4.15",
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
@@ -200,17 +200,17 @@
|
||||
"oxfmt": "0.73.0",
|
||||
"oxlint": "1.88.0",
|
||||
"oxlint-tsgolint": "7.0.2004",
|
||||
"postcss": "8.5.32",
|
||||
"postcss": "8.5.26",
|
||||
"postcss-scss": "4.0.9",
|
||||
"react-resizable": "3.0.4",
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.35.5",
|
||||
"sharp": "0.35.4",
|
||||
"storybook": "10.6.3",
|
||||
"stylelint": "17.15.0",
|
||||
"svgo": "4.1.0",
|
||||
"ts-jest": "29.4.15",
|
||||
"typescript-plugin-css-modules": "5.3.0",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
|
||||
915
frontend/pnpm-lock.yaml
generated
915
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -37,9 +37,9 @@ overrides:
|
||||
cookie@<0.7.0: '>=0.7.1 <1'
|
||||
# via: @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
|
||||
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
|
||||
# remove: blocked. The direct dep is already on 3.4.17, but @grafana/data
|
||||
# remove: blocked. The direct dep is already on 3.4.15, but @grafana/data
|
||||
# (latest 13.1.0) and monaco-editor (latest 0.55.1) still pin vulnerable versions
|
||||
dompurify@<=3.4.16: '>=3.4.17 <4'
|
||||
dompurify@<=3.4.12: '>=3.4.13 <4'
|
||||
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); ts-jest@29.4.9 (~0.27.4)
|
||||
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
|
||||
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
|
||||
@@ -60,10 +60,17 @@ overrides:
|
||||
# @commitlint/cli > @commitlint/load > cosmiconfig@9 (^4.1.0)
|
||||
# remove: blocked, both consumers cap js-yaml at ^4 and only 4.3.2 carries the fix
|
||||
'js-yaml@>=4.0.0 <4.3.2': '>=4.3.2 <5'
|
||||
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
|
||||
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
|
||||
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
|
||||
less@<4.5.0: '>=4.5.0 <5'
|
||||
# via: direct dep postcss (nanoid ^3.3.17)
|
||||
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
|
||||
nanoid@<3.3.18: '>=3.3.18 <4'
|
||||
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
|
||||
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
|
||||
prismjs@<1.30.0: '>=1.30.0 <2'
|
||||
# via: typescript-plugin-css-modules@5.3.0 > postcss-modules-local-by-default@4.2.0
|
||||
# via: typescript-plugin-css-modules@5.2.0 > postcss-modules-local-by-default@4.2.0
|
||||
# (postcss-selector-parser ^7.0.0)
|
||||
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
|
||||
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
|
||||
|
||||
@@ -3936,7 +3936,7 @@ export interface CloudintegrationtypesAWSConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
connectionUrl: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
|
||||
@@ -4146,6 +4146,10 @@ export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cloudPowerShellCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
|
||||
@@ -6917,6 +6921,108 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel kind.
|
||||
*/
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
export enum DashboardtypesDistributionXScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
ordinal = 'ordinal',
|
||||
}
|
||||
export interface DashboardtypesDistributionXAxisDTO {
|
||||
/**
|
||||
* @description How bucket bounds are spaced: auto (default), linear, log, symlog, or ordinal (equal widths labelled by range).
|
||||
*/
|
||||
scale: DashboardtypesDistributionXScaleDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDistributionYScaleDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export interface DashboardtypesDistributionYAxisDTO {
|
||||
/**
|
||||
* @description How counts are spaced: linear (default) or log.
|
||||
*/
|
||||
scale: DashboardtypesDistributionYScaleDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionAxesDTO {
|
||||
/**
|
||||
* @description The bucket axis.
|
||||
*/
|
||||
x?: DashboardtypesDistributionXAxisDTO;
|
||||
/**
|
||||
* @description The count axis.
|
||||
*/
|
||||
y?: DashboardtypesDistributionYAxisDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPercentilesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Whether percentile markers are drawn. Defaults to true.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99].
|
||||
*/
|
||||
values: number[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionChartAppearanceDTO {
|
||||
/**
|
||||
* @description Percentile markers drawn across the bucket axis.
|
||||
*/
|
||||
percentiles?: DashboardtypesDistributionPercentilesDTO;
|
||||
/**
|
||||
* @description How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket.
|
||||
*/
|
||||
stack: DashboardtypesStackModeDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @description The X (bucket) and Y (count) axis scales.
|
||||
*/
|
||||
axes?: DashboardtypesDistributionAxesDTO;
|
||||
/**
|
||||
* @description Stacking and percentile markers.
|
||||
*/
|
||||
chartAppearance?: DashboardtypesDistributionChartAppearanceDTO;
|
||||
/**
|
||||
* @description Unit and decimal precision of the bucket bounds; counts take no unit.
|
||||
*/
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
/**
|
||||
* @description Legend placement and series colors.
|
||||
*/
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @description The time range the bucket counts are summed over.
|
||||
*/
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/DistributionPanel
|
||||
* @type string
|
||||
* @description The panel kind.
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind;
|
||||
/**
|
||||
* @description The options for this panel kind. {} accepts every default.
|
||||
*/
|
||||
spec: DashboardtypesDistributionPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -6928,7 +7034,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6937,6 +7044,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
distribution = 'distribution',
|
||||
}
|
||||
/**
|
||||
* The query kind.
|
||||
@@ -7411,14 +7519,14 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
*/
|
||||
plugin: DashboardtypesQueryPluginDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
/**
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels.
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels.
|
||||
*/
|
||||
kind: Querybuildertypesv5RequestTypeDTO;
|
||||
/**
|
||||
@@ -7438,7 +7546,7 @@ export interface DashboardtypesPanelSpecDTO {
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
/**
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default).
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default).
|
||||
*/
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
@@ -8209,6 +8317,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
/**
|
||||
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
|
||||
@@ -10973,7 +11082,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
value?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
*/
|
||||
values?: number[];
|
||||
}
|
||||
@@ -11594,7 +11703,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
@@ -11715,6 +11824,54 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
|
||||
*/
|
||||
counts?: number[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @description The group-by field values that identify this distribution.
|
||||
*/
|
||||
labels?: Querybuildertypesv5LabelDTO[];
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One distribution per group-by combination.
|
||||
*/
|
||||
distributions?: Querybuildertypesv5DistributionDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The zero-based position of the aggregation in the query.
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5DistributionAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the query that produced this result.
|
||||
*/
|
||||
queryName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step interval in seconds that was used for each query, keyed by query name.
|
||||
*/
|
||||
@@ -11970,21 +12127,28 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
| (Querybuildertypesv5TimeSeriesDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5ScalarDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5RawDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5DistributionDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
@@ -12118,7 +12282,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { extractOnlyMessageBody } from 'lib/logs/logBody';
|
||||
import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BuilderQuery,
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
@@ -282,6 +280,19 @@ function convertScalarWithFormatForWeb(
|
||||
});
|
||||
}
|
||||
|
||||
function extractOnlyMessageBody(body: unknown): unknown {
|
||||
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
|
||||
if (isJsonBody) {
|
||||
const keys = Object.keys(body);
|
||||
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
|
||||
if (hasOnlyMessageKey) {
|
||||
const { message } = body as { message: unknown };
|
||||
return typeof message === 'string' ? message : JSON.stringify(message);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 RawData to legacy format
|
||||
*/
|
||||
@@ -314,20 +325,6 @@ function convertRawData(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 DistributionData to legacy format
|
||||
*/
|
||||
function convertDistributionData(
|
||||
distributionData: DistributionData,
|
||||
legendMap: Record<string, string>,
|
||||
): any {
|
||||
// Convert V5 distribution format to legacy histogram format
|
||||
return {
|
||||
...distributionData,
|
||||
legendMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert V5 data based on type
|
||||
*/
|
||||
@@ -375,15 +372,6 @@ function convertV5DataByType(
|
||||
result: traceData.map((trace) => convertRawData(trace, legendMap)),
|
||||
};
|
||||
}
|
||||
case 'distribution': {
|
||||
const distributionData = v5Data.data.results as DistributionData[];
|
||||
return {
|
||||
resultType: 'distribution',
|
||||
result: distributionData.map((distribution) =>
|
||||
convertDistributionData(distribution, legendMap),
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
resultType: '',
|
||||
|
||||
@@ -906,6 +906,10 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
});
|
||||
|
||||
describe('mapPanelTypeToRequestType', () => {
|
||||
it('sends a histogram as time_series, which it bins in the browser', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.HISTOGRAM)).toBe('time_series');
|
||||
});
|
||||
|
||||
it('sends a scatter plot as scalar, like a table', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
|
||||
});
|
||||
|
||||
@@ -47,8 +47,11 @@ type PrepareQueryRangePayloadV5Result = {
|
||||
*/
|
||||
export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
switch (panelType) {
|
||||
// Histogram bins a time series in the browser; `distribution` is the
|
||||
// Distribution panel's request type.
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return 'time_series';
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.SCATTER:
|
||||
@@ -59,8 +62,6 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
return 'trace';
|
||||
case PANEL_TYPES.LIST:
|
||||
return 'raw';
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return 'distribution';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
.breadcrumb {
|
||||
padding: var(--spacing-4) 16px var(--spacing-2);
|
||||
|
||||
// The antd breadcrumb provides the item spacing.
|
||||
--button-padding: 0;
|
||||
|
||||
ol {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -16,3 +13,8 @@
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--l1-border);
|
||||
--divider-margin: 10px 0 16px 0;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function AlertBreadcrumb({
|
||||
items={breadcrumbItems}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showDivider && <Divider spacing="10px 16px" />}
|
||||
{showDivider && <Divider className={styles.divider} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
|
||||
@@ -17,3 +17,8 @@
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--l1-border);
|
||||
--divider-margin: 10px 0 16px 0;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function BackHeader({
|
||||
</Button>
|
||||
<div className={styles.title}>{title}</div>
|
||||
</div>
|
||||
<Divider spacing="10px 16px" />
|
||||
<Divider className={styles.divider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,15 +56,22 @@ function ProgressRender(item: string | number): JSX.Element {
|
||||
const percent = Number(Number(item).toFixed(1));
|
||||
return (
|
||||
<div className="progress-container">
|
||||
<div className="progress-bar">
|
||||
<Progress
|
||||
percent={percent}
|
||||
showInfo
|
||||
color={
|
||||
percent >= 90 ? 'highlight-danger' : percent >= 60 ? 'warning' : 'success'
|
||||
<Progress
|
||||
percent={percent}
|
||||
strokeLinecap="butt"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const cpuPercent = percent;
|
||||
if (cpuPercent >= 90) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
if (cpuPercent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function CeleryTaskDetail({
|
||||
endTime ? `- ${formatTimestamp(endTime)}` : ''
|
||||
}`}
|
||||
</Typography.Text>
|
||||
<Divider orientation="vertical" />
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="subtitle">{taskData.value}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
.create-sa-modal {
|
||||
max-width: 530px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.create-sa-modal__content {
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -17,6 +50,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--l1-border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--destructive);
|
||||
@@ -31,3 +83,16 @@
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardCon
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
invalidateListServiceAccounts,
|
||||
@@ -116,10 +116,11 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
<Input
|
||||
id="sa-name"
|
||||
placeholder="Enter a name"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
testId="create-sa-name-input"
|
||||
data-testid="create-sa-name-input"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
@@ -10,9 +10,9 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('@signozhq/ui/toast', () => ({
|
||||
...jest.requireActual('@signozhq/ui/toast'),
|
||||
toast: { success: jest.fn(), danger: jest.fn() },
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
@@ -58,7 +58,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
// The form only renders once the create check resolves, and the name field
|
||||
// registers its `required` rule on mount, so the empty-form invalid state
|
||||
// settles a tick later.
|
||||
await screen.findByTestId('create-sa-name-input-field');
|
||||
await screen.findByTestId('create-sa-name-input');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toHaveAttribute(
|
||||
@@ -72,7 +72,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input-field');
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
|
||||
|
||||
await user.type(nameInput, 'test');
|
||||
@@ -90,7 +90,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input-field');
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
await user.type(nameInput, 'Deploy Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
@@ -128,7 +128,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId<HTMLInputElement>(
|
||||
'create-sa-name-input-field',
|
||||
'create-sa-name-input',
|
||||
);
|
||||
await user.type(nameInput, 'Deploy Bot');
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
@@ -160,7 +160,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input-field');
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
await user.type(nameInput, 'Dupe Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
@@ -209,9 +209,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('create-sa-name-input-field'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('create-sa-name-input')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('create-service-account-modal'),
|
||||
).toBeInTheDocument();
|
||||
@@ -246,7 +244,7 @@ describe('CreateServiceAccountModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input-field');
|
||||
const nameInput = await screen.findByTestId('create-sa-name-input');
|
||||
await user.type(nameInput, 'Bot');
|
||||
await user.clear(nameInput);
|
||||
|
||||
|
||||
@@ -34,10 +34,34 @@ $item-spacing: 8px;
|
||||
@include flex-center;
|
||||
gap: 6px;
|
||||
width: -webkit-fill-available;
|
||||
}
|
||||
|
||||
// The search row draws its own padding; the inline Input hugs the text.
|
||||
--input-height: auto;
|
||||
--input-padding: 0;
|
||||
&__input {
|
||||
@include text-style-base;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
height: auto;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__esc-key {
|
||||
|
||||
@@ -65,8 +65,7 @@ function SearchBar({
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
variant="unstyled"
|
||||
noFocusRing
|
||||
className="timezone-picker__input"
|
||||
placeholder="Search timezones..."
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.details-header {
|
||||
// ghost + secondary missing hover bg token in @signozhq/button
|
||||
--button-ghost-hover-background: var(--l3-background);
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.drawer {
|
||||
overflow: hidden;
|
||||
border-color: var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px var(--l1-shadow);
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
|
||||
import cx from 'classnames';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
|
||||
import styles from './DetailsPanelDrawer.module.scss';
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
@@ -17,41 +14,22 @@ function DetailsPanelDrawer({
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
modal={false}
|
||||
direction="right"
|
||||
showOverlay={false}
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DrawerContent
|
||||
forceMount
|
||||
direction="right"
|
||||
width="extra-wide"
|
||||
showOverlay={false}
|
||||
// Outside presses are left to the page, which knows what counts as inside.
|
||||
onInteractOutside={(e): void => e.preventDefault()}
|
||||
className={cx(styles.drawer, className)}
|
||||
testId={testId}
|
||||
>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</DrawerContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Drawer>
|
||||
<div className="details-panel-drawer__body">{children}</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
DetailsPanelDrawer.defaultProps = {
|
||||
className: undefined,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -409,8 +409,7 @@ function EditMemberDrawer({
|
||||
setShowResetLinkDialog(true);
|
||||
onClose();
|
||||
} else {
|
||||
toast.danger('Failed to generate password reset link', {
|
||||
action: { label: 'Dismiss' },
|
||||
toast.error('Failed to generate password reset link', {
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
@@ -439,7 +438,7 @@ function EditMemberDrawer({
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.error) {
|
||||
toast.danger('Failed to copy link', { action: { label: 'Dismiss' } });
|
||||
toast.error('Failed to copy link');
|
||||
}
|
||||
}, [copyState.error]);
|
||||
|
||||
@@ -472,19 +471,20 @@ function EditMemberDrawer({
|
||||
<label className="edit-member-drawer__label" htmlFor="member-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="member-name"
|
||||
value={localDisplayName}
|
||||
onChange={(e): void => {
|
||||
setLocalDisplayName(e.target.value);
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter((err) => err.context !== 'Name update'),
|
||||
);
|
||||
}}
|
||||
placeholder="Enter name"
|
||||
disabled={isRootUser || isDeleted}
|
||||
disabledTooltip={isRootUser ? ROOT_USER_TOOLTIP : undefined}
|
||||
/>
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<Input
|
||||
id="member-name"
|
||||
value={localDisplayName}
|
||||
onChange={(e): void => {
|
||||
setLocalDisplayName(e.target.value);
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter((err) => err.context !== 'Name update'),
|
||||
);
|
||||
}}
|
||||
placeholder="Enter name"
|
||||
disabled={isRootUser || isDeleted}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
useCreateResetPasswordToken,
|
||||
@@ -82,11 +82,11 @@ jest.mock('@signozhq/ui/dialog', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/toast', () => ({
|
||||
...jest.requireActual('@signozhq/ui/toast'),
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
danger: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,20 @@
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
padding-left: 3px;
|
||||
padding-right: 8px;
|
||||
cursor: pointer;
|
||||
span {
|
||||
color: var(--danger-background);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 200% */
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
&__wrap {
|
||||
background: linear-gradient(
|
||||
|
||||
@@ -53,13 +53,8 @@ function ErrorModal({
|
||||
onClick={(): void => setVisible(true)}
|
||||
onKeyDown={undefined}
|
||||
>
|
||||
<Badge
|
||||
variant="solid"
|
||||
color="danger"
|
||||
textTransform="uppercase"
|
||||
prefix={<CircleAlert size={14} />}
|
||||
>
|
||||
error
|
||||
<Badge variant="solid" color="danger">
|
||||
<CircleAlert size={14} color={Color.BG_CHERRY_500} /> error
|
||||
</Badge>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.export-menu-popover {
|
||||
// The popover shell carries no padding of its own, so the content owns it.
|
||||
padding: var(--spacing-8);
|
||||
width: 240px;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, usePopoverHandle } from '@signozhq/ui/popover';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -36,10 +36,6 @@ export default function ExportMenu({
|
||||
}: ExportMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
// Driven by a handle rather than `trigger`: the button carries a Tooltip, and a Tooltip
|
||||
// routes every prop it does not recognise to its own popup, so a popover's press handlers
|
||||
// would never reach the button underneath.
|
||||
const menu = usePopoverHandle(setIsPopoverOpen);
|
||||
|
||||
const { isExporting, handleExport: handleClientExport } = useClientExport({
|
||||
data,
|
||||
@@ -49,62 +45,53 @@ export default function ExportMenu({
|
||||
});
|
||||
|
||||
const handleExport = useCallback((): void => {
|
||||
menu.close();
|
||||
setIsPopoverOpen(false);
|
||||
handleClientExport({ format: exportFormat as ExportFormat });
|
||||
}, [menu, exportFormat, handleClientExport]);
|
||||
}, [exportFormat, handleClientExport]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<Tooltip title="Download">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Download"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isPopoverOpen}
|
||||
testId={`export-menu-${dataSource}`}
|
||||
loading={isExporting}
|
||||
onClick={(event): void => menu.showAt(event.currentTarget)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={240}
|
||||
handle={menu}
|
||||
aria-label="Export format"
|
||||
>
|
||||
<div className="export-menu-popover">
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={exportFormat}
|
||||
onChange={setExportFormat}
|
||||
items={[
|
||||
{ value: ExportFormat.Csv, label: 'csv' },
|
||||
{ value: ExportFormat.Jsonl, label: 'jsonl' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
width="100%"
|
||||
onClick={handleExport}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Download"
|
||||
testId={`export-menu-${dataSource}`}
|
||||
loading={isExporting}
|
||||
prefix={<Download size={16} />}
|
||||
>
|
||||
Export
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="export-menu-popover">
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={exportFormat}
|
||||
onChange={setExportFormat}
|
||||
items={[
|
||||
{ value: ExportFormat.Csv, label: 'csv' },
|
||||
{ value: ExportFormat.Jsonl, label: 'jsonl' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
width="100%"
|
||||
onClick={handleExport}
|
||||
loading={isExporting}
|
||||
prefix={<Download size={16} />}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,15 +32,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Full-bleed search bar: only top/bottom borders, square corners.
|
||||
// Per-side widths cannot be expressed via --input-border-width.
|
||||
.searchInput {
|
||||
--input-border-radius: 0;
|
||||
|
||||
[data-slot='input'] {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
background-color: var(--l1-background);
|
||||
height: 40px;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.section {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
@@ -140,10 +140,10 @@ function FieldsSelectorContent({
|
||||
<X className={styles.closeIcon} size={16} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<section className={styles.searchInput}>
|
||||
<section>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
size="large"
|
||||
value={inputValue}
|
||||
placeholder="Search for a field..."
|
||||
onChange={handleInputChange}
|
||||
|
||||
@@ -13,9 +13,9 @@ jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/toast', () => ({
|
||||
...jest.requireActual('@signozhq/ui/toast'),
|
||||
toast: { success: jest.fn(), danger: jest.fn() },
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// FloatingPanel is a react-rnd/portal shell — presentation only. Render its
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button, Input } from 'antd';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -39,8 +39,7 @@ function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(`Failed to submit ${entityName}`);
|
||||
toast.danger(`Failed to submit ${entityName}`, {
|
||||
action: { label: 'Dismiss' },
|
||||
toast.error(`Failed to submit ${entityName}`, {
|
||||
position: 'top-right',
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Mock dependencies before imports
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
@@ -13,11 +13,11 @@ jest.mock('lib/router/useAppLocation', () => ({
|
||||
useAppLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/toast', () => ({
|
||||
...jest.requireActual('@signozhq/ui/toast'),
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
danger: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('FeedbackModal', () => {
|
||||
isCloudUser: false,
|
||||
});
|
||||
mockToast.success.mockClear();
|
||||
mockToast.danger.mockClear();
|
||||
mockToast.error.mockClear();
|
||||
});
|
||||
|
||||
it('should render feedback modal with all tabs', () => {
|
||||
|
||||
@@ -29,8 +29,8 @@ export default function WaitlistFragment({
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
logEvent('Infra Monitoring: Get early access clicked', {
|
||||
entityType,
|
||||
logEvent('Infra Monitoring: Get Early Access Clicked', {
|
||||
entity_type: entityType,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
|
||||
// The label chip and the Input share the --input-with-label-* theming
|
||||
// contract (set by e.g. FormAlertRules, WidgetFullView) so the joined
|
||||
// group recolors as one.
|
||||
.label {
|
||||
// Typography.Text takes its display from this token; at its `inline` default
|
||||
// the label blockifies as a flex item and the text rides the top of the row.
|
||||
@@ -34,40 +31,66 @@
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-weight: var(--font-weight-light);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
--input-border-radius: 0px;
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
color: var(--input-with-label-color, var(--l2-foreground));
|
||||
|
||||
font-size: 12px !important;
|
||||
line-height: 25px;
|
||||
|
||||
position: relative;
|
||||
|
||||
// Square corners: the input sits mid-group between label and close button.
|
||||
--input-border-radius: 0px;
|
||||
--input-border: var(--input-with-label-border-color, var(--l2-border));
|
||||
--input-background: var(
|
||||
--input-with-label-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-foreground: var(--input-with-label-color, var(--l2-foreground));
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
&:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.ant-select:hover .ant-select-selector,
|
||||
.ant-select-focused .ant-select-selector {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.input__has-label-after {
|
||||
margin-left: -1px;
|
||||
|
||||
.ant-select-selector {
|
||||
margin-left: -1px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.input__has-close-button {
|
||||
.ant-select-selector {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
~ .close-btn {
|
||||
margin-left: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
// The Input has no token for the native number spinners.
|
||||
input[type='number']::-webkit-inner-spin-button,
|
||||
input[type='number']::-webkit-outer-spin-button {
|
||||
&::placeholder {
|
||||
color: var(--input-with-label-color, var(--l3-foreground)) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
&[type='number']::-webkit-inner-spin-button,
|
||||
&[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
@@ -91,7 +114,16 @@
|
||||
|
||||
&.labelAfter {
|
||||
.input {
|
||||
--input-border-radius: 2px 0 0 2px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -18,7 +18,6 @@ function InputWithLabel({
|
||||
className,
|
||||
closeIcon,
|
||||
disabled,
|
||||
sanitize,
|
||||
}: {
|
||||
label: string;
|
||||
initialValue?: string | number | null;
|
||||
@@ -30,17 +29,14 @@ function InputWithLabel({
|
||||
className?: string;
|
||||
closeIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Rewrites the typed value before it is shown and reported. */
|
||||
sanitize?: (value: string) => string;
|
||||
}): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
initialValue ? initialValue.toString() : '',
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = sanitize ? sanitize(e.target.value) : e.target.value;
|
||||
setInputValue(value);
|
||||
onChange?.(value);
|
||||
setInputValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,23 +46,19 @@ function InputWithLabel({
|
||||
})}
|
||||
>
|
||||
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
|
||||
<div
|
||||
<Input
|
||||
className={cx('input', {
|
||||
'input__has-label-after': !labelAfter,
|
||||
'input__has-close-button': !!onClose,
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
disabledTooltip={undefined}
|
||||
name={label.toLowerCase()}
|
||||
testId={`input-${label}`}
|
||||
/>
|
||||
</div>
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
name={label.toLowerCase()}
|
||||
data-testid={`input-${label}`}
|
||||
/>
|
||||
{labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
|
||||
{onClose && (
|
||||
<Button
|
||||
@@ -86,7 +78,6 @@ InputWithLabel.defaultProps = {
|
||||
initialValue: undefined,
|
||||
className: undefined,
|
||||
closeIcon: undefined,
|
||||
sanitize: undefined,
|
||||
};
|
||||
|
||||
export default InputWithLabel;
|
||||
|
||||
@@ -55,12 +55,26 @@
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
|
||||
// Matches the antd RolesSelect in the same row, which is forced to the
|
||||
// same field background below.
|
||||
--input-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-hover-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-focus-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-disabled-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cellRole {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlert, Plus, SolidCheckCircle2, Trash2 } from '@signozhq/icons';
|
||||
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -97,7 +97,7 @@ function InviteMembers({
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
testId={`invite-email-${row.id}`}
|
||||
data-testid={`invite-email-${row.id}`}
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<Typography.Text size="small" className={styles.errorText}>
|
||||
@@ -152,62 +152,61 @@ function InviteMembers({
|
||||
</div>
|
||||
|
||||
{hasValidationErrors && (
|
||||
<div className={styles.callout}>
|
||||
<Callout
|
||||
color="danger"
|
||||
size="sm"
|
||||
icon={<CircleAlert />}
|
||||
testId="invite-validation-error"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
</div>
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-validation-error"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && hasFailures && (
|
||||
<div className={styles.callout}>
|
||||
<Callout
|
||||
color="danger"
|
||||
size="sm"
|
||||
icon={<CircleAlert />}
|
||||
testId="invite-api-error"
|
||||
>
|
||||
<div className={styles.results}>
|
||||
{hasSuccesses && (
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully.
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-api-error"
|
||||
>
|
||||
<div className={styles.results}>
|
||||
{hasSuccesses && (
|
||||
<Typography.Text size="small">
|
||||
{failedResults.length} invite(s) failed:
|
||||
{successResults.length} invite(s) sent successfully.
|
||||
</Typography.Text>
|
||||
<ul className={styles.resultsList}>
|
||||
{failedResults.map((result) => (
|
||||
<li key={result.email}>
|
||||
<Typography.Text size="small">
|
||||
{result.email}: {result.error}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
<Typography.Text size="small">
|
||||
{failedResults.length} invite(s) failed:
|
||||
</Typography.Text>
|
||||
<ul className={styles.resultsList}>
|
||||
{failedResults.map((result) => (
|
||||
<li key={result.email}>
|
||||
<Typography.Text size="small">
|
||||
{result.email}: {result.error}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && !hasFailures && hasSuccesses && (
|
||||
<div className={styles.callout}>
|
||||
<Callout
|
||||
color="success"
|
||||
size="sm"
|
||||
icon={<SolidCheckCircle2 />}
|
||||
testId="invite-success"
|
||||
>
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully!
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
</div>
|
||||
<Callout
|
||||
type="success"
|
||||
size="small"
|
||||
showIcon
|
||||
className={styles.callout}
|
||||
data-testid="invite-success"
|
||||
>
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully!
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{renderFooter?.({
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { aggregateAttributesResourcesToString } from 'container/LogDetailedView/utils';
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -76,7 +76,7 @@ function LogDetailsHeader({
|
||||
return (
|
||||
<div className={styles.header} data-log-detail-ignore="true">
|
||||
<div className={styles.leftSection}>
|
||||
<Divider orientation="vertical" height={16} spacing={0} />
|
||||
<Divider type="vertical" className={styles.divider} />
|
||||
<Typography.Text
|
||||
className={styles.timestamp}
|
||||
data-testid="log-details-header-timestamp"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toast } from '@signozhq/ui/toast';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -7,8 +7,8 @@ import LogDetail from '..';
|
||||
import { VIEW_TYPES } from '../constants';
|
||||
import { LogDetailProps } from '../LogDetail.interfaces';
|
||||
|
||||
jest.mock('@signozhq/ui/toast', () => ({
|
||||
toast: { success: jest.fn(), danger: jest.fn() },
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// DataViewer pulls in react-json-tree (ESM) + Monaco; mock it (as trace's tests
|
||||
|
||||
@@ -9,6 +9,7 @@ import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicat
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
@@ -22,7 +23,6 @@ import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { getBodyMessage } from 'lib/logs/logBody';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
TextSelect,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -65,6 +67,10 @@ function LogDetailInner({
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
|
||||
|
||||
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
const [filters, setFilters] = useState<TagFilter | null>(null);
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
@@ -167,7 +173,20 @@ function LogDetailInner({
|
||||
}
|
||||
};
|
||||
|
||||
const logBody = useMemo(() => getBodyMessage(log?.body), [log?.body]);
|
||||
const logBody = useMemo(() => {
|
||||
if (!isBodyJsonQueryEnabled) {
|
||||
return (log?.body as string) ?? '';
|
||||
}
|
||||
// Feature enabled: body is always a map; message is always a string
|
||||
const bodyObj = log?.body as ILogBody;
|
||||
if (!bodyObj) {
|
||||
return '';
|
||||
}
|
||||
if (bodyObj.message) {
|
||||
return bodyObj.message;
|
||||
}
|
||||
return JSON.stringify(bodyObj);
|
||||
}, [isBodyJsonQueryEnabled, log?.body]);
|
||||
|
||||
const htmlBody = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -4,7 +4,6 @@ import cx from 'classnames';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { ScalarFieldValue } from 'utils/stringUtils';
|
||||
|
||||
import './AddToQueryHOC.styles.scss';
|
||||
|
||||
@@ -41,10 +40,10 @@ function AddToQueryHOC({
|
||||
|
||||
export interface AddToQueryHOCProps {
|
||||
fieldKey: string;
|
||||
fieldValue: ScalarFieldValue;
|
||||
fieldValue: string;
|
||||
onAddToQuery: (
|
||||
fieldKey: string,
|
||||
fieldValue: ScalarFieldValue,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
) => void;
|
||||
|
||||
@@ -5,19 +5,19 @@ import cx from 'classnames';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
// utils
|
||||
import { FlatLogData, getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
// interfaces
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { coerceToString, isScalarFieldValue } from 'utils/stringUtils';
|
||||
|
||||
// components
|
||||
import AddToQueryHOC, { AddToQueryHOCProps } from '../AddToQueryHOC';
|
||||
@@ -26,6 +26,7 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
||||
// styles
|
||||
import { Container, LogContainer, LogText } from './styles';
|
||||
import { isValidLogField } from './util';
|
||||
|
||||
import './ListLogView.styles.scss';
|
||||
|
||||
@@ -36,9 +37,8 @@ interface LogFieldProps {
|
||||
fontSize: FontSize;
|
||||
}
|
||||
|
||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow' | 'fieldValue'> &
|
||||
Pick<AddToQueryHOCProps, 'fieldValue'> &
|
||||
Partial<Pick<AddToQueryHOCProps, 'onAddToQuery'>>;
|
||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
|
||||
function LogGeneralField({
|
||||
fieldKey,
|
||||
@@ -75,35 +75,27 @@ function LogSelectedField({
|
||||
onAddToQuery,
|
||||
fontSize,
|
||||
}: LogSelectedFieldProps): JSX.Element {
|
||||
const fieldKeyText = (
|
||||
<Typography.Text>
|
||||
<span
|
||||
style={{ color: blue[4] }}
|
||||
className={cx('selected-log-field-key', fontSize)}
|
||||
>
|
||||
{fieldKey}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="log-selected-fields">
|
||||
{onAddToQuery ? (
|
||||
<AddToQueryHOC
|
||||
fieldKey={fieldKey}
|
||||
fieldValue={fieldValue}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
>
|
||||
{fieldKeyText}
|
||||
</AddToQueryHOC>
|
||||
) : (
|
||||
fieldKeyText
|
||||
)}
|
||||
<AddToQueryHOC
|
||||
fieldKey={fieldKey}
|
||||
fieldValue={fieldValue}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
>
|
||||
<Typography.Text>
|
||||
<span
|
||||
style={{ color: blue[4] }}
|
||||
className={cx('selected-log-field-key', fontSize)}
|
||||
>
|
||||
{fieldKey}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</AddToQueryHOC>
|
||||
<Typography.Text truncate={1} className={cx('selected-log-kv', fontSize)}>
|
||||
<span className={cx('selected-log-field-key', fontSize)}>{': '}</span>
|
||||
<span className={cx('selected-log-value', fontSize)}>
|
||||
{coerceToString(fieldValue) || "''"}
|
||||
{fieldValue || "''"}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -138,7 +130,6 @@ function ListLogView({
|
||||
onClearActiveLog,
|
||||
}: ListLogViewProps): JSX.Element {
|
||||
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
||||
logData.id,
|
||||
@@ -209,7 +200,7 @@ function ListLogView({
|
||||
{updatedSelecedFields.some((field) => field.name === 'body') && (
|
||||
<LogGeneralField
|
||||
fieldKey="Log"
|
||||
fieldValue={getBodyDisplay(logData.body)}
|
||||
fieldValue={getBodyDisplayString(logData.body)}
|
||||
linesPerRow={linesPerRow}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
@@ -231,22 +222,17 @@ function ListLogView({
|
||||
|
||||
{updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.map((field) => {
|
||||
const value = getLogFieldValue(logData, field.name, isBodyJsonEnabled);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const queryValue = isScalarFieldValue(value) ? value : undefined;
|
||||
return (
|
||||
.map((field) =>
|
||||
isValidLogField(flattenLogData[field.name] as never) ? (
|
||||
<LogSelectedField
|
||||
key={field.name}
|
||||
fieldKey={field.name}
|
||||
fieldValue={queryValue ?? coerceToString(value)}
|
||||
onAddToQuery={queryValue === undefined ? undefined : onAddToQuery}
|
||||
fieldValue={flattenLogData[field.name] as never}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
) : null,
|
||||
)}
|
||||
</LogContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
frontend/src/components/Logs/ListLogView/util.ts
Normal file
1
frontend/src/components/Logs/ListLogView/util.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isValidLogField = (value: never): boolean => value !== undefined;
|
||||
@@ -9,16 +9,16 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip } from 'antd';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
// hooks
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { isBoolean, isEmpty, isNumber, isUndefined } from 'lodash-es';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons';
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
@@ -46,7 +46,7 @@ function RawLogView({
|
||||
isLogsExplorerPage,
|
||||
onLogCopy,
|
||||
} = useCopyLogLink(data.id);
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
|
||||
@@ -60,16 +60,15 @@ function RawLogView({
|
||||
|
||||
const attributesValues = updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.map((field) => getLogFieldValue(data, field.name, isBodyJsonEnabled))
|
||||
.map((field) => flattenLogData[field.name])
|
||||
.filter((attribute) => {
|
||||
// loadash isEmpty doesnot work with numbers
|
||||
if (isNumber(attribute) || isBoolean(attribute)) {
|
||||
if (isNumber(attribute)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isUndefined(attribute) && !isEmpty(attribute);
|
||||
})
|
||||
.map((attribute) => coerceToString(attribute));
|
||||
});
|
||||
|
||||
let attributesText = attributesValues.join(' | ');
|
||||
|
||||
@@ -103,7 +102,7 @@ function RawLogView({
|
||||
// Check if body is selected
|
||||
const showBody = selectedFields.some((field) => field.name === 'body');
|
||||
if (showBody) {
|
||||
parts.push(`${attributesText} ${getBodyDisplay(data.body)}`);
|
||||
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
|
||||
} else {
|
||||
parts.push(attributesText);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { render, renderHook, screen } from 'tests/test-utils';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { useLogsTableColumns } from '../useLogsTableColumns';
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/Timezone'),
|
||||
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/App/App', () => ({
|
||||
...jest.requireActual('providers/App/App'),
|
||||
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
|
||||
}));
|
||||
|
||||
@@ -169,36 +165,3 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogsTableColumns — cell values', () => {
|
||||
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
|
||||
|
||||
it('shows an empty body cell for a row without a body', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('body', 'log')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
const bodyCol = result.current.find((c) => c.id === 'log:body');
|
||||
|
||||
expect(bodyCol?.accessorFn?.(asLog({}))).toBe('');
|
||||
});
|
||||
|
||||
it('renders an object-valued field as JSON, not [object Object]', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('order')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
const orderCol = result.current.find((c) => c.id === 'order');
|
||||
const Cell = orderCol?.cell as (props: { value: unknown }) => JSX.Element;
|
||||
|
||||
render(<Cell value={{ id: 8412, coupons: ['WELCOME10'] }} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('{"id":8412,"coupons":["WELCOME10"]}'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,18 @@ import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
import { getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
import type { TableColumnDef } from '../../TanStackTableView/types';
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
@@ -26,7 +28,10 @@ export function useLogsTableColumns({
|
||||
fontSize,
|
||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
return useMemo<TableColumnDef<ILog>[]>(() => {
|
||||
const stateIndicatorCol: TableColumnDef<ILog> = {
|
||||
@@ -70,7 +75,7 @@ export function useLogsTableColumns({
|
||||
const bodyCol: TableColumnDef<ILog> = {
|
||||
id: buildCompositeKey('body', 'log'),
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplay(log.body),
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
@@ -94,7 +99,7 @@ export function useLogsTableColumns({
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{coerceToString(value)}</TanStackTable.Text>
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,51 +18,71 @@
|
||||
right: -2px;
|
||||
width: 240px;
|
||||
|
||||
[data-format-menu='back'],
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'],
|
||||
[data-format-menu='edit-columns'] {
|
||||
--button-justify-content: flex-start;
|
||||
}
|
||||
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'],
|
||||
[data-format-menu='edit-columns'] {
|
||||
--button-label-min-width: 100%;
|
||||
--button-font-size: 13px;
|
||||
--button-variant-link-font-weight: 400;
|
||||
--button-size-line-height: 20px;
|
||||
}
|
||||
|
||||
[data-format-menu='back'] {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'] {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.font-size-dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.horizontal-line {
|
||||
margin-inline: -12px;
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
justify-content: space-between;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.text:hover {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.option-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +102,31 @@
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
.font-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.icon {
|
||||
}
|
||||
}
|
||||
|
||||
.value:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
@@ -154,23 +199,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Stepper group: the Input sits seamlessly between the -/+
|
||||
// periscope-btns (26px tall, l1-border).
|
||||
.max-lines-per-row-input {
|
||||
display: flex;
|
||||
|
||||
--input-height: 26px;
|
||||
--input-border-radius: 0;
|
||||
--input-border: var(--l1-border);
|
||||
--input-border-width: 1px 0;
|
||||
.ant-input-number-handler-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-testid='max-lines-per-row'] {
|
||||
.ant-input-number {
|
||||
min-width: 36px;
|
||||
width: auto;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
text-align: center;
|
||||
height: 26px;
|
||||
border-radius: 0;
|
||||
|
||||
// No Input token for text alignment.
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-number-focused {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-input-number-input-wrap {
|
||||
input {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +258,30 @@
|
||||
|
||||
.edit-columns-container {
|
||||
padding: 12px;
|
||||
|
||||
.edit-columns-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
|
||||
.edit-columns-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-column-container {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
LogViewMode,
|
||||
@@ -19,10 +18,6 @@ import {
|
||||
|
||||
import './LogsFormatOptionsMenu.styles.scss';
|
||||
|
||||
const MIN_LINES_PER_ROW = 1;
|
||||
const MAX_LINES_PER_ROW = 10;
|
||||
const FONT_SIZES = [FontSize.SMALL, FontSize.MEDIUM, FontSize.LARGE];
|
||||
|
||||
interface LogsFormatOptionsMenuProps {
|
||||
items: any;
|
||||
selectedOptionFormat: any;
|
||||
@@ -67,11 +62,25 @@ function OptionsMenu({
|
||||
onChange(key);
|
||||
};
|
||||
|
||||
const handleLinesPerRowChange = (value: number | null): void => {
|
||||
if (value !== null) {
|
||||
setMaxLinesPerRow(
|
||||
Math.min(Math.max(value, MIN_LINES_PER_ROW), MAX_LINES_PER_ROW),
|
||||
);
|
||||
const incrementMaxLinesPerRow = (): void => {
|
||||
if (maxLinesPerRow < 10) {
|
||||
setMaxLinesPerRow(maxLinesPerRow + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const decrementMaxLinesPerRow = (): void => {
|
||||
if (maxLinesPerRow > 1) {
|
||||
setMaxLinesPerRow(maxLinesPerRow - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
|
||||
if (
|
||||
maxLinesPerRow &&
|
||||
Number.isInteger(maxLinesNumber) &&
|
||||
maxLinesPerRow > 1
|
||||
) {
|
||||
setMaxLinesPerRow(maxLinesPerRow);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,36 +111,51 @@ function OptionsMenu({
|
||||
{isFontSizeOptionsOpen ? (
|
||||
<div className="font-size-dropdown">
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="back"
|
||||
testId="font-size-back"
|
||||
prefix={<ChevronLeft size={14} />}
|
||||
onClick={(): void => setIsFontSizeOptionsOpen(false)}
|
||||
className="back-btn"
|
||||
type="text"
|
||||
>
|
||||
Select font size
|
||||
<ChevronLeft size={14} className="icon" />
|
||||
<Typography.Text className="text">Select font size</Typography.Text>
|
||||
</Button>
|
||||
<div className="horizontal-line" />
|
||||
<div className="content">
|
||||
{FONT_SIZES.map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="font-option"
|
||||
testId={`font-size-option-${size}`}
|
||||
onClick={(): void => setFontSizeValue(size)}
|
||||
>
|
||||
<span className="menu-row">
|
||||
{size}
|
||||
{fontSizeValue === size && <Check size={14} />}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.SMALL);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.SMALL}</Typography.Text>
|
||||
{fontSizeValue === FontSize.SMALL && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.MEDIUM);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.MEDIUM}</Typography.Text>
|
||||
{fontSizeValue === FontSize.MEDIUM && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.LARGE);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.LARGE}</Typography.Text>
|
||||
{fontSizeValue === FontSize.LARGE && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -139,20 +163,14 @@ function OptionsMenu({
|
||||
<div className="font-size-container">
|
||||
<div className="title">Font Size</div>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="font-size"
|
||||
testId="font-size-select"
|
||||
className="value"
|
||||
type="text"
|
||||
onClick={(): void => {
|
||||
setIsFontSizeOptionsOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="menu-row">
|
||||
{fontSizeValue}
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
<Typography.Text className="font-value">{fontSizeValue}</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="horizontal-line" />
|
||||
@@ -186,29 +204,24 @@ function OptionsMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
aria-label="Fewer lines per row"
|
||||
onClick={(): void => handleLinesPerRowChange(maxLinesPerRow - 1)}
|
||||
onClick={decrementMaxLinesPerRow}
|
||||
>
|
||||
<Minus size={12} />
|
||||
{' '}
|
||||
<Minus size={12} />{' '}
|
||||
</button>
|
||||
<Input.Number
|
||||
min={MIN_LINES_PER_ROW}
|
||||
max={MAX_LINES_PER_ROW}
|
||||
format={{ maximumFractionDigits: 0 }}
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
controls={false}
|
||||
noFocusRing
|
||||
aria-label="Max lines per row"
|
||||
testId="max-lines-per-row"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
aria-label="More lines per row"
|
||||
onClick={(): void => handleLinesPerRowChange(maxLinesPerRow + 1)}
|
||||
onClick={incrementMaxLinesPerRow}
|
||||
>
|
||||
<Plus size={12} />
|
||||
{' '}
|
||||
<Plus size={12} />{' '}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,18 +233,15 @@ function OptionsMenu({
|
||||
<div className="horizontal-line" />
|
||||
<div className="edit-columns-container">
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="edit-columns"
|
||||
className="edit-columns-btn"
|
||||
type="text"
|
||||
onClick={handleEditColumns}
|
||||
testId="periscope-btn-edit-columns"
|
||||
data-testid="periscope-btn-edit-columns"
|
||||
>
|
||||
<span className="menu-row">
|
||||
<Typography.Text className="edit-columns-text">
|
||||
Edit columns
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -270,15 +280,10 @@ function LogsFormatOptionsMenu({
|
||||
>
|
||||
<Tooltip title="Options">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Options"
|
||||
testId="periscope-btn-format-options"
|
||||
>
|
||||
<SlidersVertical size={14} />
|
||||
</Button>
|
||||
className="periscope-btn ghost"
|
||||
icon={<SlidersVertical size="md" />}
|
||||
data-testid="periscope-btn-format-options"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
|
||||
|
||||
@@ -113,12 +107,18 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
});
|
||||
|
||||
it('increments max-lines and calls onChange', async () => {
|
||||
const { maxLinesOnChange, getByTestId } = setup();
|
||||
const { maxLinesOnChange } = setup();
|
||||
|
||||
// Increment max lines
|
||||
const input = getByTestId('max-lines-per-row-field') as HTMLInputElement;
|
||||
const input = document.querySelector(
|
||||
'.max-lines-per-row-input input',
|
||||
) as HTMLInputElement;
|
||||
const initial = Number(input.value);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More lines per row' }));
|
||||
const buttons = document.querySelectorAll(
|
||||
'.max-lines-per-row-input .periscope-btn',
|
||||
);
|
||||
const incrementBtn = buttons[1] as HTMLElement;
|
||||
fireEvent.click(incrementBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Number(input.value)).toBe(initial + 1);
|
||||
@@ -128,47 +128,28 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps a typed max-lines value to 10', async () => {
|
||||
const { maxLinesOnChange, getByTestId } = setup();
|
||||
const input = getByTestId('max-lines-per-row-field') as HTMLInputElement;
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, '15');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(input.value).toBe('10'));
|
||||
expect(maxLinesOnChange).toHaveBeenLastCalledWith(10);
|
||||
});
|
||||
|
||||
it('stops max-lines at 10', async () => {
|
||||
const { getByTestId } = setup();
|
||||
const input = getByTestId('max-lines-per-row-field') as HTMLInputElement;
|
||||
const increment = screen.getByRole('button', { name: 'More lines per row' });
|
||||
const user = userEvent.setup();
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await user.click(increment);
|
||||
}
|
||||
|
||||
await waitFor(() => expect(input.value).toBe('10'));
|
||||
});
|
||||
|
||||
it('changes font size to MEDIUM and calls onChange', async () => {
|
||||
const { fontSizeOnChange, getByTestId } = setup();
|
||||
const { fontSizeOnChange } = setup();
|
||||
// Open font dropdown
|
||||
fireEvent.click(getByTestId('font-size-select'));
|
||||
const fontButton = document.querySelector(
|
||||
'.font-size-container .value',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(fontButton);
|
||||
|
||||
// Choose MEDIUM
|
||||
fireEvent.click(getByTestId(`font-size-option-${FontSize.MEDIUM}`));
|
||||
const optionButtons = Array.from(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn'),
|
||||
);
|
||||
const mediumBtn = optionButtons[1] as HTMLElement;
|
||||
fireEvent.click(mediumBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
getByTestId(`font-size-option-${FontSize.MEDIUM}`).querySelector('svg'),
|
||||
).toBeInTheDocument();
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
getByTestId(`font-size-option-${FontSize.SMALL}`).querySelector('svg'),
|
||||
).not.toBeInTheDocument();
|
||||
(optionButtons[1] as Element).querySelector('.icon'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
|
||||
|
||||
@@ -184,8 +184,8 @@
|
||||
}
|
||||
|
||||
.helpContent {
|
||||
// The popover shell carries no padding of its own, so the content owns it.
|
||||
padding: var(--spacing-8);
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover } from '@signozhq/ui/popover';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { MARKDOWN_HELP_ITEMS } from './constants';
|
||||
@@ -9,14 +9,8 @@ import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
function MarkdownHelp(): JSX.Element {
|
||||
return (
|
||||
<Popover
|
||||
nativeButton
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={280}
|
||||
maxHeight={320}
|
||||
aria-label="Markdown syntax"
|
||||
trigger={
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -28,9 +22,8 @@ function MarkdownHelp(): JSX.Element {
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className={styles.helpContent}>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.helpContent}>
|
||||
<Typography.Text className={styles.helpTitle}>
|
||||
Markdown syntax
|
||||
</Typography.Text>
|
||||
@@ -44,7 +37,7 @@ function MarkdownHelp(): JSX.Element {
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.banner {
|
||||
height: var(--spacing-20);
|
||||
|
||||
a {
|
||||
color: var(--callout-warning-title);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--callout-warning-title);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
import { AlertStrip } from '@signozhq/ui/alert-strip';
|
||||
import { PersistedAnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
|
||||
import styles from './NoAuthBanner.module.scss';
|
||||
|
||||
export function NoAuthBanner(): JSX.Element {
|
||||
return (
|
||||
<AlertStrip.CloseablePersisted
|
||||
color="warning"
|
||||
side="top"
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey="no-auth-banner-v1"
|
||||
testId="no-auth-banner"
|
||||
className={styles.banner}
|
||||
>
|
||||
Impersonation mode: authentication is disabled. Anyone with access to this
|
||||
instance has admin privileges.{' '}
|
||||
<AlertStrip.Link
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/configuration/impersonation-mode/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</AlertStrip.Link>
|
||||
</AlertStrip.CloseablePersisted>
|
||||
</a>
|
||||
</PersistedAnnouncementBanner>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,6 @@ describe('NoAuthBanner', () => {
|
||||
render(<NoAuthBanner />);
|
||||
const link = screen.getByRole('link', { name: /learn more/i });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
|
||||
expect(link).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface ItemBadgeProps {
|
||||
children: ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ItemBadge({ children, testId }: ItemBadgeProps): JSX.Element {
|
||||
return (
|
||||
<Badge textTransform="none" variant="solid" color="secondary" testId={testId}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemBadge;
|
||||
@@ -1,32 +0,0 @@
|
||||
.badges {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow {
|
||||
flex-shrink: 0;
|
||||
--badge-max-width: none;
|
||||
}
|
||||
|
||||
/* Lays every badge out at its natural width, off-screen, to size the visible row. */
|
||||
.measure {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: max-content;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.extraBadges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { type CSSProperties, useRef } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
|
||||
import ItemBadge from './ItemBadge';
|
||||
import type { OverflowBadgeItem } from './types';
|
||||
import { useOverflowBadgesLayout } from './useOverflowBadgesLayout';
|
||||
|
||||
import styles from './OverflowBadges.module.scss';
|
||||
|
||||
interface OverflowBadgesProps {
|
||||
items: OverflowBadgeItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of badges: as many as fit the width inline, the rest revealed on hover
|
||||
* of a "+N" badge. A lone badge too wide to fit truncates instead.
|
||||
*/
|
||||
function OverflowBadges({ items }: OverflowBadgesProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const { visibleCount, firstMaxWidth, isMeasuring } = useOverflowBadgesLayout(
|
||||
items,
|
||||
containerRef,
|
||||
measureRef,
|
||||
);
|
||||
|
||||
const visible = items.slice(0, visibleCount);
|
||||
const extra = items.slice(visibleCount);
|
||||
// The app sets `--badge-max-width` globally, which outranks Badge's `maxWidth` prop.
|
||||
const containerStyle =
|
||||
firstMaxWidth === undefined
|
||||
? undefined
|
||||
: ({ '--badge-max-width': `${firstMaxWidth}px` } as CSSProperties);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.badges} style={containerStyle}>
|
||||
{visible.map(({ key, label }) => (
|
||||
<ItemBadge key={key} testId="overflow-badges-item">
|
||||
{label}
|
||||
</ItemBadge>
|
||||
))}
|
||||
{extra.length > 0 && (
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
title={
|
||||
<div className={styles.extraBadges}>
|
||||
{extra.map(({ key, label }) => (
|
||||
<ItemBadge key={key}>{label}</ItemBadge>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Stop the click so the "+N" badge doesn't trigger the row's click. */}
|
||||
<span
|
||||
role="presentation"
|
||||
className={styles.overflow}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<ItemBadge testId="overflow-badges-more">+{extra.length}</ItemBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isMeasuring && (
|
||||
<div ref={measureRef} className={styles.measure} aria-hidden>
|
||||
{items.map(({ key, label }) => (
|
||||
<ItemBadge key={key}>{label}</ItemBadge>
|
||||
))}
|
||||
<ItemBadge>+{items.length}</ItemBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverflowBadges;
|
||||
@@ -1,74 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import OverflowBadges from '../OverflowBadges';
|
||||
import { computeOverflowBadgesLayout } from '../utils';
|
||||
|
||||
const items = ['alpha', 'beta', 'gamma', 'delta'].map((label) => ({
|
||||
key: label,
|
||||
label,
|
||||
}));
|
||||
|
||||
describe('computeOverflowBadgesLayout', () => {
|
||||
it('fits as many badges as the width allows, keeping room for +N', () => {
|
||||
// 50 + 4 + 50 + 4 + 30 (+N) = 138 fits; a third badge would not.
|
||||
expect(
|
||||
computeOverflowBadgesLayout(140, 4, [50, 50, 50, 50], 30),
|
||||
).toStrictEqual({ visibleCount: 2 });
|
||||
});
|
||||
|
||||
it('shows every badge without +N when all fit', () => {
|
||||
expect(computeOverflowBadgesLayout(200, 4, [50, 50, 50], 30)).toStrictEqual({
|
||||
visibleCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates the first badge when even it does not fit', () => {
|
||||
expect(computeOverflowBadgesLayout(100, 4, [300, 50], 30)).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
firstMaxWidth: 66,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a lone too-wide badge the full width', () => {
|
||||
expect(computeOverflowBadgesLayout(100, 4, [300], 30)).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
firstMaxWidth: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns visibleCount 0 when badgeWidths is empty', () => {
|
||||
expect(computeOverflowBadgesLayout(100, 4, [], 30)).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OverflowBadges', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('collapses the badges that do not fit into +N', () => {
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(160);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: 50 } as DOMRect);
|
||||
|
||||
render(<OverflowBadges items={items} />);
|
||||
|
||||
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(2);
|
||||
expect(screen.getByTestId('overflow-badges-more')).toHaveTextContent('+2');
|
||||
});
|
||||
|
||||
it('omits +N when every badge fits', () => {
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: 50 } as DOMRect);
|
||||
|
||||
render(<OverflowBadges items={items} />);
|
||||
|
||||
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(4);
|
||||
expect(screen.queryByTestId('overflow-badges-more')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Key, ReactNode } from 'react';
|
||||
|
||||
export interface OverflowBadgeItem {
|
||||
key: Key;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
export interface OverflowBadgesLayout {
|
||||
visibleCount: number;
|
||||
/** Set when even the first badge does not fit and must truncate. */
|
||||
firstMaxWidth?: number;
|
||||
}
|
||||
|
||||
export interface OverflowBadgesMeasurement {
|
||||
/** The items these widths were measured for. */
|
||||
itemsKey: string;
|
||||
badgeWidths: number[];
|
||||
overflowWidth: number;
|
||||
gap: number;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { type RefObject, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
OverflowBadgeItem,
|
||||
OverflowBadgesLayout,
|
||||
OverflowBadgesMeasurement,
|
||||
} from './types';
|
||||
import { computeOverflowBadgesLayout } from './utils';
|
||||
|
||||
/**
|
||||
* Fits the badges to the container's width, re-fitting on resize. Badge widths
|
||||
* are measured once per set of items, from a measuring row holding every badge
|
||||
* at its natural width followed by "+N", rendered only while `isMeasuring`.
|
||||
*/
|
||||
export function useOverflowBadgesLayout(
|
||||
items: OverflowBadgeItem[],
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
measureRef: RefObject<HTMLElement>,
|
||||
): OverflowBadgesLayout & { isMeasuring: boolean } {
|
||||
const itemsKey = items.map((item) => String(item.key)).join('\u0000');
|
||||
const [measurement, setMeasurement] =
|
||||
useState<OverflowBadgesMeasurement | null>(null);
|
||||
const [available, setAvailable] = useState(0);
|
||||
const isMeasuring = items.length > 0 && measurement?.itemsKey !== itemsKey;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!isMeasuring || !container || !measure) {
|
||||
return;
|
||||
}
|
||||
// Rounded up so sub-pixel widths never add up past the container.
|
||||
const badgeWidths = Array.from(measure.children, (el) =>
|
||||
Math.ceil(el.getBoundingClientRect().width),
|
||||
);
|
||||
const overflowWidth = badgeWidths.pop() ?? 0;
|
||||
setMeasurement({
|
||||
itemsKey,
|
||||
badgeWidths,
|
||||
overflowWidth,
|
||||
gap: parseFloat(getComputedStyle(container).columnGap) || 0,
|
||||
});
|
||||
}, [isMeasuring, itemsKey, containerRef, measureRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const update = (): void => setAvailable(container.clientWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(container);
|
||||
return (): void => observer.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
const layout = useMemo(
|
||||
(): OverflowBadgesLayout =>
|
||||
isMeasuring || !measurement || available === 0
|
||||
? { visibleCount: items.length }
|
||||
: computeOverflowBadgesLayout(
|
||||
available,
|
||||
measurement.gap,
|
||||
measurement.badgeWidths,
|
||||
measurement.overflowWidth,
|
||||
),
|
||||
[isMeasuring, measurement, available, items.length],
|
||||
);
|
||||
|
||||
return { ...layout, isMeasuring };
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { OverflowBadgesLayout } from './types';
|
||||
|
||||
/**
|
||||
* How many badges fit `available` px, keeping room for the "+N" badge while any
|
||||
* are left out. Always shows at least one, capped to truncate when too wide.
|
||||
*/
|
||||
export const computeOverflowBadgesLayout = (
|
||||
available: number,
|
||||
gap: number,
|
||||
badgeWidths: number[],
|
||||
overflowWidth: number,
|
||||
): OverflowBadgesLayout => {
|
||||
if (badgeWidths.length === 0) {
|
||||
return { visibleCount: 0 };
|
||||
}
|
||||
|
||||
let used = 0;
|
||||
let visibleCount = 0;
|
||||
for (let i = 0; i < badgeWidths.length; i++) {
|
||||
const next = used + (i > 0 ? gap : 0) + badgeWidths[i];
|
||||
const reserve = i < badgeWidths.length - 1 ? gap + overflowWidth : 0;
|
||||
if (next + reserve > available) {
|
||||
break;
|
||||
}
|
||||
used = next;
|
||||
visibleCount++;
|
||||
}
|
||||
if (visibleCount > 0) {
|
||||
return { visibleCount };
|
||||
}
|
||||
const reserve = badgeWidths.length > 1 ? gap + overflowWidth : 0;
|
||||
return { visibleCount: 1, firstMaxWidth: Math.max(available - reserve, 0) };
|
||||
};
|
||||
@@ -102,10 +102,12 @@
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Surface and metrics come from the `--input-*` tokens; the family and tracking are set here. */
|
||||
.numberField {
|
||||
--input-font-family: 'Geist Mono';
|
||||
--input-letter-spacing: -0.07px;
|
||||
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
|
||||
.numberInput {
|
||||
width: 104px;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Unfilled: a pill on the same row as the toggles above reads as another control,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { InputNumber } from '@signozhq/ui/input-number';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { ChevronUp, Info } from '@signozhq/icons';
|
||||
import {
|
||||
@@ -111,17 +111,19 @@ function BucketOptions({
|
||||
);
|
||||
|
||||
const handleMaxValueChange = useCallback(
|
||||
(value: number | null): void => {
|
||||
setMaxValue(value);
|
||||
emitLinear(value, numBuckets);
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setMaxValue(next);
|
||||
emitLinear(next, numBuckets);
|
||||
},
|
||||
[emitLinear, numBuckets],
|
||||
);
|
||||
|
||||
const handleNumBucketsChange = useCallback(
|
||||
(value: number | null): void => {
|
||||
setNumBuckets(value);
|
||||
emitLinear(maxValue, value);
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setNumBuckets(next);
|
||||
emitLinear(maxValue, next);
|
||||
},
|
||||
[emitLinear, maxValue],
|
||||
);
|
||||
@@ -205,29 +207,29 @@ function BucketOptions({
|
||||
|
||||
{kind === Querybuildertypesv5BucketsKindDTO.linear && (
|
||||
<>
|
||||
<div className={cx(styles.field, styles.numberField)}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Max value</span>
|
||||
<Input.Number
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={0}
|
||||
value={maxValue}
|
||||
onChange={handleMaxValueChange}
|
||||
placeholder="Required"
|
||||
status={maxValue !== null && maxValue <= 0 ? 'danger' : undefined}
|
||||
width={104}
|
||||
testId="bucket-options-max-value"
|
||||
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
|
||||
data-testid="bucket-options-max-value"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.field, styles.numberField)}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Buckets</span>
|
||||
<Input.Number
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={1}
|
||||
max={MAX_NUM_BUCKETS}
|
||||
format={{ maximumFractionDigits: 0 }}
|
||||
precision={0}
|
||||
value={numBuckets}
|
||||
onChange={handleNumBucketsChange}
|
||||
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
|
||||
width={104}
|
||||
testId="bucket-options-num-buckets"
|
||||
data-testid="bucket-options-num-buckets"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
import { parseLimit, sanitizeLimit } from '../../utils';
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
@@ -99,7 +99,7 @@ function QueryAddOns({
|
||||
}
|
||||
|
||||
if (
|
||||
panelType === PANEL_TYPES.HEATMAP &&
|
||||
isBucketCountingPanelType(panelType) &&
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
@@ -279,7 +279,7 @@ function QueryAddOns({
|
||||
|
||||
const handleChangeLimit = useCallback(
|
||||
(value: string) => {
|
||||
handleChangeQueryData('limit', parseLimit(value));
|
||||
handleChangeQueryData('limit', Number(value) || null);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
@@ -375,7 +375,6 @@ function QueryAddOns({
|
||||
label="Limit"
|
||||
type="number"
|
||||
onChange={handleChangeLimit}
|
||||
sanitize={sanitizeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder={placeholderOf(QueryBuilderField.Limit)}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
|
||||
@@ -107,8 +107,8 @@ export const BASE_ADD_ONS = (
|
||||
|
||||
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
// Offered only by a heatmap or distribution over metrics: the bucket axis is what they
|
||||
// plot against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
|
||||
&-aggregation-container {
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { QueryBuilderV2 } from '../../QueryBuilderV2';
|
||||
import { QueryBuilderV2Provider } from '../../QueryBuilderV2Context';
|
||||
import { QueryV2 } from '../QueryV2';
|
||||
|
||||
// Local mocks for domain-specific heavy child components
|
||||
jest.mock(
|
||||
@@ -181,7 +179,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
expect(limitInput).toBeInTheDocument();
|
||||
expect(limitInput).toHaveAttribute('type', 'number');
|
||||
expect(limitInput).toHaveAttribute('name', 'limit');
|
||||
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit-field');
|
||||
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit');
|
||||
});
|
||||
|
||||
it('Cmd+Enter on an input triggers handleRunQuery via container handler', async () => {
|
||||
@@ -251,7 +249,11 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
|
||||
describe('query actions menu', () => {
|
||||
const openQueryActions = async (): Promise<void> => {
|
||||
await userEvent.click(screen.getAllByTestId('query-actions')[0]);
|
||||
const trigger = document.querySelector(
|
||||
'.query-actions-container [aria-haspopup="menu"]',
|
||||
) as HTMLElement;
|
||||
expect(trigger).toBeInTheDocument();
|
||||
await userEvent.click(trigger);
|
||||
};
|
||||
|
||||
it('Clone calls cloneQuery with the query', async () => {
|
||||
@@ -259,7 +261,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
|
||||
await openQueryActions();
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('query-actions-item-clone-query'),
|
||||
await screen.findByRole('menuitem', { name: 'Clone' }),
|
||||
);
|
||||
|
||||
expect(baseQBContext.cloneQuery).toHaveBeenCalledTimes(1);
|
||||
@@ -273,44 +275,41 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
|
||||
|
||||
await openQueryActions();
|
||||
await screen.findByTestId('query-actions-item-clone-query');
|
||||
await screen.findByRole('menuitem', { name: 'Clone' });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-actions-item-delete-query'),
|
||||
screen.queryByRole('menuitem', { name: 'Delete' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Delete with more than one query and deletes on click', async () => {
|
||||
const [queryA] = (baseQBContext.currentQuery as Query).builder.queryData;
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
const [queryA] = currentQueryBase.builder.queryData;
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
...baseQBContext,
|
||||
currentQuery: {
|
||||
...currentQueryBase,
|
||||
builder: {
|
||||
...currentQueryBase.builder,
|
||||
queryData: [queryA, { ...queryA, queryName: 'B', legend: 'B' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const handleDeleteQuery = jest.fn();
|
||||
mockedUseQueryOperations.mockReturnValue({
|
||||
...mockedUseQueryOperations({} as Parameters<UseQueryOperations>[0]),
|
||||
handleDeleteQuery,
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryBuilderV2Provider>
|
||||
<QueryV2
|
||||
index={0}
|
||||
query={queryA}
|
||||
version="v4"
|
||||
isAvailableToDisable={false}
|
||||
isMultiQueryAllowed
|
||||
queriesCount={2}
|
||||
onSignalSourceChange={jest.fn()}
|
||||
signalSourceChangeEnabled={false}
|
||||
savePreviousQuery={false}
|
||||
/>
|
||||
</QueryBuilderV2Provider>,
|
||||
);
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
|
||||
|
||||
await openQueryActions();
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('query-actions-item-delete-query'),
|
||||
await screen.findByRole('menuitem', { name: 'Delete' }),
|
||||
);
|
||||
|
||||
expect(handleDeleteQuery).toHaveBeenCalledTimes(1);
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
|
||||
it('fx button is disabled when functions already exist', () => {
|
||||
|
||||
@@ -165,24 +165,17 @@ describe('QueryAddOns', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('input-Limit-field') as HTMLInputElement;
|
||||
const input = screen.getByTestId('input-Limit') as HTMLInputElement;
|
||||
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
|
||||
expect(input.value).toBe('5');
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.clear(input);
|
||||
await user.type(input, '10');
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, '-5');
|
||||
expect(input.value).toBe('5');
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
|
||||
fireEvent.change(input, { target: { value: '10' } });
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[QueryBuilderField.Limit, 'input-Limit-field', 'placeholder'],
|
||||
[QueryBuilderField.Legend, 'input-Legend format-field', 'placeholder'],
|
||||
[QueryBuilderField.Limit, 'input-Limit', 'placeholder'],
|
||||
[QueryBuilderField.Legend, 'input-Legend format', 'placeholder'],
|
||||
[QueryBuilderField.GroupBy, 'groupby', 'data-placeholder'],
|
||||
[QueryBuilderField.Having, 'having-change', 'data-placeholder'],
|
||||
[QueryBuilderField.OrderBy, 'orderby', 'data-placeholder'],
|
||||
@@ -223,7 +216,7 @@ describe('QueryAddOns', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('query-add-on-limit'));
|
||||
|
||||
expect(screen.getByTestId('input-Limit-field')).toHaveAttribute(
|
||||
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Enter limit',
|
||||
);
|
||||
@@ -247,9 +240,7 @@ describe('QueryAddOns', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
|
||||
const limitInput = screen.getByTestId(
|
||||
'input-Limit-field',
|
||||
) as HTMLInputElement;
|
||||
const limitInput = screen.getByTestId('input-Limit') as HTMLInputElement;
|
||||
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
|
||||
expect(limitInput.value).toBe('7');
|
||||
});
|
||||
@@ -440,6 +431,24 @@ describe('QueryAddOns', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is offered on a metrics distribution too', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.DISTRIBUTION}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('query-add-on-bucket_options'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on other panel types', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
@@ -579,7 +588,7 @@ describe('QueryAddOns', () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Linear' }));
|
||||
await user.type(screen.getByTestId('bucket-options-max-value-field'), '500');
|
||||
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user