mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-05 16:00:41 +00:00
Compare commits
10 Commits
feat/geo-m
...
nv/exec-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29a393e47e | ||
|
|
98f9850c53 | ||
|
|
07fd9ac5b8 | ||
|
|
e1fdd1282a | ||
|
|
b5cac39eb7 | ||
|
|
7d6ea4df77 | ||
|
|
b8353eccf8 | ||
|
|
0cc223fb5a | ||
|
|
c58f76f998 | ||
|
|
e39ad9e221 |
@@ -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 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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
40
.claude/rules/frontend-state.md
Normal file
40
.claude/rules/frontend-state.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.
|
||||
20
.claude/rules/frontend-tests.md
Normal file
20
.claude/rules/frontend-tests.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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.
|
||||
15
.claude/rules/log-body.md
Normal file
15
.claude/rules/log-body.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
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,3 +9,6 @@
|
||||
- **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,6 +10,9 @@ 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`. 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).
|
||||
- **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).
|
||||
- **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,6 +143,8 @@ 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
|
||||
|
||||
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -24,7 +24,7 @@ go.mod @therealpandey
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/types/ @therealpandey @srikanthccv
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
@@ -136,7 +136,7 @@ go.mod @therealpandey
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @therealpandey
|
||||
/tests/integration/ @therealpandey @srikanthccv
|
||||
|
||||
# e2e tests
|
||||
/tests/e2e/ @AshwinBhatkal
|
||||
|
||||
24
.github/workflows/jsci.yaml
vendored
24
.github/workflows/jsci.yaml
vendored
@@ -132,27 +132,3 @@ jobs:
|
||||
run: |
|
||||
cd frontend && pnpm generate:config:web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)
|
||||
geo-outlines:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
- name: generate-geo-outlines
|
||||
run: |
|
||||
cd frontend && pnpm build:geo-outlines
|
||||
git status --short
|
||||
test -z "$(git status --porcelain)" || (echo; echo "Unexpected difference in geo map outlines. Run pnpm build:geo-outlines in frontend/ locally and commit."; exit 1)
|
||||
|
||||
@@ -4280,11 +4280,6 @@ components:
|
||||
last_6_hr, last_1_day, last_3_days, last_1_week or last_1_month pin a
|
||||
fixed window.'
|
||||
type: object
|
||||
DashboardtypesBubbleRadius:
|
||||
maximum: 64
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesBuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -4517,231 +4512,6 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesGeoMapBubbles:
|
||||
properties:
|
||||
maxRadius:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
description: Radius of the largest circle, in CSS pixels from 1 to 64. Null
|
||||
means 8 on the points layer, 28 otherwise.
|
||||
minRadius:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
description: Radius of the smallest circle, in CSS pixels from 1 to 64.
|
||||
Null means 2 on the points layer, 3 otherwise.
|
||||
type: object
|
||||
DashboardtypesGeoMapChartAppearance:
|
||||
properties:
|
||||
bubbles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapBubbles'
|
||||
description: The circle radius range of the bubbles and points layers.
|
||||
colors:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapColors'
|
||||
description: The color scale applied to the colorBy values.
|
||||
labels:
|
||||
description: Shows country and state names once each fits inside its shape.
|
||||
Null shows them.
|
||||
nullable: true
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesGeoMapColors:
|
||||
properties:
|
||||
fill:
|
||||
description: The hex color varied in opacity mode. Empty uses the default
|
||||
fill.
|
||||
type: string
|
||||
max:
|
||||
description: The value mapped to the highest color. Null uses the highest
|
||||
value in the data.
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
description: The value mapped to the lowest color. Null uses the lowest
|
||||
value in the data. Must not exceed max.
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
description: 'How values are colored: palette (default) picks a hue from
|
||||
palette, opacity varies the opacity of fill.'
|
||||
palette:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
description: The named color ramp used in palette mode, such as lava (default),
|
||||
ice or ember.
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
description: 'How values map onto the color range: log (default), sqrt or
|
||||
linear.'
|
||||
steps:
|
||||
description: The number of distinct color steps, 2 to 128. 0 uses the chart
|
||||
default.
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesGeoMapCountryFormat:
|
||||
enum:
|
||||
- auto
|
||||
- alpha2
|
||||
- alpha3
|
||||
- name
|
||||
type: string
|
||||
DashboardtypesGeoMapLayer:
|
||||
enum:
|
||||
- regions
|
||||
- bubbles
|
||||
- points
|
||||
type: string
|
||||
DashboardtypesGeoMapLocation:
|
||||
properties:
|
||||
country:
|
||||
description: Group-by key holding the country, for the country and subdivision
|
||||
modes. Empty detects it by name, such as geo.country.iso_code.
|
||||
type: string
|
||||
countryFormat:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapCountryFormat'
|
||||
description: 'How country values are written: auto (default) detects it,
|
||||
alpha2, alpha3 or name.'
|
||||
lat:
|
||||
description: Group-by key holding the latitude, for the coordinates mode.
|
||||
Empty detects it by name.
|
||||
type: string
|
||||
lon:
|
||||
description: Group-by key holding the longitude, for the coordinates mode.
|
||||
Empty detects it by name.
|
||||
type: string
|
||||
mode:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapLocationMode'
|
||||
description: 'How rows are placed: auto (default) detects it from the group-by
|
||||
keys, country, subdivision or coordinates.'
|
||||
region:
|
||||
description: Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code),
|
||||
for the subdivision mode. Empty detects it by name.
|
||||
type: string
|
||||
required:
|
||||
- mode
|
||||
- countryFormat
|
||||
type: object
|
||||
DashboardtypesGeoMapLocationMode:
|
||||
enum:
|
||||
- auto
|
||||
- country
|
||||
- subdivision
|
||||
- coordinates
|
||||
type: string
|
||||
DashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapChartAppearance'
|
||||
description: Color scale, bubble size and labels.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
description: Per-column units and decimal precision.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement.
|
||||
location:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapLocation'
|
||||
description: The group-by columns that place each row on the map.
|
||||
map:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapSettings'
|
||||
description: Layer, view, projection and the value columns drawn.
|
||||
thresholds:
|
||||
description: Rules that recolor a shape, bubble or point when its value
|
||||
compares to a threshold as specified.
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the values are computed over.
|
||||
type: object
|
||||
DashboardtypesGeoMapProjection:
|
||||
enum:
|
||||
- mercator
|
||||
- equal_earth
|
||||
- natural_earth
|
||||
type: string
|
||||
DashboardtypesGeoMapSettings:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Value column key, as in a Table panel, that sets the color.
|
||||
Empty uses the first value column.
|
||||
type: string
|
||||
layer:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapLayer'
|
||||
description: 'What is drawn: regions (default) fills each shape, bubbles
|
||||
draws a circle per region, points a circle per coordinate.'
|
||||
projection:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapProjection'
|
||||
description: 'The map projection: mercator (default), equal_earth or natural_earth.'
|
||||
sizeBy:
|
||||
description: Value column key, as in a Table panel, that sets the bubble
|
||||
or point size. Empty uses colorBy.
|
||||
type: string
|
||||
view:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapView'
|
||||
description: 'The area shown: world (default), fit to the data, a continent,
|
||||
or country with viewCountry.'
|
||||
viewCountry:
|
||||
description: Uppercase ISO 3166-1 alpha-2 code of the country the country
|
||||
view shows.
|
||||
type: string
|
||||
required:
|
||||
- layer
|
||||
- view
|
||||
- projection
|
||||
type: object
|
||||
DashboardtypesGeoMapThreshold:
|
||||
properties:
|
||||
color:
|
||||
description: 'The CSS color of the shape, bubble or point while the threshold
|
||||
is active, for example #F1575F; CSS named colors such as red also work.'
|
||||
type: string
|
||||
operator:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesComparisonOperator'
|
||||
description: 'The comparison that activates the threshold: above (default),
|
||||
below, above_or_equal, below_or_equal, equal or not_equal.'
|
||||
unit:
|
||||
description: The unit id of value, such as ms or percent. Empty means the
|
||||
colorBy column's unit.
|
||||
type: string
|
||||
value:
|
||||
description: The value compared against, in unit.
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesGeoMapView:
|
||||
enum:
|
||||
- world
|
||||
- fit
|
||||
- africa
|
||||
- asia
|
||||
- europe
|
||||
- north_america
|
||||
- south_america
|
||||
- oceania
|
||||
- country
|
||||
type: string
|
||||
DashboardtypesGettableDashboardV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -5479,7 +5249,6 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/GeoMapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5502,7 +5271,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5517,7 +5285,6 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5549,21 +5316,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesGeoMapPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
|
||||
@@ -146,5 +146,5 @@ Do not use Zustand, Redux, or Context for purely local UI state.
|
||||
|
||||
## Additional resources
|
||||
|
||||
- Project rule: [.cursor/rules/state-management.mdc](../../rules/state-management.mdc)
|
||||
- Project rule: [.claude/rules/frontend-state.md](../../../../.claude/rules/frontend-state.md)
|
||||
- Detailed patterns and rationale: [reference.md](reference.md)
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
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) }));
|
||||
```
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -25,15 +25,24 @@ 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. 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. 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:
|
||||
- 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.
|
||||
- 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
|
||||
- Fixed ALL resulting errors
|
||||
|
||||
4. BEHAVIOR CHANGE DETECTION: When modifying existing behavior:
|
||||
@@ -41,6 +50,14 @@ 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.
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|d3-geo|d3-array|internmap|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|d3-geo|d3-array|internmap|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
|
||||
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'",
|
||||
"build:geo-outlines": "node scripts/geo-outlines/build.mjs"
|
||||
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
@@ -79,10 +78,9 @@
|
||||
"classnames": "2.3.2",
|
||||
"color": "^4.2.1",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-geo": "3.1.1",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.4.15",
|
||||
"dompurify": "3.4.17",
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
@@ -98,7 +96,6 @@
|
||||
"monaco-editor": "0.55.1",
|
||||
"motion": "12.4.13",
|
||||
"nuqs": "2.8.8",
|
||||
"ol": "10.11.0",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "5.4.1",
|
||||
@@ -133,7 +130,6 @@
|
||||
"rrule": "2.8.1",
|
||||
"styled-components": "^5.3.11",
|
||||
"timestamp-nano": "^1.0.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"translation-resilience": "^0.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"uplot": "1.6.31",
|
||||
@@ -174,10 +170,8 @@
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3-geo": "3.1.1",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/geojson": "7946.0.16",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
@@ -194,8 +188,6 @@
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/topojson-specification": "1.0.5",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260430.1",
|
||||
"eslint-plugin-sonarjs": "4.0.2",
|
||||
"husky": "^9.1.7",
|
||||
@@ -208,19 +200,17 @@
|
||||
"oxfmt": "0.73.0",
|
||||
"oxlint": "1.88.0",
|
||||
"oxlint-tsgolint": "7.0.2004",
|
||||
"postcss": "8.5.26",
|
||||
"postcss": "8.5.32",
|
||||
"postcss-scss": "4.0.9",
|
||||
"react-resizable": "3.0.4",
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.35.4",
|
||||
"sharp": "0.35.5",
|
||||
"storybook": "10.6.3",
|
||||
"stylelint": "17.15.0",
|
||||
"svgo": "4.1.0",
|
||||
"topojson-server": "3.0.1",
|
||||
"topojson-simplify": "3.0.3",
|
||||
"ts-jest": "29.4.15",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"typescript-plugin-css-modules": "5.3.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
|
||||
666
frontend/pnpm-lock.yaml
generated
666
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.15, but @grafana/data
|
||||
# remove: blocked. The direct dep is already on 3.4.17, but @grafana/data
|
||||
# (latest 13.1.0) and monaco-editor (latest 0.55.1) still pin vulnerable versions
|
||||
dompurify@<=3.4.12: '>=3.4.13 <4'
|
||||
dompurify@<=3.4.16: '>=3.4.17 <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,17 +60,10 @@ 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.2.0 > postcss-modules-local-by-default@4.2.0
|
||||
# via: typescript-plugin-css-modules@5.3.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'
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
// Builds the Geo Map outlines assets: Natural Earth 50m admin-0 countries, and
|
||||
// 10m admin-1 states/provinces for every country with at least two of them.
|
||||
// Run by hand when bumping Natural Earth or editing regions/aliases; the output is committed.
|
||||
// Usage: pnpm build:geo-outlines
|
||||
//
|
||||
// Nothing is written until every invariant in validate.mjs holds. The assets
|
||||
// are built in a staging folder and swapped in whole, so a failed run leaves
|
||||
// the committed ones as they were.
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
import {
|
||||
ASSETS_DIR,
|
||||
COUNTRIES_URL,
|
||||
SUBDIVISION_LIST_FILE,
|
||||
SUBDIVISIONS_URL,
|
||||
} from './config.mjs';
|
||||
import { buildCountries } from './countries.mjs';
|
||||
import { buildSubdivisions } from './subdivisions.mjs';
|
||||
import { validate } from './validate.mjs';
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const DOWNLOAD_ATTEMPTS = 3;
|
||||
|
||||
const STAGING_DIR = `${ASSETS_DIR}.staging`;
|
||||
const PREVIOUS_DIR = `${ASSETS_DIR}.previous`;
|
||||
// Everything in the assets folder is generated; anything else would be lost
|
||||
// in the swap, so its presence stops the build.
|
||||
const GENERATED_ENTRIES = new Set([
|
||||
'countries-50m.json',
|
||||
'lookups.json',
|
||||
'subdivisions',
|
||||
]);
|
||||
|
||||
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
|
||||
const sizeOf = (json) =>
|
||||
`${kb(Buffer.byteLength(json))} raw / ${kb(gzipSync(json).length)} gzip`;
|
||||
|
||||
async function downloadFeatures(url) {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
if (
|
||||
body?.type !== 'FeatureCollection' ||
|
||||
!Array.isArray(body.features) ||
|
||||
body.features.length === 0
|
||||
) {
|
||||
throw new Error('response is not a non-empty GeoJSON FeatureCollection');
|
||||
}
|
||||
return body;
|
||||
} catch (error) {
|
||||
if (attempt === DOWNLOAD_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`download failed after ${attempt} attempts: ${url}\n ${error.message}`,
|
||||
);
|
||||
}
|
||||
await sleep(attempt * 2_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subdivisionListSource(countries) {
|
||||
return `// Generated by scripts/geo-outlines/build.mjs; do not edit.
|
||||
export const SUBDIVISION_COUNTRY_CODES = [
|
||||
${countries.map((country) => `\t'${country}',`).join('\n')}
|
||||
] as const;
|
||||
`;
|
||||
}
|
||||
|
||||
// A run that died mid-swap leaves the assets in the previous folder.
|
||||
function recoverInterruptedSwap() {
|
||||
if (!existsSync(ASSETS_DIR) && existsSync(PREVIOUS_DIR)) {
|
||||
renameSync(PREVIOUS_DIR, ASSETS_DIR);
|
||||
}
|
||||
rmSync(STAGING_DIR, { recursive: true, force: true });
|
||||
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function assertOnlyGeneratedEntries() {
|
||||
if (!existsSync(ASSETS_DIR)) {
|
||||
return;
|
||||
}
|
||||
const foreign = readdirSync(ASSETS_DIR).filter(
|
||||
(entry) => !GENERATED_ENTRIES.has(entry),
|
||||
);
|
||||
if (foreign.length > 0) {
|
||||
throw new Error(
|
||||
`${ASSETS_DIR} holds files this build doesn't generate: ${foreign.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes every output under the staging folder; returns the sizes report. */
|
||||
function stage(outputs) {
|
||||
const subdivisionDir = join(STAGING_DIR, 'subdivisions');
|
||||
mkdirSync(subdivisionDir, { recursive: true });
|
||||
writeFileSync(join(STAGING_DIR, 'countries-50m.json'), outputs.countriesJson);
|
||||
writeFileSync(join(STAGING_DIR, 'lookups.json'), outputs.lookupsJson);
|
||||
const sizes = [];
|
||||
for (const [country, file] of outputs.subdivisionFiles) {
|
||||
const json = JSON.stringify(file);
|
||||
writeFileSync(join(subdivisionDir, `${country}.json`), json);
|
||||
sizes.push({
|
||||
country,
|
||||
shapes: file.objects.subdivisions.geometries.length,
|
||||
gzip: gzipSync(json).length,
|
||||
});
|
||||
}
|
||||
writeFileSync(`${SUBDIVISION_LIST_FILE}.staging`, outputs.subdivisionList);
|
||||
return sizes;
|
||||
}
|
||||
|
||||
/** Swaps the staged assets in; puts the previous ones back if that fails. */
|
||||
function commitStaged() {
|
||||
const hadAssets = existsSync(ASSETS_DIR);
|
||||
if (hadAssets) {
|
||||
renameSync(ASSETS_DIR, PREVIOUS_DIR);
|
||||
}
|
||||
try {
|
||||
renameSync(STAGING_DIR, ASSETS_DIR);
|
||||
// A failed rename leaves the previous list in place.
|
||||
renameSync(`${SUBDIVISION_LIST_FILE}.staging`, SUBDIVISION_LIST_FILE);
|
||||
} catch (error) {
|
||||
rmSync(ASSETS_DIR, { recursive: true, force: true });
|
||||
if (hadAssets) {
|
||||
renameSync(PREVIOUS_DIR, ASSETS_DIR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function cleanUpStaging() {
|
||||
rmSync(STAGING_DIR, { recursive: true, force: true });
|
||||
rmSync(`${SUBDIVISION_LIST_FILE}.staging`, { force: true });
|
||||
}
|
||||
|
||||
function report(countries, subdivisions, outputs, sizes) {
|
||||
const totalGzip = sizes.reduce((sum, { gzip }) => sum + gzip, 0);
|
||||
return {
|
||||
countries: {
|
||||
...countries.report,
|
||||
sizes: {
|
||||
'countries-50m.json': sizeOf(outputs.countriesJson),
|
||||
'lookups.json': sizeOf(outputs.lookupsJson),
|
||||
},
|
||||
},
|
||||
subdivisions: {
|
||||
...subdivisions.report,
|
||||
countries: sizes.length,
|
||||
totalGzip: kb(totalGzip),
|
||||
largest: [...sizes]
|
||||
.sort((a, b) => b.gzip - a.gzip)
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
({ country, shapes, gzip }) =>
|
||||
`${country}: ${shapes} shapes, ${kb(gzip)} gzip`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
recoverInterruptedSwap();
|
||||
assertOnlyGeneratedEntries();
|
||||
|
||||
const [countrySource, subdivisionSource] = await Promise.all([
|
||||
downloadFeatures(COUNTRIES_URL),
|
||||
downloadFeatures(SUBDIVISIONS_URL),
|
||||
]);
|
||||
const countries = buildCountries(countrySource);
|
||||
const subdivisions = buildSubdivisions(subdivisionSource);
|
||||
validate(countries, subdivisions);
|
||||
|
||||
const outputs = {
|
||||
countriesJson: JSON.stringify(countries.topology),
|
||||
lookupsJson: JSON.stringify(countries.lookups),
|
||||
subdivisionFiles: subdivisions.files,
|
||||
subdivisionList: subdivisionListSource([...subdivisions.files.keys()]),
|
||||
};
|
||||
try {
|
||||
const sizes = stage(outputs);
|
||||
commitStaged();
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(report(countries, subdivisions, outputs, sizes), null, 2)}\n`,
|
||||
);
|
||||
} finally {
|
||||
cleanUpStaging();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`Geo outlines build failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
// Inputs and tuning for the Geo Map outlines build. Tables only; no logic.
|
||||
// Read by every other module here; not run on its own.
|
||||
//
|
||||
// Edit this file to bump Natural Earth (the pinned version below), retune
|
||||
// simplification, or fix a region, alias or merge; then run
|
||||
// pnpm build:geo-outlines and commit the rebuilt assets.
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const NATURAL_EARTH_BASE =
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson';
|
||||
export const COUNTRIES_URL = `${NATURAL_EARTH_BASE}/ne_50m_admin_0_countries.geojson`;
|
||||
export const SUBDIVISIONS_URL = `${NATURAL_EARTH_BASE}/ne_10m_admin_1_states_provinces.geojson`;
|
||||
|
||||
const GEO_MAP_DIR = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../../src/lib/visualization/charts/GeoMap',
|
||||
);
|
||||
export const ASSETS_DIR = join(GEO_MAP_DIR, 'assets');
|
||||
export const SUBDIVISION_LIST_FILE = join(
|
||||
GEO_MAP_DIR,
|
||||
'geo/subdivisionCountries.ts',
|
||||
);
|
||||
|
||||
export const COUNTRY_QUANTIZATION = 1e5;
|
||||
// Simplification drops every point whose triangle with its neighbours is
|
||||
// smaller than this, in square degrees. A fixed size, unlike a share of all
|
||||
// points, still leaves a small island a few points of its own.
|
||||
export const COUNTRY_MIN_WEIGHT = 3e-3;
|
||||
// 10m is street-level detail; a country view needs far less.
|
||||
export const SUBDIVISION_QUANTIZATION = 1e4;
|
||||
export const SUBDIVISION_MIN_WEIGHT = 1e-3;
|
||||
// A shape this many times the minimum weight or smaller could lose every polygon.
|
||||
export const PROTECTED_SHAPE_FACTOR = 10;
|
||||
|
||||
// A country view needs at least this many coded states to be worth drawing.
|
||||
export const MIN_SUBDIVISIONS = 2;
|
||||
|
||||
// ISO 3166-2 codes that changed after Natural Earth v5.1.2, or older spellings
|
||||
// still in GeoIP data, mapped to the code the shape carries.
|
||||
export const SUBDIVISION_ALIASES = {
|
||||
IN: {
|
||||
'IN-CG': 'IN-CT',
|
||||
'IN-UK': 'IN-UT',
|
||||
'IN-TS': 'IN-TG',
|
||||
'IN-OD': 'IN-OR',
|
||||
'IN-DD': 'IN-DH',
|
||||
'IN-DN': 'IN-DH',
|
||||
},
|
||||
// Natural Earth carries Metro Manila under its retired code.
|
||||
PH: { 'PH-00': 'PH-MNL' },
|
||||
};
|
||||
|
||||
// Shapes Natural Earth still splits along retired lines, merged into the codes
|
||||
// GeoIP reports now; the retired codes become aliases. Northern Ireland's 26
|
||||
// districts became 11 councils in 2015, each a union of old districts.
|
||||
// Current code → [current name, retired codes].
|
||||
export const SUBDIVISION_MERGES = {
|
||||
GB: {
|
||||
'GB-ANN': ['Antrim and Newtownabbey', ['GB-ANT', 'GB-NTA']],
|
||||
'GB-AND': ['Ards and North Down', ['GB-ARD', 'GB-NDN']],
|
||||
'GB-ABC': [
|
||||
'Armagh City, Banbridge and Craigavon',
|
||||
['GB-ARM', 'GB-BNB', 'GB-CGV'],
|
||||
],
|
||||
'GB-BFS': ['Belfast', ['GB-BFS']],
|
||||
'GB-CCG': [
|
||||
'Causeway Coast and Glens',
|
||||
['GB-BLY', 'GB-CLR', 'GB-LMV', 'GB-MYL'],
|
||||
],
|
||||
'GB-DRS': ['Derry City and Strabane', ['GB-DRY', 'GB-STB']],
|
||||
'GB-FMO': ['Fermanagh and Omagh', ['GB-FER', 'GB-OMH']],
|
||||
'GB-LBC': ['Lisburn and Castlereagh', ['GB-LSB', 'GB-CSR']],
|
||||
'GB-MEA': ['Mid and East Antrim', ['GB-BLA', 'GB-CKF', 'GB-LRN']],
|
||||
'GB-MUL': ['Mid Ulster', ['GB-CKT', 'GB-DGN', 'GB-MFT']],
|
||||
'GB-NMD': ['Newry, Mourne and Down', ['GB-NYM', 'GB-DOW']],
|
||||
},
|
||||
};
|
||||
|
||||
// Natural Earth puts these islands in "Seven seas (open ocean)".
|
||||
export const OPEN_OCEAN_REGION = {
|
||||
Africa: 'africa',
|
||||
Asia: 'asia',
|
||||
Americas: 'south_america',
|
||||
};
|
||||
export const CONTINENT_REGION = {
|
||||
Africa: 'africa',
|
||||
Asia: 'asia',
|
||||
Europe: 'europe',
|
||||
'North America': 'north_america',
|
||||
'South America': 'south_america',
|
||||
Oceania: 'oceania',
|
||||
};
|
||||
// Transcontinental countries belong to every region they span.
|
||||
export const EXTRA_REGIONS = {
|
||||
RU: ['asia'],
|
||||
TR: ['europe'],
|
||||
KZ: ['europe'],
|
||||
GE: ['europe'],
|
||||
AM: ['europe'],
|
||||
AZ: ['europe'],
|
||||
CY: ['europe'],
|
||||
EG: ['asia'],
|
||||
};
|
||||
|
||||
// ISO codes GeoIP emits for places Natural Earth draws inside another country.
|
||||
export const COUNTRY_ALIASES = {
|
||||
GF: 'FR',
|
||||
GP: 'FR',
|
||||
MQ: 'FR',
|
||||
RE: 'FR',
|
||||
YT: 'FR',
|
||||
BQ: 'NL',
|
||||
SJ: 'NO',
|
||||
BV: 'NO',
|
||||
CX: 'AU',
|
||||
CC: 'AU',
|
||||
HM: 'AU',
|
||||
UM: 'US',
|
||||
TK: 'NZ',
|
||||
AC: 'SH',
|
||||
TA: 'SH',
|
||||
DG: 'IO',
|
||||
CP: 'FR',
|
||||
FX: 'FR',
|
||||
EA: 'ES',
|
||||
IC: 'ES',
|
||||
CQ: 'GB',
|
||||
GI: 'GB',
|
||||
// Retired or reserved codes still seen in older data.
|
||||
UK: 'GB',
|
||||
SU: 'RU',
|
||||
YU: 'RS',
|
||||
CS: 'RS',
|
||||
TP: 'TL',
|
||||
BU: 'MM',
|
||||
ZR: 'CD',
|
||||
DD: 'DE',
|
||||
VD: 'VN',
|
||||
YD: 'YE',
|
||||
RH: 'ZW',
|
||||
NH: 'VU',
|
||||
HV: 'BF',
|
||||
DY: 'BJ',
|
||||
AN: 'CW',
|
||||
};
|
||||
export const COUNTRY_NAME_ALIASES = {
|
||||
usa: 'US',
|
||||
'united states of america': 'US',
|
||||
america: 'US',
|
||||
uk: 'GB',
|
||||
'great britain': 'GB',
|
||||
britain: 'GB',
|
||||
england: 'GB',
|
||||
russia: 'RU',
|
||||
'south korea': 'KR',
|
||||
'north korea': 'KP',
|
||||
czechia: 'CZ',
|
||||
'czech republic': 'CZ',
|
||||
'ivory coast': 'CI',
|
||||
turkiye: 'TR',
|
||||
türkiye: 'TR',
|
||||
burma: 'MM',
|
||||
'cape verde': 'CV',
|
||||
swaziland: 'SZ',
|
||||
macedonia: 'MK',
|
||||
'east timor': 'TL',
|
||||
vatican: 'VA',
|
||||
};
|
||||
|
||||
// Alpha-2 codes Intl names that aren't countries, so none needs a shape.
|
||||
export const NON_COUNTRY_CODES = new Set([
|
||||
'EU',
|
||||
'EZ',
|
||||
'UN',
|
||||
'ZZ',
|
||||
'QO',
|
||||
'XA',
|
||||
'XB',
|
||||
'AQ',
|
||||
]);
|
||||
@@ -1,190 +0,0 @@
|
||||
// Natural Earth admin-0 → the countries topology and the lookup tables.
|
||||
// Called by build.mjs; not run on its own.
|
||||
//
|
||||
// Merges each country's parts into one shape by ISO alpha-2, drops Antarctica,
|
||||
// assigns view regions and builds the alpha-3/name/alias lookups
|
||||
// normalizeCountry reads. Returns the topology, the lookups and a report.
|
||||
import { geoArea, geoBounds } from 'd3-geo';
|
||||
import { feature } from 'topojson-client';
|
||||
|
||||
import {
|
||||
CONTINENT_REGION,
|
||||
COUNTRY_ALIASES,
|
||||
COUNTRY_MIN_WEIGHT,
|
||||
COUNTRY_NAME_ALIASES,
|
||||
COUNTRY_QUANTIZATION,
|
||||
EXTRA_REGIONS,
|
||||
NON_COUNTRY_CODES,
|
||||
OPEN_OCEAN_REGION,
|
||||
} from './config.mjs';
|
||||
import {
|
||||
polygonsOf,
|
||||
round,
|
||||
roundBounds,
|
||||
simplifyAndQuantize,
|
||||
} from './geometry.mjs';
|
||||
|
||||
export const isCountryCode = (value) => /^[A-Z]{2}$/.test(value ?? '');
|
||||
|
||||
function regionsOf(props, iso2) {
|
||||
const base = props.CONTINENT.startsWith('Seven seas')
|
||||
? OPEN_OCEAN_REGION[props.REGION_UN]
|
||||
: CONTINENT_REGION[props.CONTINENT];
|
||||
return [...new Set([base, ...(EXTRA_REGIONS[iso2] ?? [])].filter(Boolean))];
|
||||
}
|
||||
|
||||
// Group features by code so a country's overseas parts (Australia's Indian
|
||||
// Ocean territories) become one shape that one data row colours.
|
||||
function groupByCode(sourceFeatures, report) {
|
||||
const groups = new Map();
|
||||
for (const f of sourceFeatures) {
|
||||
const p = f.properties;
|
||||
if (p.CONTINENT === 'Antarctica') {
|
||||
report.dropped.push(p.NAME);
|
||||
continue;
|
||||
}
|
||||
const iso2 = isCountryCode(p.ISO_A2_EH) ? p.ISO_A2_EH : null;
|
||||
const key = iso2 ?? `uncoded:${p.NAME}`;
|
||||
if (!iso2) {
|
||||
report.uncoded.push(p.NAME);
|
||||
}
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key).push(f);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function toCountryFeature(key, members, report) {
|
||||
// The sovereign part carries the name, label point and region.
|
||||
const primary =
|
||||
members.find((f) => f.properties.ISO_A2 === f.properties.ISO_A2_EH) ??
|
||||
members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
|
||||
const p = primary.properties;
|
||||
const iso2 = key.startsWith('uncoded:') ? null : key;
|
||||
if (members.length > 1) {
|
||||
report.merged.push(
|
||||
`${iso2}: ${members.map((f) => f.properties.NAME).join(' + ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const polygons = members.flatMap((f) => polygonsOf(f.geometry));
|
||||
const geometry = { type: 'MultiPolygon', coordinates: polygons };
|
||||
const mainland = polygons
|
||||
.map((coordinates) => ({ type: 'Polygon', coordinates }))
|
||||
.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: iso2 ?? p.NAME,
|
||||
properties: {
|
||||
code: iso2,
|
||||
iso3: /^[A-Z]{3}$/.test(p.ISO_A3_EH) ? p.ISO_A3_EH : null,
|
||||
name: p.NAME,
|
||||
label: [round(p.LABEL_X), round(p.LABEL_Y)],
|
||||
mainland: roundBounds(geoBounds(mainland)),
|
||||
bounds: roundBounds(geoBounds(geometry)),
|
||||
regions: regionsOf(p, iso2 ?? ''),
|
||||
},
|
||||
geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Alpha-3 codes and English names → alpha-2, for normalising data. */
|
||||
function buildLookups(sourceFeatures) {
|
||||
const alpha3 = {};
|
||||
const names = { ...COUNTRY_NAME_ALIASES };
|
||||
for (const { properties: p } of sourceFeatures) {
|
||||
if (!isCountryCode(p.ISO_A2_EH) || p.CONTINENT === 'Antarctica') {
|
||||
continue;
|
||||
}
|
||||
if (/^[A-Z]{3}$/.test(p.ISO_A3_EH)) {
|
||||
alpha3[p.ISO_A3_EH] = p.ISO_A2_EH;
|
||||
}
|
||||
for (const name of [
|
||||
p.NAME,
|
||||
p.NAME_LONG,
|
||||
p.ADMIN,
|
||||
p.NAME_EN,
|
||||
p.BRK_NAME,
|
||||
p.NAME_SORT,
|
||||
p.NAME_CIAWF,
|
||||
]) {
|
||||
if (name) {
|
||||
names[name.toLowerCase()] ??= p.ISO_A2_EH;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alpha3, names, aliases: COUNTRY_ALIASES };
|
||||
}
|
||||
|
||||
/** ISO alpha-2 codes Intl knows that neither a shape nor an alias covers. */
|
||||
function findUncoveredCodes(drawn) {
|
||||
const display = new Intl.DisplayNames(['en'], {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
});
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const uncovered = [];
|
||||
for (const a of letters) {
|
||||
for (const b of letters) {
|
||||
const code = a + b;
|
||||
const name = display.of(code);
|
||||
if (
|
||||
name &&
|
||||
!NON_COUNTRY_CODES.has(code) &&
|
||||
!drawn.has(code) &&
|
||||
!COUNTRY_ALIASES[code]
|
||||
) {
|
||||
uncovered.push(`${code} ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uncovered;
|
||||
}
|
||||
|
||||
/** Natural Earth admin-0 GeoJSON → `{ topology, lookups, report }`. */
|
||||
export function buildCountries(source) {
|
||||
const report = { dropped: [], uncoded: [], merged: [] };
|
||||
const features = [...groupByCode(source.features, report)]
|
||||
.map(([key, members]) => toCountryFeature(key, members, report))
|
||||
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
|
||||
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
|
||||
'countries',
|
||||
features,
|
||||
COUNTRY_MIN_WEIGHT,
|
||||
COUNTRY_QUANTIZATION,
|
||||
);
|
||||
const drawn = new Set(features.map((f) => f.properties.code).filter(Boolean));
|
||||
// Lets data be checked against the drawn codes without decoding the outlines.
|
||||
const lookups = { codes: [...drawn].sort(), ...buildLookups(source.features) };
|
||||
|
||||
const regionCounts = {};
|
||||
for (const f of features) {
|
||||
for (const region of f.properties.regions) {
|
||||
regionCounts[region] = (regionCounts[region] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
topology: topo,
|
||||
lookups,
|
||||
report: {
|
||||
features: features.length,
|
||||
decodedFeatures: feature(topo, topo.objects.countries).features.length,
|
||||
droppedFlippedIslands: flipped,
|
||||
droppedCollapsedPolygons: collapsed,
|
||||
emptied: emptied.join(' '),
|
||||
regionCounts,
|
||||
...report,
|
||||
lookupCounts: {
|
||||
alpha3: Object.keys(lookups.alpha3).length,
|
||||
names: Object.keys(lookups.names).length,
|
||||
aliases: Object.keys(lookups.aliases).length,
|
||||
},
|
||||
uncoveredIsoCodes: findUncoveredCodes(drawn),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Geometry helpers and the simplification shared by countries and states.
|
||||
// Used by countries.mjs and subdivisions.mjs; not run on its own.
|
||||
//
|
||||
// Simplification protects small shapes so islands keep an outline, and drops
|
||||
// polygons that collapse to slivers or flip inside out after quantizing.
|
||||
import { geoArea } from 'd3-geo';
|
||||
import { topology } from 'topojson-server';
|
||||
import { presimplify, simplify } from 'topojson-simplify';
|
||||
import { feature, quantize } from 'topojson-client';
|
||||
|
||||
import { PROTECTED_SHAPE_FACTOR } from './config.mjs';
|
||||
|
||||
export const round = (n) => Math.round(n * 1e3) / 1e3;
|
||||
export const roundBounds = (bounds) =>
|
||||
bounds.map(([x, y]) => [round(x), round(y)]);
|
||||
|
||||
export function polygonsOf(geometry) {
|
||||
if (geometry.type === 'Polygon') {
|
||||
return [geometry.coordinates];
|
||||
}
|
||||
if (geometry.type === 'MultiPolygon') {
|
||||
return geometry.coordinates;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Area of a lon/lat ring in square degrees, the unit of the simplify weights. */
|
||||
function planarRingArea(ring) {
|
||||
let twice = 0;
|
||||
ring.forEach(([x, y], i) => {
|
||||
const [nx, ny] = ring[(i + 1) % ring.length];
|
||||
twice += x * ny - nx * y;
|
||||
});
|
||||
return Math.abs(twice) / 2;
|
||||
}
|
||||
|
||||
// Weights every point of a small shape's largest outline as Infinity, which
|
||||
// simplify never removes, so a city-state or city-sized province (Monaco, a
|
||||
// London borough) still has an outline to colour. Neighbours share those arcs,
|
||||
// so their common border stays in step.
|
||||
function protectSmallShapes(topo, objectName, features, minWeight) {
|
||||
topo.objects[objectName].geometries.forEach((geometry, i) => {
|
||||
const polygons = polygonsOf(features[i].geometry);
|
||||
const areas = polygons.map(([outer]) => planarRingArea(outer));
|
||||
const total = areas.reduce((sum, area) => sum + area, 0);
|
||||
if (polygons.length === 0 || total > minWeight * PROTECTED_SHAPE_FACTOR) {
|
||||
return;
|
||||
}
|
||||
const largest = areas.indexOf(Math.max(...areas));
|
||||
const rings =
|
||||
geometry.type === 'Polygon' ? geometry.arcs : geometry.arcs[largest];
|
||||
rings[0].forEach((index) => {
|
||||
topo.arcs[index < 0 ? ~index : index].forEach((point) => {
|
||||
point[2] = Infinity;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const isCollapsed = ([outer]) =>
|
||||
new Set(outer.map((point) => point.join())).size < 3;
|
||||
|
||||
// Simplifying can collapse a tiny island until its ring runs the other way; d3
|
||||
// then reads it as "the whole globe except this island" and floods the map.
|
||||
const isInverted = (coordinates) =>
|
||||
geoArea({ type: 'Polygon', coordinates }) > 2 * Math.PI;
|
||||
|
||||
/**
|
||||
* One feature collection to a simplified, quantized topology under
|
||||
* `objectName`. Polygons that collapse to a sliver or flip inside out are
|
||||
* dropped and the topology rebuilt without them.
|
||||
*/
|
||||
export function simplifyAndQuantize(
|
||||
objectName,
|
||||
features,
|
||||
minWeight,
|
||||
quantization,
|
||||
) {
|
||||
let topo = presimplify(
|
||||
topology({ [objectName]: { type: 'FeatureCollection', features } }),
|
||||
);
|
||||
protectSmallShapes(topo, objectName, features, minWeight);
|
||||
topo = simplify(topo, minWeight);
|
||||
// presimplify stores each point's weight as a third coordinate and simplify
|
||||
// keeps it, so it is dropped before quantizing (which would carry it through).
|
||||
topo = { ...topo, arcs: topo.arcs.map((arc) => arc.map(([x, y]) => [x, y])) };
|
||||
topo = quantize(topo, quantization);
|
||||
|
||||
let flipped = 0;
|
||||
let collapsed = 0;
|
||||
const cleaned = feature(topo, topo.objects[objectName]).features.map((f) => {
|
||||
const kept = polygonsOf(f.geometry).filter((coordinates) => {
|
||||
if (isCollapsed(coordinates)) {
|
||||
collapsed += 1;
|
||||
return false;
|
||||
}
|
||||
if (isInverted(coordinates)) {
|
||||
flipped += 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { ...f, geometry: { type: 'MultiPolygon', coordinates: kept } };
|
||||
});
|
||||
const emptied = cleaned
|
||||
.filter((f) => f.geometry.coordinates.length === 0)
|
||||
.map((f) => f.properties.code ?? f.properties.name);
|
||||
|
||||
if (flipped > 0 || collapsed > 0) {
|
||||
topo = quantize(
|
||||
topology({
|
||||
[objectName]: { type: 'FeatureCollection', features: cleaned },
|
||||
}),
|
||||
quantization,
|
||||
);
|
||||
}
|
||||
return { topo, flipped, collapsed, emptied };
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Natural Earth admin-1 → one states/provinces topology per country.
|
||||
// Called by build.mjs; not run on its own.
|
||||
//
|
||||
// Skips countries with fewer than MIN_SUBDIVISIONS coded states, merges shapes
|
||||
// split along retired lines, and records aliases for renamed codes. Returns one
|
||||
// file per country and a report of what was skipped, merged or dropped.
|
||||
import { geoArea } from 'd3-geo';
|
||||
import { topology } from 'topojson-server';
|
||||
import { merge } from 'topojson-client';
|
||||
|
||||
import {
|
||||
MIN_SUBDIVISIONS,
|
||||
SUBDIVISION_ALIASES,
|
||||
SUBDIVISION_MERGES,
|
||||
SUBDIVISION_MIN_WEIGHT,
|
||||
SUBDIVISION_QUANTIZATION,
|
||||
} from './config.mjs';
|
||||
import { isCountryCode } from './countries.mjs';
|
||||
import { round, simplifyAndQuantize } from './geometry.mjs';
|
||||
|
||||
const SUBDIVISION_CODE = /^[A-Z]{2}-[A-Z0-9]{1,3}$/;
|
||||
|
||||
/** Retired code → `[current code, current name]`, from `SUBDIVISION_MERGES`. */
|
||||
const RECODES = new Map(
|
||||
Object.values(SUBDIVISION_MERGES).flatMap((merges) =>
|
||||
Object.entries(merges).flatMap(([code, [name, retired]]) =>
|
||||
retired.map((old) => [old, [code, name]]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/** A country's aliases: the hand-written ones, then each merge's retired codes. */
|
||||
function aliasesOf(country) {
|
||||
const aliases = { ...SUBDIVISION_ALIASES[country] };
|
||||
for (const [code, [, retired]] of Object.entries(
|
||||
SUBDIVISION_MERGES[country] ?? {},
|
||||
)) {
|
||||
for (const old of retired) {
|
||||
if (old !== code) {
|
||||
aliases[old] = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
// Shapes without an ISO 3166-2 code can't be matched to data, and a code under
|
||||
// another country's prefix (Puerto Rico's `US-PR`) belongs to that country's
|
||||
// file. Retired codes are rewritten to the current ones here, so the merge
|
||||
// below dissolves them.
|
||||
function groupByCountry(sourceFeatures) {
|
||||
const byCountry = new Map();
|
||||
const uncoded = {};
|
||||
const recoded = new Set();
|
||||
for (const f of sourceFeatures) {
|
||||
const country = f.properties.iso_a2;
|
||||
if (!isCountryCode(country)) {
|
||||
continue;
|
||||
}
|
||||
const code = f.properties.iso_3166_2;
|
||||
if (!SUBDIVISION_CODE.test(code ?? '') || !code.startsWith(`${country}-`)) {
|
||||
uncoded[country] = (uncoded[country] ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (!byCountry.has(country)) {
|
||||
byCountry.set(country, []);
|
||||
}
|
||||
const recode = RECODES.get(code);
|
||||
if (recode) {
|
||||
recoded.add(code);
|
||||
}
|
||||
byCountry.get(country).push(
|
||||
recode
|
||||
? {
|
||||
...f,
|
||||
properties: {
|
||||
...f.properties,
|
||||
iso_3166_2: recode[0],
|
||||
name: recode[1],
|
||||
},
|
||||
}
|
||||
: f,
|
||||
);
|
||||
}
|
||||
return { byCountry, uncoded, recoded };
|
||||
}
|
||||
|
||||
// Natural Earth splits some codes across shapes (Dublin's four councils are all
|
||||
// `IE-D`). Merging them through a topology drops the borders between the parts,
|
||||
// so one code is one shape with one outline.
|
||||
function mergeByCode(features) {
|
||||
const byCode = new Map();
|
||||
for (const f of features) {
|
||||
const code = f.properties.iso_3166_2;
|
||||
if (!byCode.has(code)) {
|
||||
byCode.set(code, []);
|
||||
}
|
||||
byCode.get(code).push(f);
|
||||
}
|
||||
const topo = topology({ parts: { type: 'FeatureCollection', features } });
|
||||
const { geometries } = topo.objects.parts;
|
||||
let merged = 0;
|
||||
const shapes = [...byCode].map(([code, members]) => {
|
||||
// The largest part carries the name and label point.
|
||||
const primary = members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
|
||||
let geometry = primary.geometry;
|
||||
if (members.length > 1) {
|
||||
merged += members.length - 1;
|
||||
geometry = merge(
|
||||
topo,
|
||||
geometries.filter((_, i) => features[i].properties.iso_3166_2 === code),
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: code,
|
||||
properties: {
|
||||
code,
|
||||
name: primary.properties.name,
|
||||
label: [
|
||||
round(primary.properties.longitude),
|
||||
round(primary.properties.latitude),
|
||||
],
|
||||
},
|
||||
geometry,
|
||||
};
|
||||
});
|
||||
return { shapes: shapes.sort((a, b) => a.id.localeCompare(b.id)), merged };
|
||||
}
|
||||
|
||||
/** Lower-cased English names → code, for data that names a state. */
|
||||
function namesOf(members) {
|
||||
const names = {};
|
||||
for (const { properties: p } of members) {
|
||||
for (const name of [p.name, p.name_en, p.gn_name, p.woe_name]) {
|
||||
if (name) {
|
||||
names[name.toLowerCase()] ??= p.iso_3166_2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural Earth admin-1 GeoJSON → `{ files, report }`, where `files` maps each
|
||||
* country with enough coded states to its outlines file, in code order.
|
||||
*/
|
||||
export function buildSubdivisions(source) {
|
||||
const { byCountry, uncoded, recoded } = groupByCountry(source.features);
|
||||
const files = new Map();
|
||||
const report = {
|
||||
skippedUnderMinimum: [],
|
||||
mergedParts: {},
|
||||
uncodedShapes: uncoded,
|
||||
droppedFlippedIslands: {},
|
||||
droppedCollapsedPolygons: 0,
|
||||
emptiedShapes: [],
|
||||
// Merge entries whose retired code Natural Earth no longer has.
|
||||
unmatchedRetiredCodes: [...RECODES.keys()].filter(
|
||||
(code) => !recoded.has(code),
|
||||
),
|
||||
};
|
||||
|
||||
const countries = [...byCountry.keys()].sort((a, b) => a.localeCompare(b));
|
||||
for (const country of countries) {
|
||||
const members = byCountry.get(country);
|
||||
const { shapes, merged } = mergeByCode(members);
|
||||
if (shapes.length < MIN_SUBDIVISIONS) {
|
||||
report.skippedUnderMinimum.push(country);
|
||||
continue;
|
||||
}
|
||||
if (merged) {
|
||||
report.mergedParts[country] = merged;
|
||||
}
|
||||
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
|
||||
'subdivisions',
|
||||
shapes,
|
||||
SUBDIVISION_MIN_WEIGHT,
|
||||
SUBDIVISION_QUANTIZATION,
|
||||
);
|
||||
if (flipped) {
|
||||
report.droppedFlippedIslands[country] = flipped;
|
||||
}
|
||||
report.droppedCollapsedPolygons += collapsed;
|
||||
report.emptiedShapes.push(...emptied);
|
||||
files.set(country, {
|
||||
...topo,
|
||||
aliases: aliasesOf(country),
|
||||
names: namesOf(members),
|
||||
});
|
||||
}
|
||||
return { files, report };
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
// Invariants the chart relies on, checked before anything is written. A
|
||||
// failure names every problem; the committed assets are left untouched.
|
||||
// Called by build.mjs; not run on its own.
|
||||
//
|
||||
// Checks shape counts, that no shape is emptied or drawn twice, and that every
|
||||
// code, alias and name resolves to a drawn shape.
|
||||
import { feature } from 'topojson-client';
|
||||
|
||||
import { MIN_SUBDIVISIONS } from './config.mjs';
|
||||
|
||||
// Natural Earth v5.1.2 yields 239 countries and 198 countries with states. A
|
||||
// drop below these means a changed or truncated download, not real geography.
|
||||
const MIN_COUNTRIES = 230;
|
||||
const MIN_SUBDIVISION_COUNTRIES = 190;
|
||||
|
||||
function duplicatesOf(values) {
|
||||
const seen = new Set();
|
||||
return values.filter((value) => seen.has(value) || !seen.add(value));
|
||||
}
|
||||
|
||||
function checkCountries({ topology, lookups, report }, problems) {
|
||||
const shapes = feature(topology, topology.objects.countries).features;
|
||||
const codes = shapes.map((shape) => shape.properties.code).filter(Boolean);
|
||||
const drawn = new Set(codes);
|
||||
|
||||
if (shapes.length < MIN_COUNTRIES) {
|
||||
problems.push(
|
||||
`only ${shapes.length} countries (expected ≥ ${MIN_COUNTRIES})`,
|
||||
);
|
||||
}
|
||||
if (report.emptied) {
|
||||
problems.push(`countries left with no outline: ${report.emptied}`);
|
||||
}
|
||||
for (const code of duplicatesOf(codes)) {
|
||||
problems.push(`country code ${code} is drawn twice`);
|
||||
}
|
||||
if (lookups.codes.join() !== [...drawn].sort().join()) {
|
||||
problems.push('lookups.codes differs from the drawn country codes');
|
||||
}
|
||||
for (const [alias, target] of Object.entries(lookups.aliases)) {
|
||||
if (!drawn.has(target)) {
|
||||
problems.push(`country alias ${alias} → ${target}, which has no shape`);
|
||||
}
|
||||
}
|
||||
for (const [alpha3, target] of Object.entries(lookups.alpha3)) {
|
||||
if (!drawn.has(target)) {
|
||||
problems.push(`alpha-3 ${alpha3} → ${target}, which has no shape`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkSubdivisionFile(country, file, problems) {
|
||||
const codes = file.objects.subdivisions.geometries.map(
|
||||
(geometry) => geometry.properties.code,
|
||||
);
|
||||
const known = new Set(codes);
|
||||
|
||||
if (codes.length < MIN_SUBDIVISIONS) {
|
||||
problems.push(
|
||||
`${country}: ${codes.length} states (expected ≥ ${MIN_SUBDIVISIONS})`,
|
||||
);
|
||||
}
|
||||
for (const code of duplicatesOf(codes)) {
|
||||
problems.push(`${country}: state code ${code} is drawn twice`);
|
||||
}
|
||||
for (const code of codes) {
|
||||
if (!code?.startsWith(`${country}-`)) {
|
||||
problems.push(`${country}: state code ${code} is outside the country`);
|
||||
}
|
||||
}
|
||||
for (const [alias, target] of Object.entries(file.aliases)) {
|
||||
if (!known.has(target)) {
|
||||
problems.push(`${country}: alias ${alias} → ${target}, which has no shape`);
|
||||
}
|
||||
}
|
||||
for (const [name, code] of Object.entries(file.names)) {
|
||||
if (!known.has(code)) {
|
||||
problems.push(`${country}: name "${name}" → ${code}, which has no shape`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkSubdivisions({ files, report }, problems) {
|
||||
if (files.size < MIN_SUBDIVISION_COUNTRIES) {
|
||||
problems.push(
|
||||
`only ${files.size} countries with states (expected ≥ ${MIN_SUBDIVISION_COUNTRIES})`,
|
||||
);
|
||||
}
|
||||
if (report.emptiedShapes.length > 0) {
|
||||
problems.push(
|
||||
`states left with no outline: ${report.emptiedShapes.join(' ')}`,
|
||||
);
|
||||
}
|
||||
if (report.unmatchedRetiredCodes.length > 0) {
|
||||
problems.push(
|
||||
`merge codes not found in Natural Earth: ${report.unmatchedRetiredCodes.join(' ')}`,
|
||||
);
|
||||
}
|
||||
for (const [country, file] of files) {
|
||||
checkSubdivisionFile(country, file, problems);
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws one error listing every broken invariant. */
|
||||
export function validate(countries, subdivisions) {
|
||||
const problems = [];
|
||||
checkCountries(countries, problems);
|
||||
checkSubdivisions(subdivisions, problems);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`${problems.length} problem(s) in the generated outlines:\n- ${problems.join('\n- ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5634,13 +5634,6 @@ export interface DashboardtypesBasicVisualizationDTO {
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 64
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesBubbleRadiusDTO = number | null;
|
||||
|
||||
export interface Querybuildertypesv5LogAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -6924,246 +6917,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel kind.
|
||||
*/
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTOKind {
|
||||
'signoz/GeoMapPanel' = 'signoz/GeoMapPanel',
|
||||
}
|
||||
/**
|
||||
* Radius of the largest circle, in CSS pixels from 1 to 64. Null means 8 on the points layer, 28 otherwise.
|
||||
*/
|
||||
export type DashboardtypesGeoMapBubblesDTOMaxRadius = DashboardtypesBubbleRadiusDTO | null;
|
||||
|
||||
/**
|
||||
* Radius of the smallest circle, in CSS pixels from 1 to 64. Null means 2 on the points layer, 3 otherwise.
|
||||
*/
|
||||
export type DashboardtypesGeoMapBubblesDTOMinRadius = DashboardtypesBubbleRadiusDTO | null;
|
||||
|
||||
export interface DashboardtypesGeoMapBubblesDTO {
|
||||
/**
|
||||
* @description Radius of the largest circle, in CSS pixels from 1 to 64. Null means 8 on the points layer, 28 otherwise.
|
||||
*/
|
||||
maxRadius?: DashboardtypesGeoMapBubblesDTOMaxRadius;
|
||||
/**
|
||||
* @description Radius of the smallest circle, in CSS pixels from 1 to 64. Null means 2 on the points layer, 3 otherwise.
|
||||
*/
|
||||
minRadius?: DashboardtypesGeoMapBubblesDTOMinRadius;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGeoMapColorsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The hex color varied in opacity mode. Empty uses the default fill.
|
||||
*/
|
||||
fill?: string;
|
||||
/**
|
||||
* @type number,null
|
||||
* @description The value mapped to the highest color. Null uses the highest value in the data.
|
||||
*/
|
||||
max?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
* @description The value mapped to the lowest color. Null uses the lowest value in the data. Must not exceed max.
|
||||
*/
|
||||
min?: number | null;
|
||||
/**
|
||||
* @description How values are colored: palette (default) picks a hue from palette, opacity varies the opacity of fill.
|
||||
*/
|
||||
mode?: DashboardtypesHeatmapColorModeDTO;
|
||||
/**
|
||||
* @description The named color ramp used in palette mode, such as lava (default), ice or ember.
|
||||
*/
|
||||
palette?: DashboardtypesHeatmapPaletteDTO;
|
||||
/**
|
||||
* @description How values map onto the color range: log (default), sqrt or linear.
|
||||
*/
|
||||
scale?: DashboardtypesHeatmapColorScaleDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of distinct color steps, 2 to 128. 0 uses the chart default.
|
||||
*/
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGeoMapChartAppearanceDTO {
|
||||
/**
|
||||
* @description The circle radius range of the bubbles and points layers.
|
||||
*/
|
||||
bubbles?: DashboardtypesGeoMapBubblesDTO;
|
||||
/**
|
||||
* @description The color scale applied to the colorBy values.
|
||||
*/
|
||||
colors?: DashboardtypesGeoMapColorsDTO;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description Shows country and state names once each fits inside its shape. Null shows them.
|
||||
*/
|
||||
labels?: boolean | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesGeoMapCountryFormatDTO {
|
||||
auto = 'auto',
|
||||
alpha2 = 'alpha2',
|
||||
alpha3 = 'alpha3',
|
||||
name = 'name',
|
||||
}
|
||||
export enum DashboardtypesGeoMapLocationModeDTO {
|
||||
auto = 'auto',
|
||||
country = 'country',
|
||||
subdivision = 'subdivision',
|
||||
coordinates = 'coordinates',
|
||||
}
|
||||
export interface DashboardtypesGeoMapLocationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Group-by key holding the country, for the country and subdivision modes. Empty detects it by name, such as geo.country.iso_code.
|
||||
*/
|
||||
country?: string;
|
||||
/**
|
||||
* @description How country values are written: auto (default) detects it, alpha2, alpha3 or name.
|
||||
*/
|
||||
countryFormat: DashboardtypesGeoMapCountryFormatDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Group-by key holding the latitude, for the coordinates mode. Empty detects it by name.
|
||||
*/
|
||||
lat?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Group-by key holding the longitude, for the coordinates mode. Empty detects it by name.
|
||||
*/
|
||||
lon?: string;
|
||||
/**
|
||||
* @description How rows are placed: auto (default) detects it from the group-by keys, country, subdivision or coordinates.
|
||||
*/
|
||||
mode: DashboardtypesGeoMapLocationModeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code), for the subdivision mode. Empty detects it by name.
|
||||
*/
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesGeoMapLayerDTO {
|
||||
regions = 'regions',
|
||||
bubbles = 'bubbles',
|
||||
points = 'points',
|
||||
}
|
||||
export enum DashboardtypesGeoMapProjectionDTO {
|
||||
mercator = 'mercator',
|
||||
equal_earth = 'equal_earth',
|
||||
natural_earth = 'natural_earth',
|
||||
}
|
||||
export enum DashboardtypesGeoMapViewDTO {
|
||||
world = 'world',
|
||||
fit = 'fit',
|
||||
africa = 'africa',
|
||||
asia = 'asia',
|
||||
europe = 'europe',
|
||||
north_america = 'north_america',
|
||||
south_america = 'south_america',
|
||||
oceania = 'oceania',
|
||||
country = 'country',
|
||||
}
|
||||
export interface DashboardtypesGeoMapSettingsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Value column key, as in a Table panel, that sets the color. Empty uses the first value column.
|
||||
*/
|
||||
colorBy?: string;
|
||||
/**
|
||||
* @description What is drawn: regions (default) fills each shape, bubbles draws a circle per region, points a circle per coordinate.
|
||||
*/
|
||||
layer: DashboardtypesGeoMapLayerDTO;
|
||||
/**
|
||||
* @description The map projection: mercator (default), equal_earth or natural_earth.
|
||||
*/
|
||||
projection: DashboardtypesGeoMapProjectionDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Value column key, as in a Table panel, that sets the bubble or point size. Empty uses colorBy.
|
||||
*/
|
||||
sizeBy?: string;
|
||||
/**
|
||||
* @description The area shown: world (default), fit to the data, a continent, or country with viewCountry.
|
||||
*/
|
||||
view: DashboardtypesGeoMapViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Uppercase ISO 3166-1 alpha-2 code of the country the country view shows.
|
||||
*/
|
||||
viewCountry?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGeoMapThresholdDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The CSS color of the shape, bubble or point while the threshold is active, for example #F1575F; CSS named colors such as red also work.
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* @description The comparison that activates the threshold: above (default), below, above_or_equal, below_or_equal, equal or not_equal.
|
||||
*/
|
||||
operator?: DashboardtypesComparisonOperatorDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The unit id of value, such as ms or percent. Empty means the colorBy column's unit.
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The value compared against, in unit.
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGeoMapPanelSpecDTO {
|
||||
/**
|
||||
* @description Color scale, bubble size and labels.
|
||||
*/
|
||||
chartAppearance?: DashboardtypesGeoMapChartAppearanceDTO;
|
||||
/**
|
||||
* @description Per-column units and decimal precision.
|
||||
*/
|
||||
formatting?: DashboardtypesTableFormattingDTO;
|
||||
/**
|
||||
* @description Legend placement.
|
||||
*/
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @description The group-by columns that place each row on the map.
|
||||
*/
|
||||
location?: DashboardtypesGeoMapLocationDTO;
|
||||
/**
|
||||
* @description Layer, view, projection and the value columns drawn.
|
||||
*/
|
||||
map?: DashboardtypesGeoMapSettingsDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description Rules that recolor a shape, bubble or point when its value compares to a threshold as specified.
|
||||
*/
|
||||
thresholds?: DashboardtypesGeoMapThresholdDTO[] | null;
|
||||
/**
|
||||
* @description The time range the values are computed over.
|
||||
*/
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/GeoMapPanel
|
||||
* @type string
|
||||
* @description The panel kind.
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTOKind;
|
||||
/**
|
||||
* @description The options for this panel kind. {} accepts every default.
|
||||
*/
|
||||
spec: DashboardtypesGeoMapPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -7175,8 +6928,7 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -8457,7 +8209,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/GeoMapPanel' = 'signoz/GeoMapPanel',
|
||||
}
|
||||
/**
|
||||
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -281,19 +282,6 @@ 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
|
||||
*/
|
||||
|
||||
@@ -54,7 +54,6 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
case PANEL_TYPES.SCATTER:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
case PANEL_TYPES.GEO_MAP:
|
||||
return 'scalar';
|
||||
case PANEL_TYPES.TRACE:
|
||||
return 'trace';
|
||||
@@ -278,8 +277,7 @@ export function createAggregation(
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE ||
|
||||
panelType === PANEL_TYPES.GEO_MAP);
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
|
||||
if (queryData.dataSource === DataSource.METRICS) {
|
||||
return [
|
||||
|
||||
@@ -29,8 +29,8 @@ export default function WaitlistFragment({
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
logEvent('Infra Monitoring: Get Early Access Clicked', {
|
||||
entity_type: entityType,
|
||||
logEvent('Infra Monitoring: Get early access clicked', {
|
||||
entityType,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -23,6 +22,7 @@ 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,8 +33,6 @@ 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';
|
||||
|
||||
@@ -67,10 +65,6 @@ 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);
|
||||
@@ -173,20 +167,7 @@ function LogDetailInner({
|
||||
}
|
||||
};
|
||||
|
||||
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 logBody = useMemo(() => getBodyMessage(log?.body), [log?.body]);
|
||||
|
||||
const htmlBody = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -40,10 +41,10 @@ function AddToQueryHOC({
|
||||
|
||||
export interface AddToQueryHOCProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
fieldValue: ScalarFieldValue;
|
||||
onAddToQuery: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
fieldValue: ScalarFieldValue,
|
||||
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 {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { 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 } from 'lib/logs/flatLogData';
|
||||
import { FlatLogData, getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
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,7 +26,6 @@ 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';
|
||||
|
||||
@@ -37,8 +36,9 @@ interface LogFieldProps {
|
||||
fontSize: FontSize;
|
||||
}
|
||||
|
||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow' | 'fieldValue'> &
|
||||
Pick<AddToQueryHOCProps, 'fieldValue'> &
|
||||
Partial<Pick<AddToQueryHOCProps, 'onAddToQuery'>>;
|
||||
|
||||
function LogGeneralField({
|
||||
fieldKey,
|
||||
@@ -75,27 +75,35 @@ 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">
|
||||
<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>
|
||||
{onAddToQuery ? (
|
||||
<AddToQueryHOC
|
||||
fieldKey={fieldKey}
|
||||
fieldValue={fieldValue}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
>
|
||||
{fieldKeyText}
|
||||
</AddToQueryHOC>
|
||||
) : (
|
||||
fieldKeyText
|
||||
)}
|
||||
<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)}>
|
||||
{fieldValue || "''"}
|
||||
{coerceToString(fieldValue) || "''"}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -130,6 +138,7 @@ function ListLogView({
|
||||
onClearActiveLog,
|
||||
}: ListLogViewProps): JSX.Element {
|
||||
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
||||
logData.id,
|
||||
@@ -200,7 +209,7 @@ function ListLogView({
|
||||
{updatedSelecedFields.some((field) => field.name === 'body') && (
|
||||
<LogGeneralField
|
||||
fieldKey="Log"
|
||||
fieldValue={getBodyDisplayString(logData.body)}
|
||||
fieldValue={getBodyDisplay(logData.body)}
|
||||
linesPerRow={linesPerRow}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
@@ -222,17 +231,22 @@ function ListLogView({
|
||||
|
||||
{updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.map((field) =>
|
||||
isValidLogField(flattenLogData[field.name] as never) ? (
|
||||
.map((field) => {
|
||||
const value = getLogFieldValue(logData, field.name, isBodyJsonEnabled);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const queryValue = isScalarFieldValue(value) ? value : undefined;
|
||||
return (
|
||||
<LogSelectedField
|
||||
key={field.name}
|
||||
fieldKey={field.name}
|
||||
fieldValue={flattenLogData[field.name] as never}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fieldValue={queryValue ?? coerceToString(value)}
|
||||
onAddToQuery={queryValue === undefined ? undefined : onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</LogContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
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 {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
// hooks
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
|
||||
import { getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { isBoolean, 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 flattenLogData = useMemo(() => FlatLogData(data), [data]);
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
|
||||
@@ -60,15 +60,16 @@ function RawLogView({
|
||||
|
||||
const attributesValues = updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.map((field) => flattenLogData[field.name])
|
||||
.map((field) => getLogFieldValue(data, field.name, isBodyJsonEnabled))
|
||||
.filter((attribute) => {
|
||||
// loadash isEmpty doesnot work with numbers
|
||||
if (isNumber(attribute)) {
|
||||
if (isNumber(attribute) || isBoolean(attribute)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isUndefined(attribute) && !isEmpty(attribute);
|
||||
});
|
||||
})
|
||||
.map((attribute) => coerceToString(attribute));
|
||||
|
||||
let attributesText = attributesValues.join(' | ');
|
||||
|
||||
@@ -102,7 +103,7 @@ function RawLogView({
|
||||
// Check if body is selected
|
||||
const showBody = selectedFields.some((field) => field.name === 'body');
|
||||
if (showBody) {
|
||||
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
|
||||
parts.push(`${attributesText} ${getBodyDisplay(data.body)}`);
|
||||
} else {
|
||||
parts.push(attributesText);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
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: [] }),
|
||||
}));
|
||||
|
||||
@@ -165,3 +169,36 @@ 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,18 +2,16 @@ import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { 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 { useAppContext } from 'providers/App/App';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
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';
|
||||
@@ -28,10 +26,7 @@ export function useLogsTableColumns({
|
||||
fontSize,
|
||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
return useMemo<TableColumnDef<ILog>[]>(() => {
|
||||
const stateIndicatorCol: TableColumnDef<ILog> = {
|
||||
@@ -75,7 +70,7 @@ export function useLogsTableColumns({
|
||||
const bodyCol: TableColumnDef<ILog> = {
|
||||
id: buildCompositeKey('body', 'log'),
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
accessorFn: (log): string => getBodyDisplay(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
@@ -99,7 +94,7 @@ export function useLogsTableColumns({
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
<TanStackTable.Text>{coerceToString(value)}</TanStackTable.Text>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
PANEL_TYPES.SCATTER,
|
||||
PANEL_TYPES.PIE,
|
||||
PANEL_TYPES.VALUE,
|
||||
PANEL_TYPES.GEO_MAP,
|
||||
]);
|
||||
|
||||
export function isScalarPanelType(panelType?: string | null): boolean {
|
||||
|
||||
@@ -8,35 +8,3 @@ export enum Events {
|
||||
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
|
||||
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
|
||||
}
|
||||
|
||||
export enum InfraMonitoringEvents {
|
||||
PageVisited = 'Infra Monitoring: page visited',
|
||||
PageNumberChanged = 'Infra Monitoring: page number changed',
|
||||
GroupByChanged = 'Infra Monitoring: group by changed',
|
||||
FilterApplied = 'Infra Monitoring: filter applied',
|
||||
ItemClicked = 'Infra Monitoring: item clicked',
|
||||
TabChanged = 'Infra Monitoring: tab changed',
|
||||
TimeUpdated = 'Infra Monitoring: time updated',
|
||||
ExploreClicked = 'Infra Monitoring: explore clicked',
|
||||
HostEntity = 'host',
|
||||
K8sEntity = 'k8s',
|
||||
ListPage = 'list',
|
||||
DetailedPage = 'detailed',
|
||||
LogsView = 'logs',
|
||||
TracesView = 'traces',
|
||||
EventsView = 'events',
|
||||
QuickFiltersView = 'quick filters',
|
||||
MetricsView = 'metrics',
|
||||
Total = 'total',
|
||||
Cluster = 'cluster',
|
||||
Container = 'container',
|
||||
DaemonSet = 'daemonSet',
|
||||
Deployment = 'deployment',
|
||||
Job = 'job',
|
||||
Namespace = 'namespace',
|
||||
Node = 'node',
|
||||
Volume = 'volume',
|
||||
Pod = 'pod',
|
||||
StatefulSet = 'statefulSet',
|
||||
Volumes = 'volumes',
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.GEO_MAP]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.SCATTER]: null,
|
||||
|
||||
@@ -340,7 +340,6 @@ export enum PANEL_TYPES {
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
GEO_MAP = 'geo_map',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tooltip } from 'antd';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ArrowDownLeft, ArrowUpRight, Calendar } from '@signozhq/icons';
|
||||
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
|
||||
import { AlertRuleStats } from 'types/api/alerts/def';
|
||||
import { calculateChange } from 'utils/calculateChange';
|
||||
|
||||
@@ -86,7 +87,7 @@ function StatsCard({
|
||||
}: StatsCardProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const relativeTime = urlQuery.get('relativeTime');
|
||||
const { relativeTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { changePercentage, changeDirection } = calculateChange(
|
||||
totalCurrentCount,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
|
||||
import {
|
||||
useAlertHistoryQueryParams,
|
||||
useGetAlertRuleDetailsTimelineGraphData,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
|
||||
|
||||
@@ -13,9 +15,7 @@ function GraphWrapper({
|
||||
}: {
|
||||
totalCurrentTriggers: number;
|
||||
}): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const relativeTime = urlQuery.get('relativeTime');
|
||||
const { relativeTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineGraphData();
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
QuickFilterChangeEventData,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import K8sBaseDetails, {
|
||||
K8sDetailsFilters,
|
||||
@@ -273,7 +272,6 @@ function Hosts(): JSX.Element {
|
||||
fetchListData={fetchListData}
|
||||
getRowKey={getHostRowKey}
|
||||
getItemKey={getHostItemKey}
|
||||
eventCategory={InfraMonitoringEvents.HostEntity}
|
||||
detailsQueryKeyPrefix="hosts"
|
||||
/>
|
||||
</div>
|
||||
@@ -281,7 +279,6 @@ function Hosts(): JSX.Element {
|
||||
</div>
|
||||
<K8sBaseDetails
|
||||
category={InfraMonitoringEntity.HOSTS}
|
||||
eventCategory={InfraMonitoringEvents.HostEntity}
|
||||
getSelectedItemExpression={hostGetSelectedItemExpression}
|
||||
fetchEntityData={fetchEntityData}
|
||||
getEntityName={hostGetEntityName}
|
||||
|
||||
@@ -6,10 +6,8 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
GlobalTimeProvider,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
@@ -67,7 +65,6 @@ const DRAWER_MOTION_PROPS = {
|
||||
|
||||
export default function K8sBaseDetails<T>({
|
||||
category,
|
||||
eventCategory,
|
||||
getSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
getEntityName,
|
||||
@@ -224,16 +221,6 @@ export default function K8sBaseDetails<T>({
|
||||
};
|
||||
}, [entity, entityName, category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
void logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
}
|
||||
}, [entity, eventCategory]);
|
||||
|
||||
// Names the resource it returns to, so a drill several levels deep stays clear
|
||||
const backTarget = previousEntry
|
||||
? `Back to ${K8S_CATEGORY_SINGULAR_LABELS[previousEntry.category]}`
|
||||
@@ -333,7 +320,6 @@ export default function K8sBaseDetails<T>({
|
||||
<K8sBaseDetailsContent<T>
|
||||
entity={entity}
|
||||
category={category}
|
||||
eventCategory={eventCategory}
|
||||
metadataConfig={metadataConfig}
|
||||
countsConfig={countsConfig}
|
||||
getCountsFilterExpression={getCountsFilterExpression}
|
||||
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
@@ -55,7 +53,6 @@ import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/B
|
||||
export default function K8sBaseDetailsContent<T>({
|
||||
entity,
|
||||
category,
|
||||
eventCategory,
|
||||
metadataConfig,
|
||||
countsConfig,
|
||||
getCountsFilterExpression,
|
||||
@@ -138,14 +135,18 @@ export default function K8sBaseDetailsContent<T>({
|
||||
|
||||
// Wait until the view is a valid tab so we don't log a pending value before
|
||||
// the validation effect above corrects an out-of-scope query param
|
||||
const hasLoggedDefaultTab = useRef(false);
|
||||
const loggedDefaultTabFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (hasLoggedDefaultTab.current || !validTabs.includes(effectiveView)) {
|
||||
const visitKey = `${category}:${selectedItem}`;
|
||||
if (
|
||||
loggedDefaultTabFor.current === visitKey ||
|
||||
!validTabs.includes(effectiveView)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasLoggedDefaultTab.current = true;
|
||||
loggedDefaultTabFor.current = visitKey;
|
||||
logInfraDrawerTabViewedEvent(category, effectiveView, true);
|
||||
}, [validTabs, effectiveView, category]);
|
||||
}, [validTabs, effectiveView, category, selectedItem]);
|
||||
|
||||
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
@@ -167,12 +168,6 @@ export default function K8sBaseDetailsContent<T>({
|
||||
void setLogFiltersParam(null);
|
||||
void setTracesFiltersParam(null);
|
||||
void setEventsFiltersParam(null);
|
||||
void logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: value,
|
||||
});
|
||||
logInfraDrawerTabViewedEvent(category, value, false);
|
||||
};
|
||||
|
||||
@@ -188,13 +183,6 @@ export default function K8sBaseDetailsContent<T>({
|
||||
urlQuery.set(QueryParams.endTime, (timeRange.endTime * 1000).toString());
|
||||
}
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: effectiveView,
|
||||
});
|
||||
|
||||
logInfraExplorerNavigatedEvent({
|
||||
entityType: category,
|
||||
destination:
|
||||
@@ -416,7 +404,6 @@ export default function K8sBaseDetailsContent<T>({
|
||||
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
|
||||
<EntityOverview
|
||||
category={category}
|
||||
eventEntity={eventCategory}
|
||||
attributes={entityAttributes}
|
||||
entity={entity}
|
||||
entityName={entityName}
|
||||
@@ -426,7 +413,6 @@ export default function K8sBaseDetailsContent<T>({
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
eventEntity={eventCategory}
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
category={category}
|
||||
@@ -436,7 +422,6 @@ export default function K8sBaseDetailsContent<T>({
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<EntityLogs
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Logs`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
@@ -444,7 +429,6 @@ export default function K8sBaseDetailsContent<T>({
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<EntityTraces
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Traces`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
@@ -452,7 +436,6 @@ export default function K8sBaseDetailsContent<T>({
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
|
||||
<EntityEvents
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Events`}
|
||||
initialExpression={eventsInitialExpression}
|
||||
category={category}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import TanStackTable, {
|
||||
SortState,
|
||||
TableColumnDef,
|
||||
@@ -10,7 +9,6 @@ import TanStackTable, {
|
||||
useRecoverFromEmptyPage,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
@@ -51,6 +49,7 @@ import styles from './K8sBaseList.module.scss';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
logInfraColumnSortedEvent,
|
||||
logInfraListRowClickedEvent,
|
||||
logInfraTimeRangeCustomizedEvent,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
@@ -92,7 +91,6 @@ export type K8sBaseListProps<
|
||||
getRowKey: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Can return string or SelectedItemParams. */
|
||||
getItemKey: (record: T) => TItemKey;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
renderEmptyState?: (
|
||||
context: K8sBaseListEmptyStateContext,
|
||||
) => React.ReactNode | null;
|
||||
@@ -115,7 +113,6 @@ export function K8sBaseList<
|
||||
fetchListData,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
eventCategory,
|
||||
renderEmptyState,
|
||||
extraQueryKeyParts = [],
|
||||
detailsQueryKeyPrefix,
|
||||
@@ -280,15 +277,6 @@ export function K8sBaseList<
|
||||
[groupBy],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
total: totalCount,
|
||||
});
|
||||
}, [eventCategory, totalCount]);
|
||||
|
||||
const prevSelectedTimeRef = useRef(selectedTime);
|
||||
useEffect(() => {
|
||||
if (prevSelectedTimeRef.current !== selectedTime) {
|
||||
@@ -326,14 +314,10 @@ export function K8sBaseList<
|
||||
setSelectedItemParams(params);
|
||||
}
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
logInfraListRowClickedEvent(entity, false, groupBy.length > 0);
|
||||
},
|
||||
[
|
||||
eventCategory,
|
||||
entity,
|
||||
groupBy.length,
|
||||
setSelectedItemParams,
|
||||
detailsQueryKeyPrefix,
|
||||
@@ -385,13 +369,9 @@ export function K8sBaseList<
|
||||
}
|
||||
openInNewTab(url.pathname + url.search);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
logInfraListRowClickedEvent(entity, true, false);
|
||||
},
|
||||
[eventCategory, groupBy.length],
|
||||
[entity, groupBy.length],
|
||||
);
|
||||
|
||||
const isGroupedByAttribute = groupBy.length > 0;
|
||||
@@ -491,7 +471,6 @@ export function K8sBaseList<
|
||||
|
||||
<K8sTableToolbar
|
||||
entity={entity}
|
||||
eventCategory={eventCategory}
|
||||
leftFilters={leftFilters}
|
||||
onOpenOptionsDrawer={handleOpenOptionsDrawer}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -92,12 +90,6 @@ function K8sHeader({
|
||||
void invalidateQueries();
|
||||
|
||||
if (finalExpression?.trim()) {
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: entity,
|
||||
});
|
||||
|
||||
logInfraFilterCustomizedEvent(entity, 'search', finalExpression);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Select } from 'antd';
|
||||
import { Download, SlidersVertical } from '@signozhq/icons';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
@@ -35,7 +33,6 @@ const NAME_COLUMN_KEYS: Set<string> = new Set([
|
||||
|
||||
interface K8sTableToolbarProps {
|
||||
entity: InfraMonitoringEntity;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
leftFilters?: React.ReactNode;
|
||||
onOpenOptionsDrawer: () => void;
|
||||
onDownload?: () => void;
|
||||
@@ -43,7 +40,6 @@ interface K8sTableToolbarProps {
|
||||
|
||||
function K8sTableToolbar({
|
||||
entity,
|
||||
eventCategory,
|
||||
leftFilters,
|
||||
onOpenOptionsDrawer,
|
||||
onDownload,
|
||||
@@ -65,15 +61,9 @@ function K8sTableToolbar({
|
||||
|
||||
void setGroupBy(value);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.GroupByChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
|
||||
logInfraGroupByCustomizedEvent(entity, value);
|
||||
},
|
||||
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
|
||||
[entity, orderBy, setCurrentPage, setOrderBy, setGroupBy],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
type OnUrlUpdateFunction,
|
||||
} from 'nuqs/adapters/testing';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
@@ -19,6 +19,13 @@ jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
default: (): JSX.Element => <div data-testid="mock-datetime" />,
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const logEventMock = jest.mocked(logEvent);
|
||||
|
||||
type TestEntity = { name: string };
|
||||
|
||||
const mockEntity: TestEntity = { name: 'test-container' };
|
||||
@@ -49,7 +56,6 @@ function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
|
||||
>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
category={InfraMonitoringEntity.CONTAINERS}
|
||||
eventCategory={InfraMonitoringEvents.Pod}
|
||||
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
|
||||
fetchEntityData={jest
|
||||
.fn()
|
||||
@@ -71,6 +77,7 @@ function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
|
||||
describe('K8sBaseDetails - back navigation', () => {
|
||||
beforeEach(() => {
|
||||
useDrawerHistoryStore.getState().reset();
|
||||
logEventMock.mockClear();
|
||||
});
|
||||
|
||||
it('closes rather than going back when opened from the list', async () => {
|
||||
@@ -149,4 +156,36 @@ describe('K8sBaseDetails - back navigation', () => {
|
||||
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a drawer visit for a resource reached from the cache', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
useDrawerHistoryStore.getState().push({
|
||||
params: {
|
||||
selectedItem: 'container-0',
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
},
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
label: 'container-0',
|
||||
entity: { name: 'container-0' },
|
||||
queryKeyPrefix: 'testContainer',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderDrawer();
|
||||
});
|
||||
|
||||
const drawerVisits = (): number =>
|
||||
logEventMock.mock.calls.filter(
|
||||
([name]) => name === 'Infra Monitoring: Drawer tab viewed',
|
||||
).length;
|
||||
|
||||
await waitFor(() => expect(drawerVisits()).toBe(1));
|
||||
|
||||
await user.click(await screen.findByTestId('drawer-back-button'));
|
||||
|
||||
await waitFor(() => expect(drawerVisits()).toBe(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Box } from '@signozhq/icons';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
@@ -43,7 +42,6 @@ const relatedEntity: TestEntity = {
|
||||
function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
|
||||
return {
|
||||
category,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
|
||||
getEntityName: (e: TestEntity): string => e.name,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Provider } from 'react-redux';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
@@ -17,7 +16,9 @@ import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import { TestRouter } from 'tests/router';
|
||||
import { within } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
@@ -40,7 +41,13 @@ jest.mock('utils/navigation', () => ({
|
||||
openInNewTab: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const openInNewTabMock = openInNewTab as jest.Mock;
|
||||
const logEventMock = jest.mocked(logEvent);
|
||||
|
||||
// Mock Date.now to prevent flaky tests due to time-dependent values
|
||||
const MOCK_NOW = 1700000000000; // Fixed timestamp
|
||||
@@ -227,6 +234,7 @@ describe('K8sBaseList', () => {
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
logEventMock.mockClear();
|
||||
openInNewTabMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [
|
||||
@@ -240,7 +248,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItemWithTitle>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -299,6 +306,40 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should log the list row clicked event when a row opens the drawer', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const row = await screen.findByTestId(`row-PodId:${itemId}`);
|
||||
await user.click(within(row).getByText(`PodId:${itemId}`));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Infra Monitoring: List row clicked',
|
||||
{
|
||||
entityType: InfraMonitoringEntity.PODS,
|
||||
openedInNewTab: false,
|
||||
isGrouped: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should log the list row clicked event as a new tab on ctrl+click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const row = await screen.findByTestId(`row-PodId:${itemId}`);
|
||||
|
||||
await user.keyboard('{Control>}');
|
||||
await user.click(within(row).getByText(`PodId:${itemId}`));
|
||||
await user.keyboard('{/Control}');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Infra Monitoring: List row clicked',
|
||||
{
|
||||
entityType: InfraMonitoringEntity.PODS,
|
||||
openedInNewTab: true,
|
||||
isGrouped: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should update orderBy in URL when clicking sortable column header', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -463,7 +504,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
|
||||
@@ -538,7 +578,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
|
||||
@@ -603,7 +642,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -662,7 +700,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '7', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
@@ -734,7 +771,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '0', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
@@ -819,7 +855,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -857,7 +892,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -895,7 +929,6 @@ describe('K8sBaseList', () => {
|
||||
it('should show all columns defined in tableColumns', async () => {
|
||||
renderComponent<TestItemWithName>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithName(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -943,7 +976,6 @@ describe('K8sBaseList', () => {
|
||||
it('should show columns when NOT grouped', async () => {
|
||||
renderComponent<TestItemWithGroup>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {},
|
||||
tableColumns: createTestColumnsWithGroup(),
|
||||
@@ -968,7 +1000,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItemWithGroup>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
groupBy: JSON.stringify(groupByValue),
|
||||
@@ -1010,7 +1041,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
groupBy: JSON.stringify(groupByValue),
|
||||
@@ -1055,7 +1085,6 @@ describe('K8sBaseList', () => {
|
||||
it('should render all defined columns', async () => {
|
||||
renderComponent<TestItemWithName>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithName(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1078,7 +1107,6 @@ describe('K8sBaseList', () => {
|
||||
it('should render data in cells correctly', async () => {
|
||||
renderComponent<TestItemWithName>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithName(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1115,7 +1143,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1163,7 +1190,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1188,7 +1214,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1215,7 +1240,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1277,7 +1301,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1312,7 +1335,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1352,7 +1374,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1401,7 +1422,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1449,7 +1469,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1506,7 +1525,6 @@ describe('K8sBaseList', () => {
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
@@ -1580,7 +1598,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
// k8s.pod.name is a name column - should be cleared
|
||||
@@ -1623,7 +1640,6 @@ describe('K8sBaseList', () => {
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
// cpu is NOT a name column - should be kept
|
||||
@@ -1676,7 +1692,6 @@ describe('K8sBaseList status filters', () => {
|
||||
renderComponent<TestItem>({
|
||||
queryParams,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
|
||||
@@ -22,10 +22,10 @@ export function logInfraFilterCustomizedEvent(
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_filter_customized', {
|
||||
entity_type: entityType,
|
||||
void logEvent('Infra Monitoring: Filter customized', {
|
||||
entityType,
|
||||
source,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
expressionKeys: [...new Set(expressionKeys)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export function logInfraMonitoringListViewedEvent(
|
||||
): void {
|
||||
const referrer = getNavigationReferrer();
|
||||
|
||||
void logEvent('infra_list_viewed', {
|
||||
entity,
|
||||
void logEvent('Infra Monitoring: List viewed', {
|
||||
entityType: entity,
|
||||
referrer,
|
||||
});
|
||||
}
|
||||
@@ -44,9 +44,9 @@ export function logInfraTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
void logEvent('Infra Monitoring: Time range customized', {
|
||||
entityType,
|
||||
rangeLabel: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,11 +57,11 @@ export function logInfraColumnCustomizedEvent(
|
||||
maxLinesPerRow: number,
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_customized', {
|
||||
entity_type: entityType,
|
||||
columns_list: columnsList,
|
||||
font_size: fontSize,
|
||||
max_lines_per_row: maxLinesPerRow,
|
||||
void logEvent('Infra Monitoring: Column customized', {
|
||||
entityType,
|
||||
columnsList,
|
||||
fontSize,
|
||||
maxLinesPerRow,
|
||||
source,
|
||||
});
|
||||
}
|
||||
@@ -72,9 +72,9 @@ export function logInfraColumnSortedEvent(
|
||||
direction: 'asc' | 'desc',
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_sorted', {
|
||||
entity_type: entityType,
|
||||
column_key: columnKey,
|
||||
void logEvent('Infra Monitoring: Column sorted', {
|
||||
entityType,
|
||||
columnKey,
|
||||
direction,
|
||||
source,
|
||||
});
|
||||
@@ -84,9 +84,9 @@ export function logInfraGroupByCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
groupByKeysList: string[],
|
||||
): void {
|
||||
void logEvent('infra_group_by_customized', {
|
||||
entity_type: entityType,
|
||||
group_by_keys_list: groupByKeysList,
|
||||
void logEvent('Infra Monitoring: Group by customized', {
|
||||
entityType,
|
||||
groupByKeysList,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,12 +106,24 @@ export function logInfraExplorerNavigatedEvent(params: {
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
}): void {
|
||||
void logEvent('infra_explorer_navigated', {
|
||||
entity_type: params.entityType,
|
||||
void logEvent('Infra Monitoring: Explorer navigated', {
|
||||
entityType: params.entityType,
|
||||
destination: params.destination,
|
||||
source: params.source,
|
||||
tab: params.tab,
|
||||
source_key: params.sourceKey,
|
||||
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
|
||||
sourceKey: params.sourceKey,
|
||||
drawerDurationMsAtNavigation: params.drawerDurationMsAtNavigation,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraListRowClickedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
openedInNewTab: boolean,
|
||||
isGrouped: boolean,
|
||||
): void {
|
||||
void logEvent('Infra Monitoring: List row clicked', {
|
||||
entityType,
|
||||
openedInNewTab,
|
||||
isGrouped,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -111,7 +110,6 @@ export interface K8sDetailsCustomTab<T> {
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
// Data fetching configuration
|
||||
getSelectedItemExpression: (params: SelectedItemParams) => string;
|
||||
fetchEntityData: (
|
||||
@@ -137,7 +135,6 @@ export interface K8sBaseDetailsProps<T> {
|
||||
export interface K8sBaseDetailsContentProps<T> {
|
||||
entity: T;
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
@@ -117,7 +116,6 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
|
||||
{
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.CLUSTERS,
|
||||
eventCategory: InfraMonitoringEvents.Cluster,
|
||||
tableColumns: k8sClustersColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sClusterRowKey,
|
||||
@@ -126,7 +124,6 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CLUSTERS,
|
||||
eventCategory: InfraMonitoringEvents.Cluster,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'cluster',
|
||||
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
@@ -120,7 +119,6 @@ export const containerEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
tableColumns: k8sContainerColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sContainerRowKey,
|
||||
@@ -129,7 +127,6 @@ export const containerEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'container',
|
||||
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
@@ -125,7 +124,6 @@ export const daemonSetEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.DAEMONSETS,
|
||||
eventCategory: InfraMonitoringEvents.DaemonSet,
|
||||
tableColumns: k8sDaemonSetsColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sDaemonSetRowKey,
|
||||
@@ -134,7 +132,6 @@ export const daemonSetEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.DAEMONSETS,
|
||||
eventCategory: InfraMonitoringEvents.DaemonSet,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'daemonset',
|
||||
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import {
|
||||
@@ -140,7 +139,6 @@ export const deploymentEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
eventCategory: InfraMonitoringEvents.Deployment,
|
||||
tableColumns: k8sDeploymentsColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sDeploymentRowKey,
|
||||
@@ -149,7 +147,6 @@ export const deploymentEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
eventCategory: InfraMonitoringEvents.Deployment,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'deployment',
|
||||
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useCallback } from 'react';
|
||||
import { Undo } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
@@ -17,15 +15,11 @@ import styles from './EntityDateTimeSelector.module.scss';
|
||||
import { logInfraDrawerTimeRangeCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface EntityDateTimeSelectorProps {
|
||||
eventEntity: string;
|
||||
category: InfraMonitoringEntity;
|
||||
view: string;
|
||||
}
|
||||
|
||||
function EntityDateTimeSelector({
|
||||
eventEntity,
|
||||
category,
|
||||
view,
|
||||
}: EntityDateTimeSelectorProps): JSX.Element {
|
||||
const {
|
||||
timeRange,
|
||||
@@ -37,17 +31,10 @@ function EntityDateTimeSelector({
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
void logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view,
|
||||
interval,
|
||||
});
|
||||
logInfraDrawerTimeRangeCustomizedEvent(category, interval);
|
||||
handleTimeChange(interval, dateTimeRange);
|
||||
},
|
||||
[category, view, eventEntity, handleTimeChange],
|
||||
[category, handleTimeChange],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Table, TableColumnsType } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
@@ -16,12 +15,12 @@ import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -52,7 +51,6 @@ interface EventDataType {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
@@ -67,7 +65,6 @@ const handleExpandRow = (record: EventDataType): JSX.Element => (
|
||||
);
|
||||
|
||||
function EntityEventsContent({
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
@@ -120,13 +117,6 @@ function EntityEventsContent({
|
||||
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view: InfraMonitoringEvents.EventsView,
|
||||
});
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(
|
||||
category,
|
||||
'events',
|
||||
@@ -137,14 +127,7 @@ function EntityEventsContent({
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[
|
||||
inputExpression,
|
||||
initialExpression,
|
||||
refetch,
|
||||
querySearchOnRun,
|
||||
category,
|
||||
eventEntity,
|
||||
],
|
||||
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
@@ -163,7 +146,7 @@ function EntityEventsContent({
|
||||
key: event.data.id,
|
||||
id: event.data.id,
|
||||
timestamp: event.timestamp,
|
||||
body: event.data.body,
|
||||
body: getBodyDisplay(event.data.body),
|
||||
severity: event.data.severity_text,
|
||||
attributes_string: event.data.attributes_string,
|
||||
resources_string: event.data.resources_string,
|
||||
@@ -232,11 +215,7 @@ function EntityEventsContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.EventsView}
|
||||
/>
|
||||
<EntityDateTimeSelector category={category} />
|
||||
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isFetching}
|
||||
|
||||
@@ -69,7 +69,6 @@ describe('EntityEvents', () => {
|
||||
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
|
||||
>
|
||||
<EntityEvents
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression='k8s.pod.name = "x"'
|
||||
|
||||
@@ -61,7 +61,6 @@ describe('EntityEvents time range wiring', () => {
|
||||
)}`}
|
||||
>
|
||||
<EntityEvents
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from 'react-query';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { getEntityEventsQueryPayload } from './utils';
|
||||
|
||||
@@ -10,7 +11,7 @@ export const K8S_ENTITY_EVENTS_EXPRESSION_KEY = 'k8sEntityEventsExpression';
|
||||
|
||||
export interface EventRowData {
|
||||
id: string;
|
||||
body: string;
|
||||
body: ILog['body'];
|
||||
severity_text: string;
|
||||
attributes_string?: Record<string, string>;
|
||||
resources_string?: Record<string, string>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
@@ -52,14 +50,12 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface Props {
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
}
|
||||
|
||||
function EntityLogsContent({
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
@@ -127,13 +123,6 @@ function EntityLogsContent({
|
||||
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
|
||||
querySearchOnRun(newUserExpression);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view: InfraMonitoringEvents.LogsView,
|
||||
});
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(
|
||||
category,
|
||||
'logs',
|
||||
@@ -277,11 +266,7 @@ function EntityLogsContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.LogsView}
|
||||
/>
|
||||
<EntityDateTimeSelector category={category} />
|
||||
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isFetching}
|
||||
|
||||
@@ -94,7 +94,6 @@ describe('EntityLogs', () => {
|
||||
>
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
|
||||
<EntityLogs
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression='k8s.pod.name = "x"'
|
||||
|
||||
@@ -73,7 +73,6 @@ describe('EntityLogs time range wiring', () => {
|
||||
)}`}
|
||||
>
|
||||
<EntityLogs
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'lib/visualization/charts/TimeSeries/TimeSeries';
|
||||
import {
|
||||
@@ -41,7 +40,6 @@ import ChartTooltipFooter from './ChartTooltipFooter';
|
||||
|
||||
interface EntityMetricsProps<T> {
|
||||
entity: T;
|
||||
eventEntity: string;
|
||||
entityWidgetInfo: K8sDetailsWidgetInfo[];
|
||||
getEntityQueryPayload: (
|
||||
node: T,
|
||||
@@ -55,7 +53,6 @@ interface EntityMetricsProps<T> {
|
||||
|
||||
function EntityMetrics<T>({
|
||||
entity,
|
||||
eventEntity,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKey,
|
||||
@@ -203,11 +200,7 @@ function EntityMetrics<T>({
|
||||
<div ref={setTooltipPortalEl} style={{ display: 'contents' }} />
|
||||
|
||||
<div className={styles.metricsHeader}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.MetricsView}
|
||||
/>
|
||||
<EntityDateTimeSelector category={category} />
|
||||
</div>
|
||||
<div className={styles.entityMetricsContainer}>
|
||||
{queries.map((query, idx) => (
|
||||
|
||||
@@ -293,7 +293,6 @@ const renderEntityMetrics = (overrides = {}): any => {
|
||||
<TestRouter>
|
||||
<EntityMetrics
|
||||
entity={defaultProps.entity}
|
||||
eventEntity="test"
|
||||
entityWidgetInfo={defaultProps.entityWidgetInfo}
|
||||
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
|
||||
queryKey={defaultProps.queryKey}
|
||||
|
||||
@@ -60,7 +60,6 @@ function renderEntityMetrics(store: GlobalTimeStoreApi): jest.Mock {
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<EntityMetrics
|
||||
entity={entity}
|
||||
eventEntity="test"
|
||||
entityWidgetInfo={[{ title: 'CPU Usage', yAxisUnit: 'percentage' }]}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
queryKey="test-query-key"
|
||||
|
||||
@@ -58,7 +58,6 @@ type Story = StoryObj<OverviewArgs>;
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventEntity: 'pod',
|
||||
attributes: POD_ATTRIBUTES,
|
||||
entity: { podUID: POD_UID },
|
||||
entityName: POD_ATTRIBUTES['k8s.pod.name'],
|
||||
|
||||
@@ -63,7 +63,6 @@ import styles from './EntityOverview.module.scss';
|
||||
|
||||
export interface EntityOverviewProps {
|
||||
category: InfraMonitoringEntity;
|
||||
eventEntity: string;
|
||||
/** The drawer entity's own attributes, which scope every related query */
|
||||
attributes: EntityAttributes;
|
||||
/** The drawer's own record and query prefix, so a row click can leave a
|
||||
@@ -82,7 +81,6 @@ interface ContentProps extends EntityOverviewProps {
|
||||
|
||||
function EntityOverviewContent({
|
||||
category,
|
||||
eventEntity,
|
||||
attributes,
|
||||
entity,
|
||||
entityName,
|
||||
@@ -190,11 +188,7 @@ function EntityOverviewContent({
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.controls}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={VIEW_TYPES.OVERVIEW}
|
||||
/>
|
||||
<EntityDateTimeSelector category={category} />
|
||||
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isFetching}
|
||||
|
||||
@@ -88,7 +88,6 @@ function overviewTree(
|
||||
>
|
||||
<EntityOverview
|
||||
category={InfraMonitoringEntity.NODES}
|
||||
eventEntity="node"
|
||||
attributes={attributes}
|
||||
entity={{ nodeName: attributes['k8s.node.name'] }}
|
||||
entityName={attributes['k8s.node.name']}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
@@ -39,17 +37,18 @@ import { getEntityTracesQueryPayload } from './utils';
|
||||
|
||||
import styles from './EntityTraces.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
import {
|
||||
logInfraDrawerFilterCustomizedEvent,
|
||||
logInfraDrawerTraceClickedEvent,
|
||||
} from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface Props {
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
}
|
||||
|
||||
function EntityTracesContent({
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
@@ -102,13 +101,6 @@ function EntityTracesContent({
|
||||
saveRecentQueryByExpression(DataSource.TRACES, newUserExpression);
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
});
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(
|
||||
category,
|
||||
'traces',
|
||||
@@ -119,14 +111,7 @@ function EntityTracesContent({
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[
|
||||
inputExpression,
|
||||
initialExpression,
|
||||
refetch,
|
||||
querySearchOnRun,
|
||||
category,
|
||||
eventEntity,
|
||||
],
|
||||
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
@@ -154,11 +139,7 @@ function EntityTracesContent({
|
||||
const hasAdditionalFilters = !!userExpression?.trim();
|
||||
|
||||
const handleRowClick = useCallback(() => {
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
category,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
});
|
||||
logInfraDrawerTraceClickedEvent(category);
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,11 +152,7 @@ function EntityTracesContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.TracesView}
|
||||
/>
|
||||
<EntityDateTimeSelector category={category} />
|
||||
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isFetching}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { act, screen, within } from 'tests/test-utils';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { act, screen, userEvent, within } from 'tests/test-utils';
|
||||
|
||||
import { renderEntityTraces } from './testUtils';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
@@ -20,6 +22,11 @@ jest.spyOn(window, 'matchMedia').mockImplementation(
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
describe('EntityTraces - Table Rendering', () => {
|
||||
it('should render table with all column headers', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({ pageSize: 3 });
|
||||
@@ -182,4 +189,23 @@ describe('EntityTraces - Table Rendering', () => {
|
||||
'database',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should log the drawer trace clicked event when a trace row is clicked', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [{ serviceName: 'frontend' }],
|
||||
});
|
||||
|
||||
renderEntityTraces();
|
||||
|
||||
const user = userEvent.setup();
|
||||
const cell = await screen.findByTestId('serviceName');
|
||||
await user.click(cell.closest('tr') as HTMLElement);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Infra Monitoring: Drawer trace clicked',
|
||||
{
|
||||
entityType: InfraMonitoringEntity.PODS,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,6 @@ describe('EntityTraces time range wiring', () => {
|
||||
)}`}
|
||||
>
|
||||
<EntityTraces
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
|
||||
@@ -33,7 +33,6 @@ export function renderEntityTraces({
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<EntityTraces
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={category}
|
||||
initialExpression={expression}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { logInfraDrawerFilterCustomizedEvent } from '../events';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
describe('logInfraDrawerFilterCustomizedEvent', () => {
|
||||
it('logs a cleared filter with no expression keys', () => {
|
||||
logInfraDrawerFilterCustomizedEvent(
|
||||
InfraMonitoringEntity.PODS,
|
||||
'logs',
|
||||
'',
|
||||
'search',
|
||||
);
|
||||
|
||||
expect(jest.mocked(logEvent)).toHaveBeenCalledWith(
|
||||
'Infra Monitoring: Drawer filter customized',
|
||||
{
|
||||
entityType: InfraMonitoringEntity.PODS,
|
||||
tab: 'logs',
|
||||
expressionKeys: [],
|
||||
filterSource: 'search',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
|
||||
import { CustomTab } from '../Base/types';
|
||||
@@ -11,20 +10,6 @@ import {
|
||||
|
||||
import EntityMetrics from './EntityMetrics';
|
||||
|
||||
const categoryToEventEntity: Record<InfraMonitoringEntity, string> = {
|
||||
[InfraMonitoringEntity.DAEMONSETS]: InfraMonitoringEvents.DaemonSet,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: InfraMonitoringEvents.Deployment,
|
||||
[InfraMonitoringEntity.JOBS]: InfraMonitoringEvents.Job,
|
||||
[InfraMonitoringEntity.NAMESPACES]: InfraMonitoringEvents.Namespace,
|
||||
[InfraMonitoringEntity.STATEFULSETS]: InfraMonitoringEvents.StatefulSet,
|
||||
[InfraMonitoringEntity.PODS]: InfraMonitoringEvents.Pod,
|
||||
[InfraMonitoringEntity.NODES]: InfraMonitoringEvents.Node,
|
||||
[InfraMonitoringEntity.CLUSTERS]: InfraMonitoringEvents.Cluster,
|
||||
[InfraMonitoringEntity.VOLUMES]: InfraMonitoringEvents.Volume,
|
||||
[InfraMonitoringEntity.HOSTS]: InfraMonitoringEvents.HostEntity,
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'container',
|
||||
};
|
||||
|
||||
interface CreatePodMetricsTabParams<T> {
|
||||
getQueryPayload: (
|
||||
entity: T,
|
||||
@@ -42,8 +27,6 @@ export function createPodMetricsTab<T>({
|
||||
category,
|
||||
docBasePath,
|
||||
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
|
||||
const eventEntity = categoryToEventEntity[category];
|
||||
|
||||
const widgetInfo = docBasePath
|
||||
? podUtilizationByPodWidgetInfo.map((widget) => ({
|
||||
...widget,
|
||||
@@ -58,7 +41,6 @@ export function createPodMetricsTab<T>({
|
||||
render: ({ entity }) => (
|
||||
<EntityMetrics
|
||||
entity={entity}
|
||||
eventEntity={eventEntity}
|
||||
entityWidgetInfo={widgetInfo}
|
||||
getEntityQueryPayload={getQueryPayload}
|
||||
queryKey={queryKey}
|
||||
|
||||
@@ -10,9 +10,9 @@ export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_drawer_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
void logEvent('Infra Monitoring: Drawer time range customized', {
|
||||
entityType,
|
||||
rangeLabel: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,15 +26,11 @@ export function logInfraDrawerFilterCustomizedEvent(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_drawer_filter_customized', {
|
||||
entity_type: entityType,
|
||||
void logEvent('Infra Monitoring: Drawer filter customized', {
|
||||
entityType,
|
||||
tab,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
filter_source: filterSource,
|
||||
expressionKeys: [...new Set(expressionKeys)],
|
||||
filterSource,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,9 +39,17 @@ export function logInfraDrawerTabViewedEvent(
|
||||
tab: string,
|
||||
isDefaultTab: boolean,
|
||||
): void {
|
||||
void logEvent('infra_drawer_tab_viewed', {
|
||||
entity_type: entityType,
|
||||
void logEvent('Infra Monitoring: Drawer tab viewed', {
|
||||
entityType,
|
||||
tab,
|
||||
is_default_tab: isDefaultTab,
|
||||
isDefaultTab,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTraceClickedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
): void {
|
||||
void logEvent('Infra Monitoring: Drawer trace clicked', {
|
||||
entityType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ import {
|
||||
} from './hooks';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import {
|
||||
@@ -120,13 +118,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
}, [compositeQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
useEffect(() => {
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: selectedCategory,
|
||||
view: InfraMonitoringEvents.QuickFiltersView,
|
||||
});
|
||||
|
||||
// The category query param is a plain string; entities are validated by
|
||||
// getEntityConfig before rendering
|
||||
logInfraMonitoringListViewedEvent(selectedCategory as InfraMonitoringEntity);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
@@ -125,7 +124,6 @@ export const jobEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.JOBS,
|
||||
eventCategory: InfraMonitoringEvents.Job,
|
||||
tableColumns: k8sJobsColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sJobRowKey,
|
||||
@@ -134,7 +132,6 @@ export const jobEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.JOBS,
|
||||
eventCategory: InfraMonitoringEvents.Job,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'job',
|
||||
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
@@ -136,7 +135,6 @@ export const namespaceEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.NAMESPACES,
|
||||
eventCategory: InfraMonitoringEvents.Namespace,
|
||||
tableColumns: k8sNamespacesColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sNamespaceRowKey,
|
||||
@@ -145,7 +143,6 @@ export const namespaceEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.NAMESPACES,
|
||||
eventCategory: InfraMonitoringEvents.Namespace,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'namespace',
|
||||
getSelectedItemExpression: k8sNamespaceGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
@@ -123,7 +122,6 @@ export const nodeEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.NODES,
|
||||
eventCategory: InfraMonitoringEvents.Node,
|
||||
tableColumns: k8sNodesColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sNodeRowKey,
|
||||
@@ -132,7 +130,6 @@ export const nodeEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
eventCategory: InfraMonitoringEvents.Node,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'node',
|
||||
getSelectedItemExpression: k8sNodeGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
@@ -114,7 +113,6 @@ export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO>
|
||||
{
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
tableColumns: k8sPodColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sPodRowKey,
|
||||
@@ -123,7 +121,6 @@ export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO>
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'pod',
|
||||
getSelectedItemExpression: k8sPodGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { createPodMetricsTab } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/createPodMetricsTab';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
@@ -125,7 +124,6 @@ export const statefulSetEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.STATEFULSETS,
|
||||
eventCategory: InfraMonitoringEvents.StatefulSet,
|
||||
tableColumns: k8sStatefulSetsColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sStatefulSetRowKey,
|
||||
@@ -134,7 +132,6 @@ export const statefulSetEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.STATEFULSETS,
|
||||
eventCategory: InfraMonitoringEvents.StatefulSet,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'statefulSet',
|
||||
getSelectedItemExpression: k8sStatefulSetGetSelectedItemExpression,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
@@ -117,7 +116,6 @@ export const volumeEntityConfig: K8sEntityConfig<
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.VOLUMES,
|
||||
eventCategory: InfraMonitoringEvents.Volumes,
|
||||
tableColumns: k8sVolumesColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sVolumeRowKey,
|
||||
@@ -126,7 +124,6 @@ export const volumeEntityConfig: K8sEntityConfig<
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.VOLUMES,
|
||||
eventCategory: InfraMonitoringEvents.Volume,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'volume',
|
||||
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
// Mock the useContextLogData hook
|
||||
const mockHandleRunQuery = jest.fn();
|
||||
|
||||
jest.mock('hooks/logs/useIsBodyJsonEnabled', () => ({
|
||||
useIsBodyJsonEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('container/OptionsMenu', () => ({
|
||||
useOptionsMenu: (): any => ({
|
||||
options: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
ChangeViewFunctionType,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
PrettyViewAction,
|
||||
VisibleActionsConfig,
|
||||
} from 'periscope/components/PrettyView/PrettyView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { LogDetailsAction } from '../constants';
|
||||
@@ -54,11 +53,7 @@ export function useLogAttributeActions({
|
||||
onApplyLogFilter,
|
||||
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
const isBodyJsonQueryEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
const filterFor = useCallback(
|
||||
(context: FieldContext, isFilterIn: boolean): void => {
|
||||
|
||||
@@ -2,11 +2,7 @@ import * as Sentry from '@sentry/react';
|
||||
import Convert from 'ansi-to-html';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import dompurify from 'dompurify';
|
||||
import {
|
||||
ILog,
|
||||
ILogAggregateAttributesResources,
|
||||
ILogBody,
|
||||
} from 'types/api/logs/log';
|
||||
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
@@ -349,8 +345,3 @@ export const getSanitizedLogBody = (
|
||||
return '{}';
|
||||
}
|
||||
};
|
||||
|
||||
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
|
||||
export function getBodyDisplayString(body: string | ILogBody): string {
|
||||
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Form, Select, Space } from 'antd';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ModalFooterTitle } from 'container/PipelinePage/styles';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
import { ProcessorData } from 'types/api/pipeline/def';
|
||||
|
||||
import { formValidationRules } from '../config';
|
||||
@@ -138,10 +137,7 @@ function ProcessorForm({
|
||||
selectedProcessorData,
|
||||
isAdd,
|
||||
}: ProcessorFormProps): JSX.Element {
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
return (
|
||||
<div className="processor-form-container">
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Expand } from '@signozhq/icons';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@@ -31,7 +31,7 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
<div className="logs-preview-list-item-body">
|
||||
{getBodyDisplayString(log.body)}
|
||||
{getBodyDisplay(log.body)}
|
||||
</div>
|
||||
<div
|
||||
className="logs-preview-list-item-expand"
|
||||
|
||||
@@ -32,7 +32,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
GEO_MAP: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -24,5 +24,4 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.GEO_MAP]: null,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { ScalarFieldValue } from 'utils/stringUtils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -21,7 +22,7 @@ export type UseActiveLog = {
|
||||
onClearActiveLog: () => void;
|
||||
onAddToQuery: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
fieldValue: ScalarFieldValue,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
) => void;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ScalarFieldValue } from 'utils/stringUtils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { getFilterQueryData } from 'container/LogDetailedView/logAttributeActions.utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -37,7 +38,7 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
const onAddToQuery = useCallback(
|
||||
(
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
fieldValue: ScalarFieldValue,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
): void => {
|
||||
|
||||
10
frontend/src/hooks/logs/useIsBodyJsonEnabled.ts
Normal file
10
frontend/src/hooks/logs/useIsBodyJsonEnabled.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
export function useIsBodyJsonEnabled(): boolean {
|
||||
const { featureFlags } = useAppContext();
|
||||
return (
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active ?? false
|
||||
);
|
||||
}
|
||||
@@ -262,17 +262,21 @@ describe('withAuthZ router context stability', () => {
|
||||
server.use(setupAuthzGrantByPrefix('read||__||role'));
|
||||
});
|
||||
|
||||
it('does not re-render when useParams returns new object with same values', async () => {
|
||||
it('keeps the router context when useParams returns new object with same values', async () => {
|
||||
mockAppParams({ roleId: 'r-1' });
|
||||
mockAppLocation({ pathname: '/', search: '' });
|
||||
const seen: RouterContext[] = [];
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
checks: (_props, router: RouterContext) => {
|
||||
seen.push(router);
|
||||
return [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
@@ -281,24 +285,24 @@ describe('withAuthZ router context stability', () => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
const checksBefore = seen.length;
|
||||
|
||||
// Return NEW object with SAME values — should not cause re-render
|
||||
// Return NEW object with SAME values: the router context stays the same
|
||||
mockAppParams({ roleId: 'r-1' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
// Allow any pending effects to flush
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
expect(seen.length).toBeGreaterThan(checksBefore);
|
||||
expect(seen[seen.length - 1]).toBe(seen[0]);
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same pathname', async () => {
|
||||
it('keeps the router context when useLocation returns new object with same pathname', async () => {
|
||||
mockAppParams({});
|
||||
mockAppLocation({ pathname: '/roles', search: '' });
|
||||
const seen: RouterContext[] = [];
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
seen.push(router);
|
||||
const match = router.matchPath<{ id: string }>('/roles/:id');
|
||||
return match
|
||||
? [buildPermission('read', buildObjectString<'read'>('role', match.id))]
|
||||
@@ -312,23 +316,24 @@ describe('withAuthZ router context stability', () => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
const checksBefore = seen.length;
|
||||
|
||||
// Return NEW object with SAME pathname — should not cause re-render
|
||||
// Return NEW object with SAME pathname: the router context stays the same
|
||||
mockAppLocation({ pathname: '/roles', search: '' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
expect(seen.length).toBeGreaterThan(checksBefore);
|
||||
expect(seen[seen.length - 1]).toBe(seen[0]);
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same search params', async () => {
|
||||
it('keeps the router context when useLocation returns new object with same search params', async () => {
|
||||
mockAppParams({});
|
||||
mockAppLocation({ pathname: '/', search: '?tab=keys' });
|
||||
const seen: RouterContext[] = [];
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
seen.push(router);
|
||||
// Access searchParams to ensure it's part of the dependency chain
|
||||
void router.searchParams.get('tab');
|
||||
return [readPerm];
|
||||
@@ -341,15 +346,14 @@ describe('withAuthZ router context stability', () => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
const checksBefore = seen.length;
|
||||
|
||||
// Return NEW object with SAME search — should not cause re-render
|
||||
// Return NEW object with SAME search: the router context stays the same
|
||||
mockAppLocation({ pathname: '/', search: '?tab=keys' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
expect(seen.length).toBeGreaterThan(checksBefore);
|
||||
expect(seen[seen.length - 1]).toBe(seen[0]);
|
||||
});
|
||||
|
||||
it('re-renders when params values actually change', async () => {
|
||||
|
||||
25
frontend/src/lib/getMinMax.test.ts
Normal file
25
frontend/src/lib/getMinMax.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import GetMinMax from './getMinMax';
|
||||
|
||||
const DAY_NS = 24 * 60 * 60 * 1000 * 1e6;
|
||||
|
||||
function rangeInDays(interval: string): number {
|
||||
const { minTime, maxTime } = GetMinMax(interval);
|
||||
return Math.round((maxTime - minTime) / DAY_NS);
|
||||
}
|
||||
|
||||
describe('GetMinMax', () => {
|
||||
it.each([
|
||||
['1month', 30],
|
||||
['2months', 60],
|
||||
['3months', 90],
|
||||
['6month', 180],
|
||||
['10d', 10],
|
||||
['3w', 21],
|
||||
])('resolves %p to %p days', (interval, days) => {
|
||||
expect(rangeInDays(interval)).toBe(days);
|
||||
});
|
||||
|
||||
it('throws on an unknown interval', () => {
|
||||
expect(() => GetMinMax('1y')).toThrow('invalid time type');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user