Compare commits

..

10 Commits

Author SHA1 Message Date
Gaurav Tewari
eddac1f169 refactor(saved-views): use the source page instead of mapping from DataSource
Callers already know the source page; going through DataSource could not
tell meter or AI observability apart.
2026-11-02 10:48:14 +05:30
Gaurav Tewari
00feed18d3 chore(saved-views): mark the source map for removal 2026-11-02 10:47:18 +05:30
Gaurav Tewari
9b9f07c71a Merge remote-tracking branch 'origin/main' into refactor/saved-view-source-page
# Conflicts:
#	frontend/src/container/SavedViews/hooks/useSavedViewActions.ts
2026-11-02 09:59:05 +05:30
Nityananda Gohain
a621fb2b49 feat(aiobservability): read the messages the Langfuse SDK writes (#13331)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Langfuse SDK spans reached the thread with empty formatted messages:
the SDK writes them to `langfuse.observation.input/output`, which the
mapper never moved onto `gen_ai.*.messages`.
- `gen_ai.llm` v5 moves `langfuse.observation.input/output` onto
`gen_ai.input/output.messages` and copies `langfuse.observation.type`
onto `gen_ai.operation.name`, above every existing source. New spans
only.
- Formatter handles the shapes these spans carry: a superjson `{json}`
envelope, a single message object (incl. role-less `{tool_calls}` from
the LangChain handler), a bare list of Responses items (LiteLLM
`langfuse_otel` tool turns), and falls back to the raw output when the
OpenInference bucket is empty. Mixed-format warning dropped when one
side is generic or OpenInference.

  #### Additional Information

- Verified with unit tests, the shipped definition through the
in-process collector pipeline.
- Why Langfuse keeps its own keys instead of emitting semconv, checked
in the Langfuse repos:
- Docs ([OpenTelemetry
integration](https://langfuse.com/integrations/native/opentelemetry)):
`langfuse.*` attributes "always take precedence over the generic
OpenTelemetry conventions" and are "recommended for all users that are
manually instrumenting"; semconv is "still evolving", so OTel traces are
mapped into the Langfuse model.
- [Discussion #2509](https://github.com/orgs/langfuse/discussions/2509),
Sep 2024, maintainer: Langfuse features "not yet covered by the
conventions" and the risk of "breaking backwards compatibility once
conventions are in place" are the reasons for an own namespace.
- Ingestion (`OtelIngestionProcessor.ts`, `extractInputAndOutput`): own
keys first, then vendor keys, then semconv `gen_ai.*.messages`, then
events. Semconv is one vendor format among many.
- SDK sources: input and output are any JSON on any observation type
(span, agent, tool, generation, …), looser than `gen_ai.input.messages`.
- Still unmapped: `usage_details`, `cost_details`, `model.parameters`
are JSON objects; the mapper cannot extract fields, so pure Langfuse
spans have no tokens or cost.


Closes https://github.com/SigNoz/nerve-pod/issues/579
2026-11-01 07:54:17 +00:00
Vinicius Lourenço
72443c8267 chore(deps): bump axios to 1.21.0 (#13339)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Bump `axios` from 1.18.0 to 1.21.0 to fix the high-severity advisories
affecting `< 1.20.0`.
2026-11-01 05:35:32 +00:00
Vinicius Lourenço
b37231f31a fix(saved-views): remove loading effect from authz checks (#13287)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This basically does two fixes:
- Remove loading effect
- The effect was not looking good, this was implemented during the
migration of components.
- Remove read checks for each item
- We should not have loading for each item in the list, against our
guidelines for authz
- The list already returns the info needed in read, redundant to have
both checks
- Replace the button with a link
  - To improve UX of this views, they were suppose to act as links.

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

Closes https://github.com/SigNoz/uforge-pod/issues/6394

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

Normal view:
<img width="1680" height="1224"
alt="dark--components-saved-views-authz--no-read-open-view--blink (1)"
src="https://github.com/user-attachments/assets/68772cda-11f1-4798-8012-79bfe685cbcd"
/>

No read only:
<img width="1680" height="1224"
alt="dark--components-saved-views-overview--unsaved-changes--blink (1)"
src="https://github.com/user-attachments/assets/898ab59a-f870-4d7c-8c2a-e20843f98444"
/>

Long names:
<img width="1680" height="1224"
alt="dark--components-saved-views-overview--long-names--blink (1)"
src="https://github.com/user-attachments/assets/48a4bd61-8335-4047-8c2a-eca42c43653e"
/>

No read only 2:
<img width="1680" height="1224"
alt="dark--components-saved-views-authz--no-read--blink (1)"
src="https://github.com/user-attachments/assets/af04bb90-1ce7-4c36-818b-49e26933ea0b"
/>


Full diff:
https://claude.ai/artifact/S43YyxbMmCdtZLmEXVNsgm?sk=LXctCOiBiN5kGigap2M_JA
2026-10-30 16:53:33 +00:00
Gaurav Tewari
3fff4d14f2 test: type the mock and update tests 2026-10-30 03:04:30 +05:30
Gaurav Tewari
0d95d6ec83 refactor(saved-views): key saved view options by source page 2026-10-29 18:04:32 +05:30
Gaurav Tewari
44f11a1d84 refactor(options-menu): take source page instead of data source 2026-10-29 17:08:08 +05:30
Gaurav Tewari
7f3fbb3562 refactor(preferences): key preference slices by source page
Preferences belong to an explorer page, not a signal.
2026-10-29 17:08:02 +05:30
139 changed files with 2469 additions and 2042 deletions

View File

@@ -17,7 +17,7 @@ Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` fr
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
| `langfuse.observation.model.name`, `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.observation.type`, `langfuse.session.id` | Langfuse SDK v3 (Python) and v4 (JS), LiteLLM `langfuse_otel` callback |
| `session.id` | OpenInference, Langfuse, Claude Code |
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |

View File

@@ -593,13 +593,12 @@
}
},
{
// The react-router allowlist: the facade itself, the route table, the router mount,
// `useSafeNavigate`, the history singleton and the test and story router mounts. Test files are
// The react-router allowlist: the facade itself, the route table, `useSafeNavigate`,
// the history singleton and the test and story router mounts. Test files are
// deliberately absent: they render at a route or use tests/router-mocks.
"files": [
"src/lib/router/**",
"src/AppRoutes/**",
"src/app/AppRouter.tsx",
"src/hooks/useSafeNavigate.ts",
"src/lib/history.ts",
"src/tests/router.tsx",

View File

@@ -5,8 +5,6 @@
/**
* Adds custom matchers from the react testing library to all tests
*/
import { TextDecoder, TextEncoder } from 'node:util';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import 'jest-styled-components';
@@ -16,14 +14,6 @@ import { server } from './src/mocks-server/server';
import './src/styles.scss';
// Establish API mocking before all tests.
// react-router@7's entry point pulls in its server-runtime cookie signing,
// which builds a TextEncoder at module scope. jsdom ships neither encoder, so
// importing anything from the router throws before a test starts.
Object.assign(globalThis, {
TextEncoder: globalThis.TextEncoder ?? TextEncoder,
TextDecoder: globalThis.TextDecoder ?? TextDecoder,
});
// Mock window.matchMedia
window.matchMedia =
window.matchMedia ||

View File

@@ -70,7 +70,7 @@
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"antlr4": "4.13.2",
"axios": "1.18.0",
"axios": "1.21.0",
"babel-jest": "^29.6.4",
"chart.js": "3.9.1",
"chartjs-adapter-date-fns": "^2.0.0",
@@ -83,7 +83,7 @@
"dompurify": "3.4.15",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "5.3.0",
"history": "4.10.1",
"html-to-image": "1.11.13",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
@@ -117,7 +117,8 @@
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router": "7.18.4",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.30.6",
"react-syntax-highlighter": "15.5.0",
"react-use": "^17.3.2",
"react-virtuoso": "4.0.3",
@@ -171,6 +172,7 @@
"@types/crypto-js": "4.2.2",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
"@types/node": "^22.20.5",
@@ -181,6 +183,7 @@
"@types/react-grid-layout": "^1.1.2",
"@types/react-redux": "^7.1.11",
"@types/react-resizable": "3.0.3",
"@types/react-router-dom": "^5.1.6",
"@types/react-syntax-highlighter": "15.5.13",
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",

View File

@@ -54,8 +54,8 @@ await ruleTester({
errors: [{ message: MOCK }],
},
{
name: 'jest.doMock of react-router',
code: "jest.doMock('react-router');",
name: 'jest.doMock of react-router-dom-v5-compat',
code: "jest.doMock('react-router-dom-v5-compat');",
errors: [{ message: MOCK }],
},
{
@@ -74,9 +74,9 @@ await ruleTester({
errors: [{ message: FACADE }],
},
{
name: 'react-router-dom is flagged as the v7 re-export shim',
code: "import { Outlet } from 'react-router-dom';",
errors: [{ message: 'only a re-export shim' }],
name: 'react-router-dom-v5-compat',
code: "import { useNavigate } from 'react-router-dom-v5-compat';",
errors: [{ message: 'compat package is an implementation detail' }],
},
{
name: 'lib/history default import',

View File

@@ -5,15 +5,12 @@
* upgrade is a change to one directory instead of ~300 call sites. This rule
* keeps new call sites, and test mocks, from reaching past it.
*
* `react-router-dom` is still flagged even though the package is gone: on v7 it is a
* re-export shim over `react-router`, so reinstalling it would split the tree again.
*
* `history` (the package) is deliberately not flagged: it is a transitive concern of
* the version bump, not something the facade replaces.
*
* The allowlist (the facade itself, the route table, the router mount, `useSafeNavigate`,
* the history singleton and the test and story router mounts) is applied via overrides
* in .oxlintrc.json, not here, so the file list stays visible next to the severity.
* The allowlist (the facade itself, the route table, `useSafeNavigate`, the history
* singleton and the test and story router mounts) is applied via overrides in
* .oxlintrc.json, not here, so the file list stays visible next to the severity.
*/
import path from 'node:path';
@@ -32,8 +29,9 @@ const JEST_MODULE_METHODS = new Set([
]);
const MESSAGE_IDS = {
'react-router': 'router',
'react-router-dom': 'routerDom',
'react-router': 'v5Router',
'react-router-dom': 'v5Router',
'react-router-dom-v5-compat': 'v6Compat',
'lib/history': 'historySingleton',
'@/lib/history': 'historySingleton',
};
@@ -67,10 +65,10 @@ export default {
},
schema: [],
messages: {
router:
'Do not import react-router directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
routerDom:
'Do not import react-router-dom. The app is on react-router; on v7 react-router-dom is only a re-export shim, and installing it puts two copies of the router in the tree. Use the src/lib/router facade.',
v5Router:
'Do not import react-router-dom directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
v6Compat:
'Do not import react-router-dom-v5-compat directly. Use the src/lib/router facade instead; the compat package is an implementation detail of the migration and disappears with it.',
historySingleton:
'Do not import the lib/history singleton. Use the helpers in src/lib/router/navigation.ts, or useAppNavigate() to follow the nearest router. history loses basename handling once the router owns it.',
mock:

132
frontend/pnpm-lock.yaml generated
View File

@@ -24,6 +24,7 @@ overrides:
nanoid@<3.3.18: '>=3.3.18 <4'
prismjs@<1.30.0: '>=1.30.0 <2'
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
tmp@<0.2.6: '>=0.2.6 <0.3.0'
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
'@types/node': ^22.20.5
@@ -82,7 +83,7 @@ importers:
version: 0.4.0
'@signozhq/ui':
specifier: 0.2.0
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -126,8 +127,8 @@ importers:
specifier: 4.13.2
version: 4.13.2
axios:
specifier: 1.18.0
version: 1.18.0
specifier: 1.21.0
version: 1.21.0
babel-jest:
specifier: ^29.6.4
version: 29.6.4(@babel/core@7.29.7)
@@ -165,8 +166,8 @@ importers:
specifier: 5.0.1
version: 5.0.1
history:
specifier: 5.3.0
version: 5.3.0
specifier: 4.10.1
version: 4.10.1
html-to-image:
specifier: 1.11.13
version: 1.11.13
@@ -202,7 +203,7 @@ importers:
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
overlayscrollbars:
specifier: ^2.16.0
version: 2.16.0
@@ -266,9 +267,12 @@ importers:
react-rnd:
specifier: ^10.5.3
version: 10.5.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router:
specifier: 7.18.4
version: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router-dom:
specifier: ^5.2.0
version: 5.3.4(react@18.2.0)
react-router-dom-v5-compat:
specifier: 6.30.6
version: 6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0)
react-syntax-highlighter:
specifier: 15.5.0
version: 15.5.0(react@18.2.0)
@@ -387,6 +391,9 @@ importers:
'@types/event-source-polyfill':
specifier: ^1.0.0
version: 1.0.1
'@types/history':
specifier: 4.7.11
version: 4.7.11
'@types/jest':
specifier: 30.0.0
version: 30.0.0
@@ -417,6 +424,9 @@ importers:
'@types/react-resizable':
specifier: 3.0.3
version: 3.0.3
'@types/react-router-dom':
specifier: ^5.1.6
version: 5.3.3
'@types/react-syntax-highlighter':
specifier: 15.5.13
version: 15.5.13
@@ -3318,6 +3328,10 @@ packages:
'@react-dnd/shallowequal@4.0.2':
resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
'@remix-run/router@1.23.4':
resolution: {integrity: sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3999,6 +4013,9 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/history@4.7.11':
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
'@types/hoist-non-react-statics@3.3.1':
resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
@@ -4080,6 +4097,12 @@ packages:
'@types/react-resizable@3.0.3':
resolution: {integrity: sha512-W/QsUOZoXBAIBQNhNm95A5ohoaiUA874lWQytO2UP9dOjp5JHO9+a0cwYNabea7sA12ZDJnGVUFZxcNaNksAWA==}
'@types/react-router-dom@5.3.3':
resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
'@types/react-router@5.1.20':
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
@@ -4570,8 +4593,8 @@ packages:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
axios@1.18.0:
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
axios@1.21.0:
resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==}
babel-jest@29.6.4:
resolution: {integrity: sha512-meLj23UlSLddj6PC+YTOFRgDAtjnZom8w/ACsrx0gtPtv5cJZk0A5Unk5bV4wixD7XaPCN1fQvpww8czkZURmw==}
@@ -5012,10 +5035,6 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
@@ -7496,7 +7515,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7
react-router: '>=6.30.6 <7'
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -7517,7 +7536,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7 || ^8
react-router: '>=6.30.6 <7'
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -8441,6 +8460,14 @@ packages:
react: '>=16.3.0'
react-dom: '>=16.3.0'
react-router-dom-v5-compat@6.30.6:
resolution: {integrity: sha512-I54amXpivD3V0r1BYUIj1qLOljXx+UpbLG63Bqbm3BdKH62z4H3znC0L7SyMTYAMEDZuUjDSlqtkBRG/OEDXBg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router-dom: 4 || 5
react-router-dom@5.3.4:
resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==}
peerDependencies:
@@ -8451,15 +8478,11 @@ packages:
peerDependencies:
react: '>=15'
react-router@7.18.4:
resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==}
engines: {node: '>=20.0.0'}
react-router@6.30.6:
resolution: {integrity: sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react: '>=16.8'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
@@ -12905,6 +12928,8 @@ snapshots:
'@react-dnd/shallowequal@4.0.2': {}
'@remix-run/router@1.23.4': {}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
optional: true
@@ -13137,7 +13162,7 @@ snapshots:
'@signozhq/icons@0.4.0': {}
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)':
dependencies:
'@base-ui/react': 1.7.0(@date-fns/tz@1.4.1)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@chenglou/pretext': 0.0.8
@@ -13161,7 +13186,7 @@ snapshots:
lodash-es: 4.18.1
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-compiler-runtime: 1.0.0(react@18.2.0)
react-day-picker: 9.9.0(react@18.2.0)
@@ -13613,6 +13638,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.2
'@types/history@4.7.11': {}
'@types/hoist-non-react-statics@3.3.1':
dependencies:
'@types/react': 18.0.26
@@ -13709,6 +13736,17 @@ snapshots:
dependencies:
'@types/react': 18.0.26
'@types/react-router-dom@5.3.3':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.0.26
'@types/react-router': 5.1.20
'@types/react-router@5.1.20':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.0.26
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 18.0.26
@@ -14245,7 +14283,7 @@ snapshots:
axe-core@4.13.0: {}
axios@1.18.0:
axios@1.21.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
@@ -14750,8 +14788,6 @@ snapshots:
cookie@0.7.2: {}
cookie@1.1.1: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
@@ -16262,8 +16298,7 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isarray@0.0.1:
optional: true
isarray@0.0.1: {}
isarray@1.0.0: {}
@@ -17758,20 +17793,20 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.2.0
optionalDependencies:
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.1.0
react: 18.2.0
optionalDependencies:
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nwsapi@2.2.23: {}
@@ -18147,7 +18182,6 @@ snapshots:
path-to-regexp@1.9.0:
dependencies:
isarray: 0.0.1
optional: true
path-to-regexp@6.3.0: {}
@@ -18917,9 +18951,18 @@ snapshots:
react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
tslib: 2.6.2
react-router-dom-v5-compat@6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0):
dependencies:
'@remix-run/router': 1.23.4
history: 5.3.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
react-router-dom@5.3.4(react@18.2.0):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.28.2
history: 4.10.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -18927,11 +18970,10 @@ snapshots:
react-router: 5.3.4(react@18.2.0)
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
optional: true
react-router@5.3.4(react@18.2.0):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.28.2
history: 4.10.1
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@@ -18941,15 +18983,11 @@ snapshots:
react-is: 16.13.1
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
optional: true
react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
react-router@6.30.6(react@18.2.0):
dependencies:
cookie: 1.1.1
'@remix-run/router': 1.23.4
react: 18.2.0
set-cookie-parser: 2.6.0
optionalDependencies:
react-dom: 18.2.0(react@18.2.0)
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
dependencies:
@@ -20272,7 +20310,7 @@ snapshots:
wait-on@7.2.0:
dependencies:
axios: 1.18.0
axios: 1.21.0
joi: 17.13.7
lodash: 4.18.1
minimist: 1.2.8

View File

@@ -74,6 +74,11 @@ overrides:
# (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'
# via: @signozhq/ui > nuqs@2 (react-router ^6.4.0 || ^7)
# remove: blocked. GHSA-wrjc-x8rr-h8h6 and the deserializeErrors advisory are patched
# only in 7.18.0. Do NOT open the cap: react-router >=7 requires React 19 and breaks
# the app-wide CompatRouter, so those two moderates stay until the app moves to React 19
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'

View File

@@ -1,4 +1,5 @@
import { ReactChild, useCallback, useMemo } from 'react';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useListUsers } from 'api/generated/services/users';
@@ -8,9 +9,6 @@ import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { isEmpty } from 'lodash-es';
import { matchRoute } from 'lib/router/matchRoute';
import { Redirect } from 'lib/router/Redirect';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
@@ -30,7 +28,7 @@ import routes, {
// eslint-disable-next-line sonarjs/cognitive-complexity
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const location = useAppLocation();
const location = useLocation();
const { pathname } = location;
const {
org,
@@ -49,11 +47,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
() =>
new Map(
[...routes, LIST_LICENSES, SUPPORT_ROUTE].map((e) => {
const patterns = Array.isArray(e.path) ? e.path : [e.path];
const matches = patterns.some(
(pattern) => matchRoute(pathname, pattern) !== null,
);
return [matches ? 'current' : null, e];
const currentPath = matchPath(pathname, {
path: e.path,
});
return [currentPath === null ? null : 'current', e];
}),
),
[pathname],
@@ -245,7 +242,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
return <Redirect to={ROUTES.HOME} />;
@@ -259,7 +256,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
return <Redirect to={ROUTES.HOME} />;
} else {

View File

@@ -1,6 +1,6 @@
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -237,17 +237,12 @@ function buildPrivateRouteTree(
<MemoryRouter initialEntries={[initialRoute]}>
<AppContext.Provider value={contextValue}>
<PrivateRoute>
<Routes>
<Route
path="*"
element={
<>
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</>
}
/>
</Routes>
<Switch>
<Route path="*">
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</Route>
</Switch>
</PrivateRoute>
</AppContext.Provider>
</MemoryRouter>

View File

@@ -1,73 +0,0 @@
import { lazy, Suspense, useState } from 'react';
import { Routes } from 'react-router';
import { act, render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { TestRouter } from 'tests/router';
import { resetTestRoute } from 'tests/router-utils';
import { routeElements } from '../routeElements';
import type { AppRoutes } from '../routes';
let mounts = 0;
function Page(): JSX.Element {
const [mount] = useState(() => {
mounts += 1;
return mounts;
});
return <div data-testid="page">mount {mount}</div>;
}
// Two lazy wrappers of one module, like the logs explorer and pipelines routes.
const Explorer = lazy(async () => ({ default: Page }));
const Pipelines = lazy(async () => ({ default: Page }));
const routes: AppRoutes[] = [
{ path: ROUTES.LOGS, component: Explorer, key: 'LOGS', isPrivate: true },
{
path: [ROUTES.LOGS_PIPELINES, ROUTES.LOGS_INDEX_FIELDS],
component: Pipelines,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
];
function renderRoutes(): void {
render(
<TestRouter initialRoute={ROUTES.LOGS}>
<Suspense fallback={<div data-testid="fallback" />}>
<Routes>{routeElements(routes)}</Routes>
</Suspense>
</TestRouter>,
);
}
describe('routeElements', () => {
beforeEach(() => {
mounts = 0;
});
afterEach(() => {
resetTestRoute();
});
it('remounts the page when another route entry resolves to the same component', async () => {
renderRoutes();
await expect(screen.findByText('mount 1')).resolves.toBeInTheDocument();
act(() => navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
});
it('keeps the page mounted across the patterns of one route entry', async () => {
renderRoutes();
act(() => navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
act(() => navigate(ROUTES.LOGS_INDEX_FIELDS));
expect(screen.getByTestId('page')).toHaveTextContent('mount 2');
});
});

View File

@@ -1,5 +1,6 @@
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Routes } from 'react-router';
import { Route, Router, Switch } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -22,7 +23,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
import { IS_PROD } from 'lib/env';
import { getCurrentLocation, navigate, subscribe } from 'lib/router/navigation';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App';
@@ -32,7 +33,6 @@ import { extractDomain } from 'utils/app';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
import { routeElements } from './routeElements';
import defaultRoutes, {
AppRoutes,
LIST_LICENSES,
@@ -44,6 +44,12 @@ import {
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
const appRouter = (children: ReactNode): ReactNode => (
<Router history={history}>
<CompatRouter>{children}</CompatRouter>
</Router>
);
const appLayout = (children: ReactNode): ReactNode => (
<AppLayout>{children}</AppLayout>
);
@@ -68,16 +74,14 @@ function App(): JSX.Element {
const chatSupport = useChatSupport();
const { hostname } = window.location;
// Through the facade, not the raw history: the router owns the base path
// now, so `history.location.pathname` would still carry it.
const [pathname, setPathname] = useState(getCurrentLocation().pathname);
const [pathname, setPathname] = useState(history.location.pathname);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
useEffect(() => {
return subscribe(({ location }) => {
return history.listen((location) => {
setPathname(location.pathname);
});
}, []);
@@ -394,7 +398,7 @@ function App(): JSX.Element {
// this needs to be on top of data missing error because if there is an error, data will never be loaded and it will
// move to indefinitive loading
if (userFetchError && pathname !== ROUTES.SOMETHING_WENT_WRONG) {
navigate(ROUTES.SOMETHING_WENT_WRONG, { replace: true });
history.replace(ROUTES.SOMETHING_WENT_WRONG);
}
// if all of the data is not set then return a spinner, this is required because there is some gap between loading states and data setting
@@ -410,6 +414,7 @@ function App(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<AppShell
router={appRouter}
overlays={
isLoggedInState && (
<>
@@ -422,11 +427,18 @@ function App(): JSX.Element {
<PrivateRoute>
<AppPageProviders layout={appLayout}>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Routes>
{routeElements(routes)}
<Route path="/" element={<Home />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppPageProviders>
</PrivateRoute>

View File

@@ -1,20 +0,0 @@
import { Route } from 'react-router';
import type { AppRoutes } from './routes';
/**
* The page is keyed by its route entry. `<Routes>` renders the match unkeyed, so
* two lazy pages that resolve to the same component would otherwise reuse the
* previous route's instance. The patterns of one entry share the key.
*/
export function routeElements(routes: AppRoutes[]): JSX.Element[] {
return routes.flatMap(({ path, component: Component, nested, key }) =>
(Array.isArray(path) ? path : [path]).map((pattern) => (
<Route
key={pattern}
path={nested ? `${pattern}/*` : pattern}
element={<Component key={key} />}
/>
)),
);
}

View File

@@ -1,4 +1,4 @@
import type { ComponentType } from 'react';
import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
@@ -58,25 +58,27 @@ const routes: AppRoutes[] = [
{
component: SignupPage,
path: ROUTES.SIGN_UP,
exact: true,
isPrivate: false,
key: 'SIGN_UP',
},
{
path: ROUTES.GET_STARTED_WITH_CLOUD,
nested: true,
exact: false,
component: OnboardingV2,
isPrivate: true,
key: 'GET_STARTED_WITH_CLOUD',
},
{
path: ROUTES.HOME,
exact: true,
component: Home,
isPrivate: true,
key: 'HOME',
},
{
path: ROUTES.ONBOARDING,
nested: true,
exact: false,
component: OrgOnboarding,
isPrivate: true,
key: 'ONBOARDING',
@@ -84,23 +86,27 @@ const routes: AppRoutes[] = [
{
component: LogsIndexToFields,
path: ROUTES.LOGS_INDEX_FIELDS,
exact: true,
isPrivate: true,
key: 'LOGS_INDEX_FIELDS',
},
{
component: ServicesTablePage,
path: ROUTES.APPLICATION,
exact: true,
isPrivate: true,
key: 'APPLICATION',
},
{
path: ROUTES.SERVICE_METRICS,
exact: true,
component: ServiceMetricsPage,
isPrivate: true,
key: 'SERVICE_METRICS',
},
{
path: ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
exact: true,
component: ServiceTopLevelOperationsPage,
isPrivate: true,
key: 'SERVICE_TOP_LEVEL_OPERATIONS',
@@ -109,252 +115,292 @@ const routes: AppRoutes[] = [
path: ROUTES.SERVICE_MAP,
component: ServiceMapPage,
isPrivate: true,
exact: true,
key: 'SERVICE_MAP',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,
nested: true,
exact: false,
component: SettingsPage,
isPrivate: true,
key: 'SETTINGS',
},
{
path: ROUTES.USAGE_EXPLORER,
exact: true,
component: UsageExplorerPage,
isPrivate: true,
key: 'USAGE_EXPLORER',
},
{
path: ROUTES.ALL_DASHBOARD,
exact: true,
component: DashboardsListPage,
isPrivate: true,
key: 'ALL_DASHBOARD',
},
{
path: ROUTES.DASHBOARD,
exact: true,
component: DashboardPage,
isPrivate: true,
key: 'DASHBOARD',
},
{
path: ROUTES.PUBLIC_DASHBOARD,
nested: true,
exact: false,
component: PublicDashboardPage,
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,
component: DashboardPanelEditorPage,
isPrivate: true,
key: 'DASHBOARD_PANEL_EDITOR',
},
{
path: ROUTES.EDIT_ALERTS,
exact: true,
component: EditRulesPage,
isPrivate: true,
key: 'EDIT_ALERTS',
},
{
path: ROUTES.LIST_ALL_ALERT,
exact: true,
component: ListAllALertsPage,
isPrivate: true,
key: 'LIST_ALL_ALERT',
},
{
path: ROUTES.ALERTS_NEW,
exact: true,
component: CreateNewAlerts,
isPrivate: true,
key: 'ALERTS_NEW',
},
{
path: ROUTES.ALERT_HISTORY,
exact: true,
component: AlertHistory,
isPrivate: true,
key: 'ALERT_HISTORY',
},
{
path: ROUTES.ALERT_OVERVIEW,
exact: true,
component: AlertOverview,
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACES_EXPLORER,
exact: true,
component: TracesExplorer,
isPrivate: true,
key: 'TRACES_EXPLORER',
},
{
path: ROUTES.TRACES_FUNNELS,
exact: true,
component: TracesFunnels,
isPrivate: true,
key: 'TRACES_FUNNELS',
},
{
path: ROUTES.TRACES_FUNNELS_DETAIL,
exact: true,
component: TracesFunnelDetails,
isPrivate: true,
key: 'TRACES_FUNNELS_DETAIL',
},
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: ChannelsEdit,
isPrivate: true,
key: 'CHANNELS_EDIT',
},
{
path: ROUTES.ALL_ERROR,
exact: true,
isPrivate: true,
component: AllErrors,
key: 'ALL_ERROR',
},
{
path: ROUTES.ERROR_DETAIL,
exact: true,
component: ErrorDetails,
isPrivate: true,
key: 'ERROR_DETAIL',
},
{
path: ROUTES.VERSION,
exact: true,
component: StatusPage,
isPrivate: true,
key: 'VERSION',
},
{
path: ROUTES.LOGS,
exact: true,
component: Logs,
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_PIPELINES,
exact: true,
component: PipelinePage,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
{
path: ROUTES.LOGIN,
exact: true,
component: Login,
isPrivate: false,
key: 'LOGIN',
},
{
path: ROUTES.FORGOT_PASSWORD,
exact: true,
component: ForgotPassword,
isPrivate: false,
key: 'FORGOT_PASSWORD',
},
{
path: ROUTES.UN_AUTHORIZED,
exact: true,
component: UnAuthorized,
key: 'UN_AUTHORIZED',
isPrivate: true,
},
{
path: ROUTES.PASSWORD_RESET,
exact: true,
component: PasswordReset,
key: 'PASSWORD_RESET',
isPrivate: false,
},
{
path: ROUTES.SOMETHING_WENT_WRONG,
exact: true,
component: SomethingWentWrong,
key: 'SOMETHING_WENT_WRONG',
isPrivate: false,
},
{
path: ROUTES.WORKSPACE_LOCKED,
exact: true,
component: WorkspaceBlocked,
isPrivate: true,
key: 'WORKSPACE_LOCKED',
},
{
path: ROUTES.WORKSPACE_SUSPENDED,
exact: true,
component: WorkspaceSuspended,
isPrivate: true,
key: 'WORKSPACE_SUSPENDED',
},
{
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
exact: true,
component: WorkspaceAccessRestricted,
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: Integrations,
isPrivate: true,
key: 'INTEGRATIONS',
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_CELERY_TASK',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_HOSTS',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_KUBERNETES',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
@@ -362,48 +408,56 @@ const routes: AppRoutes[] = [
{
path: ROUTES.METER,
exact: true,
component: MeterExplorerPage,
key: 'METER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER,
exact: true,
component: MeterExplorerPage,
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.API_MONITORING,
exact: true,
component: ApiMonitoring,
key: 'API_MONITORING',
isPrivate: true,
},
{
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_EXPLORER,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
@@ -412,6 +466,7 @@ const routes: AppRoutes[] = [
export const SUPPORT_ROUTE: AppRoutes = {
path: ROUTES.SUPPORT,
exact: true,
component: SupportPage,
key: 'SUPPORT',
isPrivate: true,
@@ -419,6 +474,7 @@ export const SUPPORT_ROUTE: AppRoutes = {
export const LIST_LICENSES: AppRoutes = {
path: ROUTES.LIST_LICENSES,
exact: true,
component: LicensePage,
isPrivate: true,
key: 'LIST_LICENSES',
@@ -450,15 +506,9 @@ export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [
];
export interface AppRoutes {
component: ComponentType;
/** An array registers the same component under each pattern. */
path: string | string[];
/**
* The route renders its own child routes, so it matches as a prefix. v6
* matches the whole path by default, so the mount appends a `/*` splat for
* these and nothing for the rest.
*/
nested?: boolean;
component: RouteProps['component'];
path: RouteProps['path'];
exact: RouteProps['exact'];
isPrivate: boolean;
key: keyof typeof ROUTES;
}

View File

@@ -16,7 +16,6 @@ export interface AppProvidersProps {
store: Store;
queryClient: QueryClient;
appContext: AppLayer;
router: AppLayer;
searchParams: AppLayer;
}
@@ -28,34 +27,27 @@ export interface AppProvidersProps {
* A new provider belongs here only if it holds process-wide state that does not
* depend on the user, the license or the route. One that fetches on mount would
* fire unauthenticated from here; put it in `AppShell` or lower.
*
* `router` is the outermost layer because `searchParams` (nuqs) reads the
* router's `useNavigate` / `useSearchParams`. Everything below, including the
* boot spinner, therefore renders inside the router.
*/
function AppProviders({
children,
store,
queryClient,
appContext,
router,
searchParams,
}: AppProvidersProps): JSX.Element {
return (
<HelmetProvider>
{router(
searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
{appContext(children)}
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
),
{searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
{appContext(children)}
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
)}
</HelmetProvider>
);

View File

@@ -1,26 +0,0 @@
import { ReactNode } from 'react';
import { unstable_HistoryRouter as HistoryRouter } from 'react-router';
import history from 'lib/history';
import { getBasePath } from 'utils/basePath';
/**
* The outermost layer. It is above `NuqsAdapter` on purpose:
* `nuqs/adapters/react-router/v7` calls `useNavigate` and `useSearchParams`, so
* it only works inside a router.
*
* Transitions are off on purpose: under one React keeps the previous screen up
* instead of committing the Suspense fallback, so a route whose chunk is not
* cached yet renders no loader at all. Rendering pending UI under it needs
* `useNavigation()` and a data router.
*/
export function appRouter(children: ReactNode): ReactNode {
return (
<HistoryRouter
basename={getBasePath()}
history={history}
useTransitions={false}
>
{children}
</HistoryRouter>
);
}

View File

@@ -5,8 +5,11 @@ import { NotificationProvider } from 'hooks/useNotifications';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { AppLayer } from './types';
export interface AppShellProps {
children: ReactNode;
router: AppLayer;
/** Mounted beside the routed content: the command palette and its siblings. */
overlays?: ReactNode;
}
@@ -16,26 +19,28 @@ export interface AppShellProps {
* fetches settle, above `PrivateRoute`, so it also covers the redirects and the
* not-found route, and it survives every navigation.
*
* A new provider belongs here if it needs the user and has to outlive the page:
* a global overlay, a shortcut host, anything one route opens and the next one
* keeps. The router itself is higher up, in `AppProviders`.
* A new provider belongs here if it needs the router or the user and has to
* outlive the page: a global overlay, a shortcut host, anything one route opens
* and the next one keeps.
*
* `ConfigProvider` reads `useThemeConfig`, which needs `ThemeProvider` above it,
* so the antd theme is settled here instead of by the caller.
*/
function AppShell({ children, overlays }: AppShellProps): JSX.Element {
function AppShell({ children, router, overlays }: AppShellProps): JSX.Element {
const themeConfig = useThemeConfig();
return (
<ConfigProvider theme={themeConfig}>
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
{router(
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>,
)}
</ConfigProvider>
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Dot } from '@signozhq/icons';
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
@@ -16,7 +16,6 @@ import {
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';

View File

@@ -4,6 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
@@ -147,7 +148,7 @@ function LogDetailInner({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -121,7 +121,6 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
</div>
<a
class="c3"
data-discover="true"
href="/home"
tabindex="0"
>

View File

@@ -47,29 +47,23 @@ export function QuerySearchV2Provider({
store.getState().setInitialExpression(initialExpression);
}, [initialExpression, store]);
// The URL owns the expression, in both directions. A provider can outlive the
// navigation away from the page it belongs to (a route-driven tab keeps the
// leaving pane mounted until its animation ends), so treating a param that
// disappeared as something to restore republishes it onto the URL of the page
// being entered.
const isInitialized = useRef(false);
useEffect(() => {
const userExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
if (userExpression !== store.getState().committedExpression) {
store.getState().initializeFromUrl(userExpression);
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
const publishedExpression = useRef(committedExpression);
useEffect(() => {
if (committedExpression === publishedExpression.current) {
return;
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
publishedExpression.current = committedExpression;
setUrlExpression(committedExpression || null);
}, [committedExpression, setUrlExpression]);
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -110,22 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('should follow the URL when the param is dropped', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('status = 500');
mockSetQueryState.mockClear();
mockUrlValue = null;
rerender();
expect(result.current.expression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalledWith('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -18,17 +18,14 @@ import {
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import {
buildExplorerNavigationUrl,
findSavedViewInLists,
openSavedView,
openSavedViewByKey,
} from '../openSavedView';
import {
entityToDataSource,
entityToSavedViewSource,
isSavedViewOpenAction,
resolveActionEntity,
resolveOpenResourceType,
@@ -132,10 +129,14 @@ describe('resolveOpenResource', () => {
).toBe('abc-123');
});
it('maps entity values to explorer data sources', () => {
expect(entityToDataSource('logs')).toBe(DataSource.LOGS);
expect(entityToDataSource('logs_explorer')).toBe(DataSource.LOGS);
expect(entityToDataSource('traces')).toBe(DataSource.TRACES);
it('maps entity values to saved view sources', () => {
expect(entityToSavedViewSource('logs')).toBe(SavedviewtypesSourceDTO.logs);
expect(entityToSavedViewSource('logs_explorer')).toBe(
SavedviewtypesSourceDTO.logs,
);
expect(entityToSavedViewSource('traces')).toBe(
SavedviewtypesSourceDTO.traces,
);
});
it('prefers entity over signal for saved-view source hints', () => {
@@ -146,7 +147,7 @@ describe('resolveOpenResource', () => {
entity: SavedViewEntityDTO.traces,
signal: ApplyFilterSignalDTO.logs,
}),
).toBe(DataSource.TRACES);
).toBe(SavedviewtypesSourceDTO.traces);
});
it('falls back to signal when entity is absent', () => {
@@ -156,7 +157,7 @@ describe('resolveOpenResource', () => {
label: 'Open view',
signal: ApplyFilterSignalDTO.metrics,
}),
).toBe(DataSource.METRICS);
).toBe(SavedviewtypesSourceDTO.metrics);
});
it('normalises saved-view resource types', () => {
@@ -209,7 +210,10 @@ describe('findSavedViewInLists', () => {
const tracesView = makeView('view-traces', SavedviewtypesSourceDTO.traces);
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
const result = await findSavedViewInLists(
'view-traces',
SavedviewtypesSourceDTO.traces,
);
expect(result).toStrictEqual(tracesView);
expect(mockedListSavedViews).toHaveBeenCalledTimes(1);
@@ -232,23 +236,6 @@ describe('findSavedViewInLists', () => {
});
});
describe('buildExplorerNavigationUrl', () => {
it('encodes composite query and view selectors', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewKey]: 'view-1',
},
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});
describe('openSavedView', () => {
beforeEach(() => {
mockedNavigate.mockClear();
@@ -294,7 +281,7 @@ describe('openSavedViewByKey', () => {
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
mockedGetSavedView.mockResolvedValueOnce(mockViewByIdResponse(view));
await openSavedViewByKey('view-logs', DataSource.LOGS);
await openSavedViewByKey('view-logs', SavedviewtypesSourceDTO.logs);
expect(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
expect(mockedListSavedViews).not.toHaveBeenCalled();
@@ -306,7 +293,7 @@ describe('openSavedViewByKey', () => {
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([view]));
await openSavedViewByKey('view-traces', DataSource.TRACES);
await openSavedViewByKey('view-traces', SavedviewtypesSourceDTO.traces);
expect(mockedListSavedViews).toHaveBeenCalledWith({
source: SavedviewtypesSourceDTO.traces,
@@ -318,8 +305,8 @@ describe('openSavedViewByKey', () => {
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
mockedListSavedViews.mockResolvedValue(mockViewsResponse([]));
await expect(openSavedViewByKey('missing', DataSource.LOGS)).rejects.toThrow(
'Saved view not found',
);
await expect(
openSavedViewByKey('missing', SavedviewtypesSourceDTO.logs),
).rejects.toThrow('Saved view not found');
});
});

View File

@@ -2,37 +2,34 @@ import {
getSavedView,
listSavedViews,
} from 'api/generated/services/saved-view';
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { SavedViewSourcePage } from 'container/SavedViews/types';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
import { navigate } from 'lib/router/navigation';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
type SavedViewSourceHint = SavedViewSourcePage;
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
DataSource.LOGS,
DataSource.TRACES,
DataSource.METRICS,
const DEFAULT_PROBE_SOURCES: SavedviewtypesSourceDTO[] = [
SavedviewtypesSourceDTO.logs,
SavedviewtypesSourceDTO.traces,
SavedviewtypesSourceDTO.metrics,
];
export async function findSavedViewInLists(
viewKey: string,
sourceHint?: SavedViewSourceHint | null,
sourceHint?: SavedviewtypesSourceDTO | null,
): Promise<SavedviewtypesSavedViewDTO | null> {
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
for (const source of sources) {
try {
const response = await listSavedViews({
source: toSavedViewSource(source),
});
const response = await listSavedViews({ source });
const match = findSavedView(response.data, viewKey);
if (match) {
return match;
@@ -47,7 +44,7 @@ export async function findSavedViewInLists(
async function loadSavedView(
viewKey: string,
sourceHint?: SavedViewSourceHint | null,
sourceHint?: SavedviewtypesSourceDTO | null,
): Promise<SavedviewtypesSavedViewDTO> {
try {
const response = await getSavedView({ id: viewKey });
@@ -72,26 +69,6 @@ export function explorerRouteForSourcePage(
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
}
/**
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
* without inheriting stale query params from the current page's `urlQuery`.
*/
export function buildExplorerNavigationUrl(
route: string,
query: Query,
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});
return `${route}?${params.toString()}`;
}
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
if (!route) {
@@ -112,7 +89,7 @@ export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
export async function openSavedViewByKey(
viewKey: string,
sourceHint: SavedViewSourceHint | null | undefined,
sourceHint: SavedviewtypesSourceDTO | null | undefined,
): Promise<void> {
const view = await loadSavedView(viewKey, sourceHint);
openSavedView(view);

View File

@@ -3,7 +3,7 @@ import {
ApplyFilterSignalDTO,
SavedViewEntityDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { ResourceType } from './resourceRoute';
@@ -119,15 +119,15 @@ export function resolveActionEntity(
}
function normalizeToSavedViewEntity(value: string): SavedViewEntityDTO | null {
const source = entityToDataSource(value);
const source = entityToSavedViewSource(value);
switch (source) {
case DataSource.LOGS:
case SavedviewtypesSourceDTO.logs:
return SavedViewEntityDTO.logs;
case DataSource.TRACES:
case SavedviewtypesSourceDTO.traces:
return SavedViewEntityDTO.traces;
case DataSource.METRICS:
case SavedviewtypesSourceDTO.metrics:
return SavedViewEntityDTO.metrics;
case 'meter':
case SavedviewtypesSourceDTO.meter:
return SavedViewEntityDTO.meter;
default:
return null;
@@ -135,26 +135,26 @@ function normalizeToSavedViewEntity(value: string): SavedViewEntityDTO | null {
}
/**
* Maps an action `entity` to an explorer `DataSource` for saved-view lookups.
* Maps an action `entity` to the saved views api source for saved-view lookups.
* Accepts both short (`logs`) and taxonomy (`logs_explorer`) values.
*/
export function entityToDataSource(
export function entityToSavedViewSource(
entity: SavedViewEntityDTO | string,
): DataSource | 'meter' | null {
): SavedviewtypesSourceDTO | null {
const normalized = entity.trim().toLowerCase().replace(/-/g, '_');
switch (normalized) {
case SavedViewEntityDTO.logs:
case ResourceType.logs_explorer:
return DataSource.LOGS;
return SavedviewtypesSourceDTO.logs;
case SavedViewEntityDTO.traces:
case ResourceType.traces_explorer:
return DataSource.TRACES;
return SavedviewtypesSourceDTO.traces;
case SavedViewEntityDTO.metrics:
case ResourceType.metrics_explorer:
return DataSource.METRICS;
return SavedviewtypesSourceDTO.metrics;
case SavedViewEntityDTO.meter:
return 'meter';
return SavedviewtypesSourceDTO.meter;
default:
return null;
}
@@ -166,10 +166,10 @@ export function entityToDataSource(
*/
export function resolveSavedViewSourceHint(
action: MessageActionDTO,
): DataSource | 'meter' | null {
): SavedviewtypesSourceDTO | null {
const entity = resolveActionEntity(action);
if (entity) {
const fromEntity = entityToDataSource(entity);
const fromEntity = entityToSavedViewSource(entity);
if (fromEntity) {
return fromEntity;
}
@@ -178,11 +178,11 @@ export function resolveSavedViewSourceHint(
if (action.signal) {
switch (action.signal) {
case ApplyFilterSignalDTO.logs:
return DataSource.LOGS;
return SavedviewtypesSourceDTO.logs;
case ApplyFilterSignalDTO.traces:
return DataSource.TRACES;
return SavedviewtypesSourceDTO.traces;
case ApplyFilterSignalDTO.metrics:
return DataSource.METRICS;
return SavedviewtypesSourceDTO.metrics;
default: {
const _exhaustive: never = action.signal;
return _exhaustive;

View File

@@ -38,8 +38,7 @@ import { useAppContext } from 'providers/App/App';
import { UserPreference } from 'types/api/preferences/preference';
import { DataSource } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { isIngestionActive } from 'utils/app';
import { isModifierKeyPressed } from 'utils/app';
import { isIngestionActive, isModifierKeyPressed } from 'utils/app';
import { popupContainer } from 'utils/selectPopupContainer';
import crackerUrl from '@/assets/Icons/cracker.svg';

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -18,10 +18,10 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
} from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { DefaultQueryRedirect } from 'container/InfraMonitoringK8sV2/Base/DefaultQueryRedirect';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
@@ -29,6 +29,9 @@ import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from 'container/InfraMonitoringK8sV2/constants';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import {
getHostMetricsQueryPayload,
@@ -58,6 +61,10 @@ import {
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const compositeQuery = useGetCompositeQueryParam();
const { redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
const { startUnixMilli, endUnixMilli } = useMemo(() => {
@@ -69,6 +76,28 @@ function Hosts(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTime, getMinMaxTime]);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
@@ -197,7 +226,6 @@ function Hosts(): JSX.Element {
return (
<>
<DefaultQueryRedirect />
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (

View File

@@ -1,42 +0,0 @@
import { useEffect, useRef } from 'react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
/**
* Puts the default query in the URL when the page mounts without one.
*
* Render it before the list: React runs sibling effects in tree order, and this
* navigation drops the nuqs writes still queued, such as the page size the list
* writes on mount.
*/
export function DefaultQueryRedirect(): null {
const compositeQuery = useGetCompositeQueryParam();
const { redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
return null;
}

View File

@@ -62,6 +62,7 @@ function K8sHeader({
const currentExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const finalExpression = newExpression ?? currentExpression;
void setCurrentPage(1);
const updatedQuery = {
...currentQuery,
@@ -88,8 +89,6 @@ function K8sHeader({
);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
// After the navigation: a navigation drops the nuqs writes still queued.
void setCurrentPage(1);
void invalidateQueries();
if (finalExpression?.trim()) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -9,12 +9,14 @@ import {
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { DefaultQueryRedirect } from './Base/DefaultQueryRedirect';
import { K8sDynamicList } from './Base/K8sDynamicList';
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
import {
@@ -66,7 +68,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
@@ -93,6 +97,28 @@ export default function InfraMonitoringK8s(): JSX.Element {
[getUseFieldApis, selectedCategory],
);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
useEffect(() => {
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
@@ -195,7 +221,14 @@ export default function InfraMonitoringK8s(): JSX.Element {
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
// Navigate first: a navigation drops the nuqs writes still queued.
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {
@@ -209,14 +242,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
],
},
});
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
}
};
@@ -245,7 +270,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<DefaultQueryRedirect />
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (

View File

@@ -1,213 +0,0 @@
import { usePreferredPageSizeStore } from 'components/TanStackTableView/usePreferredPageSize.store';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import K8sHeader from '../Base/K8sHeader';
import EntityDateTimeSelector from '../EntityDetailsUtils/EntityDateTimeSelector/EntityDateTimeSelector';
import {
InfraMonitoringEntity,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategories,
} from '../constants';
import {
useInfraMonitoringCategory,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import InfraMonitoringK8s from '../InfraMonitoringK8s';
// The real hook: a navigation made while a nuqs write is queued drops that write,
// which is what these tests guard. The extension slips past jest.config.ts's mock.
jest.mock('hooks/useSafeNavigate', () =>
jest.requireActual('../../../hooks/useSafeNavigate.ts'),
);
jest.mock('components/QuickFilters/QuickFilters', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="quick-filters" />,
}));
// Keeps the list's own URL write on mount: the page size.
jest.mock('../Base/K8sDynamicList', () => {
const { useTableParams } = jest.requireActual('components/TanStackTableView');
function K8sDynamicList(): JSX.Element {
useTableParams(
{ page: 'page', limit: 'pageSize' },
{ page: 1, limit: 10, storageKey: 'k8s-containers' },
);
return <div data-testid="k8s-dynamic-list" />;
}
return { __esModule: true, K8sDynamicList, default: K8sDynamicList };
});
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: ({ onRun }: { onRun: (expression: string) => void }): JSX.Element => (
<button
type="button"
aria-label="Run filter"
data-testid="run-filter"
onClick={(): void => onRun("k8s.pod.name = 'pod-1'")}
/>
),
}));
jest.mock('lib/navigation', () => ({
getNavigationReferrer: (): string => 'direct',
}));
const route = ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES;
function DrawerHarness(): JSX.Element {
const [, setCategory] = useInfraMonitoringCategory();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
return (
<>
<button
type="button"
aria-label="Select category"
data-testid="select-category"
onClick={(): void => {
void setCategory(K8sCategories.PODS);
}}
/>
<button
type="button"
aria-label="Open row"
data-testid="open-row"
onClick={(): void =>
setSelectedItemParams({
selectedItem: 'pod-1',
clusterName: null,
namespaceName: null,
})
}
/>
{selectedItemParams.selectedItem && (
<div data-testid="drawer">
<EntityDateTimeSelector
eventEntity="k8s"
category={InfraMonitoringEntity.PODS}
view="overview"
/>
</div>
)}
</>
);
}
function getUrlParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key);
}
describe('K8s URL writes queued by nuqs', () => {
afterEach(() => {
usePreferredPageSizeStore
.getState()
.setPreferredPageSize('k8s-containers', null);
});
it('opens the drawer on the first click after an earlier shallow write', async () => {
render(<DrawerHarness />, undefined, {
initialRoute: `${route}?relativeTime=15m`,
});
fireEvent.click(screen.getByTestId('select-category'));
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
K8sCategories.PODS,
),
);
fireEvent.click(screen.getByTestId('open-row'));
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM)).toBe(
'pod-1',
),
);
expect(screen.getByTestId('drawer')).toBeInTheDocument();
});
it('switches category on the first click while a filter is applied', async () => {
const query = initialQueriesMap[DataSource.METRICS];
const filteredQuery = {
...query,
builder: {
...query.builder,
queryData: [
{
...query.builder.queryData[0],
filter: { expression: "k8s.pod.name = 'pod-1'" },
},
],
},
};
const params = new URLSearchParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY]: K8sCategories.PODS,
compositeQuery: encodeURIComponent(JSON.stringify(filteredQuery)),
});
render(<InfraMonitoringK8s />, undefined, {
initialRoute: `${route}?${params.toString()}`,
});
fireEvent.click(
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`),
);
await waitFor(() => {
const compositeQuery = JSON.parse(
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
);
expect(compositeQuery.builder.queryData[0].filter.expression).toBe('');
});
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
K8sCategories.CONTAINERS,
),
);
});
it('goes back to the first page when a filter runs', async () => {
render(
<K8sHeader
entity={InfraMonitoringEntity.PODS}
showAutoRefresh={false}
cancelQuery={jest.fn()}
/>,
undefined,
{ initialRoute: `${route}?relativeTime=30m&page=3` },
);
fireEvent.click(screen.getByTestId('run-filter'));
await waitFor(() => {
const compositeQuery = JSON.parse(
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
);
expect(compositeQuery.builder.queryData[0].filter.expression).toBe(
"k8s.pod.name = 'pod-1'",
);
});
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE)).toBe('1'),
);
});
it('keeps the page size the list writes while the default query loads', async () => {
usePreferredPageSizeStore
.getState()
.setPreferredPageSize('k8s-containers', 25);
render(<InfraMonitoringK8s />, undefined, { initialRoute: route });
await waitFor(() => expect(getUrlParam('compositeQuery')).not.toBeNull());
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE)).toBe('25'),
);
});
});

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
@@ -54,7 +55,7 @@ function LiveLogsContainer({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -5,6 +5,7 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui/sonner';
import { Card } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
@@ -30,7 +31,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { StringOperators } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import { getAbsoluteUrl } from 'utils/basePath';
@@ -68,7 +69,7 @@ function LiveLogsList({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: StringOperators.NOOP,
});

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Button, Skeleton } from 'antd';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -16,7 +17,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { StringOperators } from 'types/common/queryBuilder';
import { withBasePath } from 'utils/basePath';
import { useContextLogData } from './useContextLogData';
@@ -45,7 +46,7 @@ function ContextLogRenderer({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -5,6 +5,7 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui/sonner';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
@@ -77,7 +78,7 @@ function LogsExplorerList({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator:
currentStagedQueryData?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -1,6 +1,7 @@
import { ReactNode, useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
@@ -28,7 +29,7 @@ function LogsActionsContainer({
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -1,11 +1,20 @@
import { useQueries } from 'react-query';
import { renderHook } from '@testing-library/react';
import { createElement, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import {
FormattingOptions,
PreferenceContextValue,
PreferenceSlice,
} from 'providers/preferences/types';
import { renderHook } from 'tests/test-utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FontSize } from '../types';
import useOptionsMenu from '../useOptionsMenu';
// Mock all dependencies
@@ -13,104 +22,118 @@ jest.mock('hooks/useNotifications');
jest.mock('providers/preferences/context/PreferenceContextProvider');
jest.mock('hooks/useUrlQueryData');
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
const mockUseNotifications = jest.mocked(useNotifications);
const mockUsePreferenceContext = jest.mocked(usePreferenceContext);
const mockUseUrlQueryData = jest.mocked(useUrlQueryData);
const mockUseGetQueryKeySuggestions = jest.mocked(useGetQueryKeySuggestions);
type KeySuggestionsResult = ReturnType<typeof useGetQueryKeySuggestions>;
// The hook reads only `data.data.data.keys` and `isFetching`.
const keySuggestions = (
attributeKeys: Record<string, string>[],
): KeySuggestionsResult =>
({
data: { data: { data: { complete: true, keys: { attributeKeys } } } },
isFetching: false,
}) as unknown as KeySuggestionsResult;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element =>
createElement(QueryClientProvider, { client: new QueryClient() }, children);
const toIds = (columns: TelemetryFieldKey[]): string[] =>
columns.map((column) => `${column.fieldContext}:${column.name}`);
describe('useOptionsMenu', () => {
const mockNotifications = { error: jest.fn(), success: jest.fn() };
const mockUpdateColumns = jest.fn();
const mockUpdateFormatting = jest.fn();
const mockRedirectWithQuery = jest.fn();
const mockNotifications = {
error: jest.fn() as jest.MockedFunction<NotificationInstance['error']>,
success: jest.fn() as jest.MockedFunction<NotificationInstance['success']>,
} as Partial<NotificationInstance> as NotificationInstance;
const mockUpdateColumns = jest.fn() as jest.MockedFunction<
PreferenceSlice['updateColumns']
>;
const mockUpdateFormatting = jest.fn() as jest.MockedFunction<
PreferenceSlice['updateFormatting']
>;
const mockRedirectWithQuery = jest.fn() as jest.MockedFunction<
(newQueryData: unknown) => void
>;
const slice = (
columns: TelemetryFieldKey[],
formatting: FormattingOptions,
): PreferenceSlice => ({
preferences: { columns, formatting },
loading: false,
error: null,
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
});
const rawFormatting: FormattingOptions = {
format: 'raw',
maxLines: 1,
fontSize: FontSize.SMALL,
};
beforeEach(() => {
jest.clearAllMocks();
(useNotifications as jest.Mock).mockReturnValue({
mockUseNotifications.mockReturnValue({
notifications: mockNotifications,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 1,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 1,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
} satisfies PreferenceContextValue);
(useUrlQueryData as jest.Mock).mockReturnValue({
mockUseUrlQueryData.mockReturnValue({
query: null,
queryData: undefined,
redirectWithQuery: mockRedirectWithQuery,
});
(useQueries as jest.Mock).mockReturnValue([]);
});
it('does not show isRoot or isEntryPoint in column options when dataSource is TRACES', () => {
it('does not show isRoot or isEntryPoint in column options when sourcePage is traces', () => {
// Mock the query key suggestions to return data including isRoot and isEntryPoint
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'isRoot',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'isEntryPoint',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'duration',
signal: 'traces',
fieldDataType: 'float64',
fieldContext: '',
},
{
name: 'serviceName',
signal: 'traces',
fieldDataType: 'string',
fieldContext: '',
},
],
},
},
mockUseGetQueryKeySuggestions.mockReturnValue(
keySuggestions([
{
name: 'isRoot',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
},
isFetching: false,
});
{
name: 'isEntryPoint',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'duration',
signal: 'traces',
fieldDataType: 'float64',
fieldContext: '',
},
{
name: 'serviceName',
signal: 'traces',
fieldDataType: 'string',
fieldContext: '',
},
]),
);
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Get the column options from the config
@@ -127,98 +150,38 @@ describe('useOptionsMenu', () => {
expect(optionNames).toContain('serviceName');
});
it('does not show body in column options when dataSource is METRICS', () => {
it('shows body in column options when sourcePage is logs', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'status',
signal: 'metrics',
fieldDataType: 'int64',
fieldContext: '',
},
{
name: 'value',
signal: 'metrics',
fieldDataType: 'float64',
fieldContext: '',
},
],
},
},
mockUseGetQueryKeySuggestions.mockReturnValue(
keySuggestions([
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.METRICS,
aggregateOperator: 'count',
}),
{
name: 'level',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'timestamp',
signal: 'logs',
fieldDataType: 'int64',
fieldContext: '',
},
]),
);
// Get the column options from the config
const columnOptions = result.current.config.addColumn?.options ?? [];
const optionNames = columnOptions.map((option) => option.label);
// body should NOT be in the options
expect(optionNames).not.toContain('body');
// Other attributes should be present
expect(optionNames).toContain('status');
expect(optionNames).toContain('value');
});
it('does not show body in column options when dataSource is LOGS', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'level',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'timestamp',
signal: 'logs',
fieldDataType: 'int64',
fieldContext: '',
},
],
},
},
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Get the column options from the config
@@ -234,15 +197,15 @@ describe('useOptionsMenu', () => {
});
describe('reorderSelectColumns / handleRemoveSelectedColumn — composite ID lookup', () => {
const baseFormatting = {
const baseFormatting: FormattingOptions = {
format: 'table',
maxLines: 1,
fontSize: 'small',
fontSize: FontSize.SMALL,
};
// Two entries share the same `name` but differ in `fieldContext` — the
// exact case composite IDs are meant to disambiguate.
const seedColumns = [
const seedColumns: TelemetryFieldKey[] = [
{
name: 'body',
fieldContext: 'log',
@@ -264,36 +227,26 @@ describe('useOptionsMenu', () => {
{
name: 'timestamp',
fieldContext: 'log',
fieldDataType: '',
signal: 'logs',
},
];
beforeEach(() => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: { columns: [], formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: seedColumns, formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
} satisfies PreferenceContextValue);
});
it('reorders by composite IDs — preserves both same-name variants distinctly', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
@@ -306,12 +259,7 @@ describe('useOptionsMenu', () => {
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const reordered = mockUpdateColumns.mock.calls[0][0];
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
expect(toIds(reordered)).toStrictEqual([
'attribute:service.name',
'log:body',
'resource:service.name',
@@ -320,11 +268,13 @@ describe('useOptionsMenu', () => {
});
it('reorder ignores ids that are not columns (e.g. state-indicator) and skips unknown ids', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
result.current.config.addColumn?.onReorder([
@@ -338,12 +288,7 @@ describe('useOptionsMenu', () => {
const reordered = mockUpdateColumns.mock.calls[0][0];
// state-indicator + unknown.composite filtered; rest preserved in given order.
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
expect(toIds(reordered)).toStrictEqual([
'log:timestamp',
'log:body',
'resource:service.name',
@@ -352,11 +297,13 @@ describe('useOptionsMenu', () => {
});
it('removes only the variant whose composite ID matches', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Removing 'resource:service.name' should drop ONLY the resource variant.
@@ -364,20 +311,21 @@ describe('useOptionsMenu', () => {
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const remaining = mockUpdateColumns.mock.calls[0][0];
expect(
remaining.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
expect(toIds(remaining)).toStrictEqual([
'log:body',
'attribute:service.name',
'log:timestamp',
]);
});
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
result.current.config.addColumn?.onRemove('unknown.composite');
@@ -390,35 +338,34 @@ describe('useOptionsMenu', () => {
describe('fieldsSelector.value drops legacy columns without a name', () => {
it('excludes entries missing name while keeping valid columns', () => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [
{ name: 'body', fieldContext: 'log' },
{ key: 'legacy-key-no-name', fieldContext: 'log' },
{ name: 'timestamp', fieldContext: 'log' },
],
formatting: { format: 'table', maxLines: 1, fontSize: 'small' },
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice(
[
{ name: 'body', fieldContext: 'log' },
// A legacy stored column: `key` only, no `name`.
{
key: 'legacy-key-no-name',
fieldContext: 'log',
} as unknown as TelemetryFieldKey,
{ name: 'timestamp', fieldContext: 'log' },
],
{
format: 'table',
maxLines: 1,
fontSize: FontSize.SMALL,
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: [], formatting: {} },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
),
[SavedviewtypesSourceDTO.logs]: slice([], {}),
} satisfies PreferenceContextValue);
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
}),
{ wrapper },
);
const fields = result.current.config.fieldsSelector?.value ?? [];

View File

@@ -1,4 +1,6 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PREFERENCE_SOURCE_PAGES } from 'providers/preferences/constants';
import { DataSource } from 'types/common/queryBuilder';
import { FontSize, OptionsQuery } from './types';
@@ -13,6 +15,14 @@ export const defaultOptionsQuery: OptionsQuery = {
fontSize: FontSize.SMALL,
};
export const SOURCE_PAGE_SIGNAL: Record<
(typeof PREFERENCE_SOURCE_PAGES)[number],
DataSource
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
[DataSource.TRACES]: ['body', 'isRoot', 'isEntryPoint'],
[DataSource.METRICS]: ['body'],

View File

@@ -1,6 +1,7 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { TelemetryFieldKey } from 'api/v5/v5';
import { AxiosResponse } from 'axios';
@@ -11,6 +12,7 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
import { has } from 'lodash-es';
import { AllTraceFilterKeyValue } from 'constants/traceFilterKeys';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { PreferenceSourcePage } from 'providers/preferences/types';
import {
QueryKeyRequestProps,
QueryKeySuggestionsResponseProps,
@@ -20,13 +22,13 @@ import {
FieldDataType,
SignalType,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultOptionsQuery,
defaultTraceSelectedColumns,
EXCLUDED_COLUMNS,
SOURCE_PAGE_SIGNAL,
URL_OPTIONS,
} from './constants';
import {
@@ -40,7 +42,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
sourcePage: PreferenceSourcePage;
aggregateOperator?: string;
initialOptions?: InitialOptions;
}
@@ -52,15 +54,14 @@ interface UseOptionsMenu {
}
const useOptionsMenu = ({
dataSource,
// aggregateOperator,
sourcePage,
initialOptions = {},
}: UseOptionsMenuProps): UseOptionsMenu => {
const signal = SOURCE_PAGE_SIGNAL[sourcePage];
const { notifications } = useNotifications();
const prefCtx = usePreferenceContext();
// TODO: send null to updateColumns and updateFormatting if dataSource is not logs or traces
const slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
const { preferences, updateColumns, updateFormatting } = slice;
const { preferences, updateColumns, updateFormatting } =
usePreferenceContext()[sourcePage];
const [searchText, setSearchText] = useState<string>('');
const [isFocused, setIsFocused] = useState<boolean>(false);
@@ -68,10 +69,10 @@ const useOptionsMenu = ({
const initialQueryParamsV5: QueryKeyRequestProps = useMemo(
() => ({
signal: dataSource,
signal,
searchText: '',
}),
[dataSource],
[signal],
);
const { query: optionsQuery, redirectWithQuery: redirectWithOptionsData } =
@@ -127,7 +128,7 @@ const useOptionsMenu = ({
?.map((column) => attributesData.find(({ name }) => name === column))
.filter((e) => !!e) || []) as TelemetryFieldKey[];
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
initialSelected = initialSelected
?.map((col) => {
if (col && Object.keys(AllTraceFilterKeyValue).includes(col?.name)) {
@@ -157,7 +158,7 @@ const useOptionsMenu = ({
isFetchedInitialAttributes,
initialOptions?.selectColumns,
initialAttributesResult,
dataSource,
sourcePage,
]);
const {
@@ -195,7 +196,7 @@ const useOptionsMenu = ({
fieldDataType: e.fieldDataType,
}));
}
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return defaultTraceSelectedColumns.map((e) => ({
...e,
name: e.name,
@@ -203,16 +204,16 @@ const useOptionsMenu = ({
}
return [];
}, [dataSource, searchedAttributesDataV5?.data.data.keys]);
}, [sourcePage, searchedAttributesDataV5?.data.data.keys]);
const initialOptionsQuery: OptionsQuery = useMemo(() => {
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
defaultColumns = defaultTraceSelectedColumns.map((e) => ({
...e,
name: e.name,
}));
} else if (dataSource === DataSource.LOGS) {
} else if (sourcePage === SavedviewtypesSourceDTO.logs) {
// eslint-disable-next-line sonarjs/no-identical-functions
defaultColumns = defaultLogsSelectedColumns.map((e) => ({
...e,
@@ -232,7 +233,7 @@ const useOptionsMenu = ({
...initialOptions,
selectColumns: finalSelectColumns,
};
}, [dataSource, initialOptions, initialSelectedColumns]);
}, [sourcePage, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],
@@ -241,7 +242,7 @@ const useOptionsMenu = ({
const optionsFromAttributeKeys = useMemo(() => {
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
const exclusions = EXCLUDED_COLUMNS[dataSource];
const exclusions = EXCLUDED_COLUMNS[signal];
if (exclusions) {
return !exclusions.includes(item.name);
}
@@ -249,7 +250,7 @@ const useOptionsMenu = ({
});
return getOptionsFromKeys(filteredAttributeKeys, selectedColumnKeys);
}, [dataSource, searchedAttributeKeys, selectedColumnKeys]);
}, [signal, searchedAttributeKeys, selectedColumnKeys]);
const handleRedirectWithOptionsData = useCallback(
(newQueryData: OptionsQuery) => {
@@ -285,7 +286,10 @@ const useOptionsMenu = ({
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
if (
!newSelectedColumns?.length &&
sourcePage !== SavedviewtypesSourceDTO.logs
) {
notifications.error({
message: 'There must be at least one selected column',
});
@@ -294,7 +298,7 @@ const useOptionsMenu = ({
updateColumns(newSelectedColumns || []);
},
[dataSource, notifications, preferences, updateColumns],
[sourcePage, notifications, preferences, updateColumns],
);
const handleFormatChange = useCallback(

View File

@@ -77,6 +77,11 @@
word-break: break-all;
}
.error {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}
.noFilters {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);

View File

@@ -1,13 +1,8 @@
import { CalendarClock } from '@signozhq/icons';
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { useTimezone } from 'providers/Timezone';
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
import SavedViewHoverCardDetails from './SavedViewHoverCardDetails';
import { SavedViewHoverCardProps } from './types';
import styles from './SavedViewHoverCard.module.scss';
@@ -18,23 +13,11 @@ function SavedViewHoverCard({
onCardLeave,
onClose,
}: SavedViewHoverCardProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const view = target?.view;
const querySummaries = useMemo(
() => (view ? getSavedViewQuerySummaries(view) : []),
[view],
);
const hasQueryNames = querySummaries.length > 1;
const copyValue = querySummaries
.filter(({ expression }) => expression.length > 0)
.map(({ queryName, expression }) =>
hasQueryNames ? `${queryName}: ${expression}` : expression,
)
.join('\n');
const { allowed: canReadAll } = useAuthZ([SavedViewReadPermission]);
return (
<Popover
open={!!view}
open={!!target}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
@@ -55,71 +38,17 @@ function SavedViewHoverCard({
onOpenAutoFocus={(event): void => event.preventDefault()}
onCloseAutoFocus={(event): void => event.preventDefault()}
>
{view && (
{target && (
<div
className={styles.content}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
data-testid="saved-view-hover-card"
>
<div className={styles.header}>
<Typography.Text className={styles.name}>
{view.spec.displayName}
</Typography.Text>
{copyValue && (
<CopyButton
value={copyValue}
ariaLabel="Copy query"
testId="saved-view-hover-card-copy"
/>
)}
</div>
<div className={styles.query} data-testid="saved-view-hover-card-query">
{querySummaries.length === 0 ? (
<Typography.Text className={styles.noFilters}>
No filters
</Typography.Text>
) : (
querySummaries.map(({ queryName, expression, metric }) => (
<div key={queryName} className={styles.queryItem}>
{hasQueryNames && (
<Typography.Text className={styles.queryName}>
{queryName}
</Typography.Text>
)}
{metric && (
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
)}
{expression && (
<Typography.Text className={styles.expression}>
<SavedViewHighlightedExpression expression={expression} />
</Typography.Text>
)}
</div>
))
)}
</div>
<div className={styles.updated}>
<Typography.Text className={styles.sectionTitle}>
Last updated
</Typography.Text>
{view.updatedBy && (
<Typography.Text className={styles.metaText}>
{view.updatedBy}
</Typography.Text>
)}
{view.updatedAt && (
<div className={styles.meta}>
<CalendarClock size={14} />
<Typography.Text className={styles.metaText}>
{formatTimezoneAdjustedTimestamp(
view.updatedAt,
SAVED_VIEW_UPDATED_AT_FORMAT,
)}
</Typography.Text>
</div>
)}
</div>
<SavedViewHoverCardDetails
viewId={target.viewId}
canReadAll={canReadAll}
/>
</div>
)}
</PopoverContent>

View File

@@ -0,0 +1,121 @@
import { useMemo } from 'react';
import { CalendarClock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { useTimezone } from 'providers/Timezone';
import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_UPDATED_AT_FORMAT,
} from './constants';
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
import SavedViewHoverCardSkeleton from './SavedViewHoverCardSkeleton';
import { SavedViewHoverCardDetailsProps } from './types';
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
import styles from './SavedViewHoverCard.module.scss';
function SavedViewHoverCardDetails({
viewId,
}: SavedViewHoverCardDetailsProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { data, isLoading, isError } = useGetSavedView({ id: viewId });
const view = data?.data;
const querySummaries = useMemo(
() => (view ? getSavedViewQuerySummaries(view) : []),
[view],
);
if (isLoading) {
return <SavedViewHoverCardSkeleton />;
}
if (isError || !view) {
return (
<Typography.Text
className={styles.error}
data-testid="saved-view-hover-card-error"
>
{SAVED_VIEW_LOAD_FAILED_NAME}
</Typography.Text>
);
}
const hasQueryNames = querySummaries.length > 1;
const copyValue = querySummaries
.filter(({ expression }) => expression.length > 0)
.map(({ queryName, expression }) =>
hasQueryNames ? `${queryName}: ${expression}` : expression,
)
.join('\n');
return (
<>
<div className={styles.header}>
<Typography.Text className={styles.name}>
{view.spec.displayName}
</Typography.Text>
{copyValue && (
<CopyButton
value={copyValue}
ariaLabel="Copy query"
testId="saved-view-hover-card-copy"
/>
)}
</div>
<div className={styles.query} data-testid="saved-view-hover-card-query">
{querySummaries.length === 0 ? (
<Typography.Text className={styles.noFilters}>No filters</Typography.Text>
) : (
querySummaries.map(({ queryName, expression, metric }) => (
<div key={queryName} className={styles.queryItem}>
{hasQueryNames && (
<Typography.Text className={styles.queryName}>
{queryName}
</Typography.Text>
)}
{metric && (
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
)}
{expression && (
<Typography.Text className={styles.expression}>
<SavedViewHighlightedExpression expression={expression} />
</Typography.Text>
)}
</div>
))
)}
</div>
<div className={styles.updated}>
<Typography.Text className={styles.sectionTitle}>
Last updated
</Typography.Text>
{view.updatedBy && (
<Typography.Text className={styles.metaText}>
{view.updatedBy}
</Typography.Text>
)}
{view.updatedAt && (
<div className={styles.meta}>
<CalendarClock size={14} />
<Typography.Text className={styles.metaText}>
{formatTimezoneAdjustedTimestamp(
view.updatedAt,
SAVED_VIEW_UPDATED_AT_FORMAT,
)}
</Typography.Text>
</div>
)}
</div>
</>
);
}
export default withAuthZContent(SavedViewHoverCardDetails, {
checks: ({ viewId, canReadAll }) =>
canReadAll ? [] : [buildSavedViewReadPermission(viewId)],
fallbackOnLoading: <SavedViewHoverCardSkeleton />,
});

View File

@@ -0,0 +1,13 @@
import { Skeleton } from '@signozhq/ui/skeleton';
function SavedViewHoverCardSkeleton(): JSX.Element {
return (
<Skeleton
active
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
testId="saved-view-hover-card-loading"
/>
);
}
export default SavedViewHoverCardSkeleton;

View File

@@ -54,9 +54,8 @@
&.isActive {
background-color: var(--l3-background);
.rowName {
--button-variant-ghost-color: var(--l1-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
.rowLink {
color: var(--l1-foreground);
}
}
}
@@ -76,18 +75,32 @@
opacity: 1;
}
.rowName {
--button-height: 100%;
--button-padding: 0 var(--spacing-6);
--button-justify-content: flex-start;
--button-font-size: var(--periscope-font-size-base);
--button-variant-ghost-color: var(--l2-foreground);
--button-variant-ghost-hover-color: var(--l2-foreground);
--button-variant-ghost-hover-background-color: transparent;
.rowLink {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
height: 100%;
padding: 0 var(--spacing-6);
border-radius: var(--radius-1);
color: var(--l2-foreground);
text-decoration: none;
&:focus-visible {
outline: 1px solid var(--button-focus-ring);
outline-offset: -1px;
}
&:hover {
color: var(--l2-foreground-hover);
}
}
// nowrap fills the row before the ellipsis; line clamping alone stops at the last whole word.
.rowLinkLabel {
min-width: 0;
letter-spacing: -0.005em;
white-space: nowrap;
}
.loading {

View File

@@ -65,11 +65,11 @@ function SavedViewsList({
const rowHover = useMemo<SavedViewsListRowHoverProps>(
() => ({
onRowEnter: (view, row): void => {
onRowEnter: (viewId, row): void => {
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
const rowRect = row.getBoundingClientRect();
onHoverCardRowEnter({
view,
viewId,
anchorTop: rowRect.top - listTop,
anchorHeight: rowRect.height,
});

View File

@@ -1,10 +1,8 @@
import cx from 'classnames';
import { Undo2 } from '@signozhq/icons';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import SavedViewsRowButton from './SavedViewsRowButton';
import SavedViewsRowLink from './SavedViewsRowLink';
import SavedViewsRowMenu from './SavedViewsRowMenu';
import { SavedViewsListRowProps } from './types';
@@ -18,42 +16,17 @@ function SavedViewsListRow({
onAction,
hover,
}: SavedViewsListRowProps): JSX.Element {
const name = view.spec.displayName;
const readPermission = buildSavedViewReadPermission(view.id);
// Same cached check as the button; gates the hover card.
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
const canRead = !isLoading && deniedPermissions.length === 0;
return (
<div
className={cx(styles.row, { [styles.isActive]: isActive })}
data-testid="saved-views-row"
data-view-id={view.id}
onMouseEnter={(event): void => {
if (canRead) {
hover.onRowEnter(view, event.currentTarget);
}
}}
onMouseEnter={(event): void =>
hover.onRowEnter(view.id, event.currentTarget)
}
onMouseLeave={hover.onRowLeave}
>
<div className={styles.rowName}>
<AuthZButton
checks={[readPermission]}
size="md"
variant="ghost"
color="secondary"
width="100%"
textOverflow="ellipsis"
onClick={(): void => {
if (!isActive) {
onSelect(view);
}
}}
testId="saved-views-row-name"
>
{name}
</AuthZButton>
</div>
<SavedViewsRowLink view={view} isActive={isActive} onSelect={onSelect} />
{isActive && (
<SavedViewsRowButton
icon={<Undo2 size={14} />}

View File

@@ -5,7 +5,11 @@ import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
SavedViewListPermission,
SavedViewReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsList from './SavedViewsList';
@@ -21,6 +25,8 @@ function SavedViewsPanel({
onClose?: () => void;
}): JSX.Element {
const [search, setSearch] = useState('');
// Batched with the list check, so the hover card reads it from cache.
useAuthZ([SavedViewReadPermission]);
return (
<div

View File

@@ -0,0 +1,57 @@
import { MouseEvent, useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { AppLink } from 'lib/router/AppLink';
import { useAppLocation } from 'lib/router/useAppLocation';
import { isModifierKeyPressed } from 'utils/app';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import { SavedViewsRowLinkProps } from './types';
import { getSavedViewPath } from './utils/getSavedViewPath';
import styles from './SavedViewsList.module.scss';
function SavedViewsRowLink({
view,
isActive,
onSelect,
}: SavedViewsRowLinkProps): JSX.Element {
const { pathname } = useAppLocation();
const timeSearch = useTimeSearchParams();
const path = useMemo(
() => getSavedViewPath(pathname, view, timeSearch),
[pathname, view, timeSearch],
);
// Opening in a new tab or window is left to the browser.
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
if (isModifierKeyPressed(event) || event.shiftKey) {
return;
}
event.preventDefault();
if (!isActive) {
onSelect(view);
}
};
return (
<AppLink
to={path}
className={styles.rowLink}
aria-current={isActive ? 'page' : undefined}
onClick={handleClick}
data-testid="saved-views-row-name"
>
<Typography.Text
as="span"
size="base"
weight="normal"
truncate={1}
className={styles.rowLinkLabel}
>
{view.spec.displayName}
</Typography.Text>
</AppLink>
);
}
export default SavedViewsRowLink;

View File

@@ -8,6 +8,8 @@ import {
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -16,7 +18,7 @@ import {
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
} from '../constants';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
@@ -139,9 +141,11 @@ function advance(ms: number): void {
});
}
function openCard(name: string): void {
// Resolves once the card has fetched its view.
async function openCard(name: string): Promise<HTMLElement> {
fireEvent.mouseEnter(row(name));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
return screen.findByTestId('saved-view-hover-card-query');
}
describe('SavedViewHoverCard', () => {
@@ -168,7 +172,8 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
advance(1);
expect(card()).toHaveTextContent('Errors');
expect(card()).toBeInTheDocument();
await waitFor(() => expect(card()).toHaveTextContent('Errors'));
});
it('does not open when the pointer leaves before the delay', async () => {
@@ -184,7 +189,7 @@ describe('SavedViewHoverCard', () => {
it('stays open while the pointer moves into the card', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(card() as HTMLElement);
@@ -198,7 +203,7 @@ describe('SavedViewHoverCard', () => {
it('closes on leaving the row and reopens for the next row after the delay', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(row('Slow spans'));
@@ -206,12 +211,12 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
expect(card()).toHaveTextContent('Slow spans');
await waitFor(() => expect(card()).toHaveTextContent('Slow spans'));
});
it('closes when the row menu is pressed', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
@@ -222,19 +227,17 @@ describe('SavedViewHoverCard', () => {
describe('content', () => {
it('shows the filter, the updater and the update time', async () => {
await renderPanel();
openCard('Errors');
const query = await openCard('Errors');
const content = card() as HTMLElement;
expect(
within(content).getByTestId('saved-view-hover-card-query'),
).toHaveTextContent('has_error = true');
expect(query).toHaveTextContent('has_error = true');
expect(content).toHaveTextContent('editor@signoz.io');
expect(content).toHaveTextContent('Sep 28, 2026');
});
it('copies the filter expression', async () => {
await renderPanel();
openCard('Errors');
await openCard('Errors');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
@@ -243,9 +246,8 @@ describe('SavedViewHoverCard', () => {
it('names each query when there are several, and copies them per line', async () => {
await renderPanel();
openCard('Errors and slow');
const query = await openCard('Errors and slow');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('A');
expect(query).toHaveTextContent('B');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
@@ -257,21 +259,74 @@ describe('SavedViewHoverCard', () => {
it('says there are no filters, with nothing to copy', async () => {
await renderPanel();
openCard('Everything');
const query = await openCard('Everything');
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
'No filters',
);
expect(query).toHaveTextContent('No filters');
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
});
it('shows the metric of a metrics query', async () => {
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
openCard('Request rate');
const query = await openCard('Request rate');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('app.requests · rate · sum');
expect(query).not.toHaveTextContent('No filters');
});
});
describe('fetching', () => {
it('shows the query from the view fetch, not from the list', async () => {
server.use(
rest.get(API, (_req, res, ctx) =>
res(
ctx.json({
status: 'success',
data: [{ ...errors, spec: { ...errors.spec, queries: [] } }],
}),
),
),
);
await renderPanel();
const query = await openCard('Errors');
expect(query).toHaveTextContent('has_error = true');
});
it('fetches the view again on every open', async () => {
let fetches = 0;
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) => {
fetches += 1;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
await renderPanel();
await openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
expect(card()).toBeNull();
await openCard('Errors');
await waitFor(() => expect(fetches).toBe(2));
});
it('says the view failed to load when its fetch fails', async () => {
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await expect(
screen.findByTestId('saved-view-hover-card-error'),
).resolves.toHaveTextContent('View failed to load');
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
});
});
});

View File

@@ -1,6 +1,10 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesTransactionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
@@ -9,34 +13,42 @@ import {
buildSavedViewUpdatePermission,
SavedViewCreatePermission,
SavedViewListPermission,
SavedViewReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS } from '../constants';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const HOVER_CARD_TIMEOUT = { timeout: SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS * 3 };
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
function renderPanel(url = explorerUrl(PATH, {})): void {
renderWithExplorerProviders(
function renderPanel(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
@@ -68,6 +80,37 @@ async function openRowMenu(name: string): Promise<void> {
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
}
// Records every check, answered by `isGranted`.
function recordChecks(
isGranted: (transaction: AuthtypesTransactionDTO) => boolean = (): boolean =>
true,
): AuthtypesTransactionDTO[] {
const checks: AuthtypesTransactionDTO[] = [];
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
checks.push(...payload);
return res(ctx.json(authzMockResponse(payload, payload.map(isGranted))));
}),
);
return checks;
}
const readSelectors = (checks: AuthtypesTransactionDTO[]): string[] =>
checks
.filter((transaction) => transaction.relation === 'read')
.map((transaction) => transaction.object.selector);
async function hoverRow(name: string): Promise<HTMLElement> {
await screen.findAllByTestId('saved-views-row');
fireEvent.mouseEnter(rowNamed(name));
return screen.findByTestId(
'saved-view-hover-card',
undefined,
HOVER_CARD_TIMEOUT,
);
}
// Resolves once the row's check is back, so its state is final.
async function settledRow(testId: string): Promise<HTMLElement> {
await waitFor(
@@ -98,21 +141,51 @@ describe('SavedViews - AuthZ', () => {
expect(screen.queryByTestId('saved-views-search')).toBeNull();
});
it('disables only the views it cannot read', async () => {
it('runs no read check per row', async () => {
mockSavedViewsApi([errors, slow]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
const checks = recordChecks();
renderPanel();
// The menu checks go out after any check the rows made on mount.
await openRowMenu('Errors');
await settledRow('saved-views-row-menu-item-delete');
expect(checks.map((transaction) => transaction.relation)).toContain('delete');
expect(readSelectors(checks)).toStrictEqual(['*']);
});
it('opens a view it cannot read, then the header says no access', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
const { history } = renderWithExplorerProviders(
<>
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onToggleViews={jest.fn()}
/>
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>
</>,
explorerUrl(PATH, {}),
);
await screen.findAllByTestId('saved-views-row');
const nameOf = (view: string): HTMLElement =>
within(rowNamed(view)).getByTestId('saved-views-row-name');
const name = within(rowNamed('Errors')).getByTestId('saved-views-row-name');
expectUsable(name);
await userEvent.click(name);
await waitFor(() =>
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
),
);
await waitFor(() =>
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
});
it('keeps the row menu on a view it cannot read', async () => {
@@ -133,6 +206,58 @@ describe('SavedViews - AuthZ', () => {
).resolves.not.toHaveAttribute('data-disabled');
});
it('shows the denied callout in the hover card of a view it cannot read, without fetching it', async () => {
let fetches = 0;
mockSavedViewsApi([errors]);
server.use(
setupAuthzDeny(
SavedViewReadPermission,
buildSavedViewReadPermission(errors.id),
),
rest.get(`${API}/:id`, (_req, res, ctx) => {
fetches += 1;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(within(card).queryByTestId('saved-view-hover-card-query')).toBeNull();
expect(fetches).toBe(0);
});
it('skips the read check of the hovered view with read on every view', async () => {
mockSavedViewsApi([errors]);
const checks = recordChecks();
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByTestId('saved-view-hover-card-query'),
).resolves.toHaveTextContent('has_error = true');
expect(readSelectors(checks)).toStrictEqual(['*']);
});
it('checks read on the hovered view without read on every view', async () => {
mockSavedViewsApi([errors]);
const checks = recordChecks(
(transaction) =>
transaction.relation !== 'read' || transaction.object.selector !== '*',
);
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByTestId('saved-view-hover-card-query'),
).resolves.toHaveTextContent('has_error = true');
expect(readSelectors(checks)).toStrictEqual(['*', errors.id]);
});
it('disables creating without create', async () => {
mockSavedViewsApi([]);
server.use(setupAuthzDeny(SavedViewCreatePermission));

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { act, screen, userEvent, waitFor } from 'tests/test-utils';
import SavedViewsHeader from '../SavedViewsHeader';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -28,7 +27,7 @@ const errors = {
};
function Harness(): JSX.Element {
const { traces } = usePreferenceContext();
const traces = usePreferenceContext()[SavedviewtypesSourceDTO.traces];
const [headerKey, setHeaderKey] = useState(0);
const columns = traces.preferences?.columns ?? [];
@@ -37,7 +36,7 @@ function Harness(): JSX.Element {
<SavedViewsHeader
key={headerKey}
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onOpenViews={jest.fn() as jest.MockedFunction<() => void>}
/>
<span data-testid="columns">
{columns.map((column) => column.name).join(',')}
@@ -84,13 +83,14 @@ describe('SavedViewsHeader columns', () => {
});
it('keeps edited columns when the view is fetched again', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('remount-header'));
await user.click(screen.getByTestId('remount-header'));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(columnsText()).toBe('service.name,name,extra-2');
@@ -98,35 +98,37 @@ describe('SavedViewsHeader columns', () => {
});
it('discard puts the saved columns back', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await user.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
expect(isDirty()).toBe(false);
});
it('discard after an update goes back to the updated columns', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await user.click(screen.getByTestId('saved-views-save-changes'));
const update = await screen.findByTestId(
'saved-views-save-changes-item-update',
);
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
await userEvent.click(update);
await user.click(update);
await waitFor(() => expect(isDirty()).toBe(false));
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await user.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name,extra-2'));
expect(isDirty()).toBe(false);

View File

@@ -1,9 +1,14 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
fireEvent,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -14,6 +19,7 @@ import {
renderWithExplorerProviders,
urlParam,
viewUrl,
withGlobalTime,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
@@ -53,6 +59,9 @@ const row = (name: string): HTMLElement =>
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const rowName = (name: string): HTMLElement =>
within(row(name)).getByTestId('saved-views-row-name');
describe('SavedViewsPanel list', () => {
beforeEach(() => {
localStorage.clear();
@@ -85,9 +94,8 @@ describe('SavedViewsPanel list', () => {
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
await waitFor(() => expectUsable(name));
await userEvent.click(name);
await waitFor(() => expectUsable(rowName('Slow spans')));
await userEvent.click(rowName('Slow spans'));
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
@@ -96,6 +104,60 @@ describe('SavedViewsPanel list', () => {
);
});
it('links each row to its view in the current time range', async () => {
mockSavedViewsApi([mine, alsoMine]);
renderWithExplorerProviders(
withGlobalTime(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
{ selectedTime: '1h' },
),
explorerUrl(PATH, {}),
);
await screen.findByTestId('saved-views-list');
const url = new URL(
rowName('Slow spans').getAttribute('href') as string,
window.location.origin,
);
expect(url.pathname).toBe(PATH);
expect(url.searchParams.get(QueryParams.viewKey)).toBe(
JSON.stringify(alsoMine.id),
);
expect(url.searchParams.get(QueryParams.panelTypes)).toBe(
JSON.stringify(alsoMine.spec.panelType),
);
expect(url.searchParams.has(QueryParams.compositeQuery)).toBe(true);
expect(url.searchParams.get(QueryParams.relativeTime)).toBe('1h');
});
it.each([
['Ctrl', { ctrlKey: true }],
['Cmd', { metaKey: true }],
['Shift', { shiftKey: true }],
])('leaves a %s click on a row to the browser', async (_key, modifiers) => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
const before = history.location.search;
let defaultPrevented: boolean | undefined;
const recordClick = (event: MouseEvent): void => {
defaultPrevented = event.defaultPrevented;
// jsdom cannot open a new tab.
event.preventDefault();
};
document.addEventListener('click', recordClick);
fireEvent.click(rowName('Slow spans'), modifiers);
document.removeEventListener('click', recordClick);
expect(defaultPrevented).toBe(false);
expect(history.location.search).toBe(before);
});
it('marks the open view and clears it from its row', async () => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel(viewUrl(PATH, mine));
@@ -120,9 +182,8 @@ describe('SavedViewsPanel list', () => {
await screen.findByTestId('saved-views-list');
const before = history.location.search;
await userEvent.click(
within(row('Errors')).getByTestId('saved-views-row-name'),
);
expect(rowName('Errors')).toHaveAttribute('aria-current', 'page');
await userEvent.click(rowName('Errors'));
expect(history.location.search).toBe(before);
});

View File

@@ -4,6 +4,7 @@ import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
@@ -16,6 +17,7 @@ import {
renderWithExplorerProviders,
urlParam,
viewUrl,
withGlobalTime,
} from './savedViewsTestUtils';
const mockCopy = jest.fn();
@@ -32,6 +34,8 @@ jest.mock('@signozhq/ui/sonner', () => ({
}));
const PATH = ROUTES.TRACES_EXPLORER;
const START_MS = 1700000000000;
const END_MS = 1700003600000;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
@@ -119,9 +123,22 @@ describe('SavedViewsPanel row menu', () => {
);
});
it('copies a link that opens the view with its query and tab', async () => {
it('copies a link that opens the view with its query, tab and time range', async () => {
mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
renderWithExplorerProviders(
withGlobalTime(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
{
selectedTime: 'custom',
minTime: START_MS * NANO_SECOND_MULTIPLIER,
maxTime: END_MS * NANO_SECOND_MULTIPLIER,
},
),
explorerUrl(PATH, {}),
);
await chooseFromRowMenu('Slow spans', 'Copy link');
@@ -137,6 +154,9 @@ describe('SavedViewsPanel row menu', () => {
expect(
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
).toContain('duration_nano > 1000');
expect(link.searchParams.get(QueryParams.startTime)).toBe(String(START_MS));
expect(link.searchParams.get(QueryParams.endTime)).toBe(String(END_MS));
expect(link.searchParams.has(QueryParams.relativeTime)).toBe(false);
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
});

View File

@@ -28,6 +28,7 @@ import { TestRouter } from 'tests/router';
import { getAppContextMock } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
@@ -160,6 +161,23 @@ export function viewUrl(
const mockStore = configureStore([thunk]);
export function withGlobalTime(
ui: ReactElement,
globalTime: Partial<GlobalReducer>,
): ReactElement {
const state = store.getState();
return (
<Provider
store={mockStore({
...state,
globalTime: { ...state.globalTime, ...globalTime },
})}
>
{ui}
</Provider>
);
}
// What the explorer page does around the header: writes its default query to a
// bare url, and sets the provider's tab from the url (the provider alone seeds
// it from the raw, still JSON-quoted param).

View File

@@ -1,28 +1,16 @@
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SavedviewtypesSchemaVersionDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
buildSavedViewDeletePermission,
buildSavedViewUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,

View File

@@ -8,10 +8,9 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { DataSource } from 'types/common/queryBuilder';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
@@ -21,9 +20,9 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const hasOptions = isPreferenceSourcePage(source);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
});
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
@@ -61,17 +60,19 @@ export function useActiveSavedView(
}
return hasUnsavedViewChanges({
view,
source,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
options: hasOptions ? options : undefined,
});
}, [
view,
source,
isError,
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
hasOptions,
options,
]);

View File

@@ -20,13 +20,12 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppLocation } from 'lib/router/useAppLocation';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
@@ -60,15 +59,11 @@ export function useSavedViewActions(
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const hasOptions = isPreferenceSourcePage(source);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
});
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
const preferenceSlice = usePreferenceSlice(source);
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
@@ -86,10 +81,10 @@ export function useSavedViewActions(
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
options: hasOptions ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
[stagedQuery, currentQuery, panelType, hasOptions, options],
);
const selectView = useCallback(
@@ -105,14 +100,14 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (optionsDataSource) {
if (preferenceSlice) {
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
source,
);
preferencesSlice.updateColumns(columns);
preferenceSlice.updateColumns(columns);
if (formatting) {
preferencesSlice.updateFormatting(formatting);
preferenceSlice.updateFormatting(formatting);
}
}
selectView(view);
@@ -120,7 +115,7 @@ export function useSavedViewActions(
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
[source, preferenceSlice, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt

View File

@@ -12,11 +12,10 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
import { useAppLocation } from 'lib/router/useAppLocation';
import { getAbsoluteUrl } from 'utils/basePath';
import { toAPIError } from 'utils/errorUtils';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import {
SAVED_VIEW_SCHEMA_VERSION,
@@ -24,7 +23,7 @@ import {
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getSavedViewPath } from '../utils/getSavedViewPath';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
@@ -43,6 +42,7 @@ export function useSavedViewRowActions(
): UseSavedViewRowActionsResult {
const queryClient = useQueryClient();
const { pathname } = useAppLocation();
const timeSearch = useTimeSearchParams();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
@@ -52,17 +52,15 @@ export function useSavedViewRowActions(
const copyViewLink = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
[QueryParams.panelTypes]: view.spec.panelType,
[QueryParams.viewKey]: view.id,
});
copyToClipboard(getAbsoluteUrl(path));
copyToClipboard(
getAbsoluteUrl(getSavedViewPath(pathname, view, timeSearch)),
);
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, copyToClipboard, logSavedViewEvent],
[pathname, timeSearch, copyToClipboard, logSavedViewEvent],
);
const copyViewId = useCallback(

View File

@@ -45,6 +45,7 @@ import {
savedViewsListResponse,
type SavedViewSource,
unreadableViewId,
withLongName,
} from './__story_mockdata__/savedViews';
import SavedViewsHeader from '../SavedViewsHeader';
@@ -73,7 +74,13 @@ const controls = {
unreadableView: toggleControl('Unreadable view', {
group: VIEW,
description:
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
'Denies `read` on the first view created by others, and so on `saved-view:*`, on top of the Access controls: its row still opens it, and the header then shows No view access, as with Open view on `created-by-others`. Its hover card shows the permission callout in place of the query.',
value: false,
}),
viewPending: toggleControl('View fetch pending', {
group: VIEW,
description:
'Holds every `GET /saved_views/:id` open, so a hover card stays on its loading skeleton.',
value: false,
}),
createdByMe: countControl('Created by me', {
@@ -87,6 +94,12 @@ const controls = {
value: 4,
max: SAVED_VIEWS_MAX,
}),
longNames: toggleControl('Long names', {
group: LIST,
description:
'Lengthens every view name past the sidebar width, so rows and the open view name truncate.',
value: false,
}),
};
type SavedViewsValues = MockControlValues<typeof controls>;
@@ -99,7 +112,7 @@ const viewDenied = (
): boolean =>
transaction.relation === 'read' &&
transaction.object.resource.kind === 'saved-view' &&
transaction.object.selector === deniedId;
[deniedId, '*'].includes(transaction.object.selector);
const definition = defineStoryMocks({
controls,
@@ -107,7 +120,14 @@ const definition = defineStoryMocks({
values: SavedViewsValues & AuthzArgs,
response,
): RequestHandler[] => {
const { source, openView, createdByMe, createdByOthers } = values;
const {
source,
openView,
viewPending,
createdByMe,
createdByOthers,
longNames,
} = values;
// What the panel writes stays in this handler set until a control changes.
const created: SavedviewtypesSavedViewDTO[] = [];
@@ -118,8 +138,14 @@ const definition = defineStoryMocks({
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
const named = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => (longNames ? withLongName(view) : view);
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
const view = created.find((item) => item.id === id) ?? savedViewById(id);
const stored = savedViewById(id);
const view =
created.find((item) => item.id === id) ?? (stored && named(stored));
return view && !deleted.has(id) ? current(view) : undefined;
};
@@ -143,7 +169,7 @@ const definition = defineStoryMocks({
return {
...list,
data: [...(list.data ?? []), ...created]
data: [...(list.data ?? []).map(named), ...created]
.filter((view) => !deleted.has(view.id))
.map(current),
};
@@ -151,6 +177,10 @@ const definition = defineStoryMocks({
),
rest.get(`${API}/:id`, (req, res, ctx) => {
if (viewPending) {
return res(ctx.delay('infinite'));
}
if (openError) {
return res(ctx.status(openError.status), ctx.json(openError.body));
}
@@ -274,7 +304,9 @@ const isPending = (element: HTMLElement): boolean =>
element.hasAttribute('data-loading');
const isDisabled = (element: HTMLElement): boolean =>
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
element.hasAttribute('disabled') ||
element.hasAttribute('data-disabled') ||
element.getAttribute('aria-disabled') === 'true';
export const expectDisabled = (
find: FindElement,
@@ -302,6 +334,30 @@ export const rowName =
(): HTMLElement =>
within(row).getByTestId('saved-views-row-name');
const hoverRow = async (row: HTMLElement): Promise<HTMLElement> => {
await userEvent.hover(row);
return screen.findByTestId('saved-view-hover-card', undefined, UNTIL_LOADED);
};
const searchViews = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
const search = await within(canvasElement).findByTestId(
'saved-views-search',
undefined,
UNTIL_LOADED,
);
await userEvent.type(search, text);
};
const clickWhenEnabled = async (find: FindElement): Promise<void> => {
await expectDisabled(find, false);
await userEvent.click(find());
};
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
await screen.findByRole('menu', undefined, UNTIL_LOADED);
@@ -327,3 +383,115 @@ export const openSaveChangesMenu = async (
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
byTestId(`saved-views-save-changes-item-${key}`);
type PlayContext = {
canvasElement: HTMLElement;
args: { source: SavedViewSource };
};
export const hoverFirstRow = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
const card = await hoverRow(row);
await within(card).findByTestId(
'saved-view-hover-card-query',
undefined,
UNTIL_LOADED,
);
};
export const hoverFirstRowLoading = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
const card = await hoverRow(row);
await within(card).findByTestId(
'saved-view-hover-card-loading',
undefined,
UNTIL_LOADED,
);
};
export const searchWithNoResults = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await searchViews(canvasElement, 'nothing like this');
await screen.findByTestId('saved-views-list-no-results');
};
export const openCreateModal = async (): Promise<void> => {
await clickWhenEnabled(byTestId('saved-views-create'));
await screen.findByTestId('save-view-modal');
};
export const openEditDetailsModal = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await clickWhenEnabled(rowMenuItem('editDetails'));
await screen.findByTestId('save-view-modal');
};
export const openSaveAsNewModal = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openSaveChangesMenu(canvasElement);
await clickWhenEnabled(saveChangesItem('save-as-new'));
await screen.findByTestId('save-view-modal');
};
export const openDeleteDialog = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await clickWhenEnabled(rowMenuItem('delete'));
await screen.findByTestId('delete-saved-view-confirm');
};
const findUnreadableRow = async ({
canvasElement,
args,
}: PlayContext): Promise<HTMLElement> => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
return rows.find(
(row) => row.getAttribute('data-view-id') === deniedId,
) as HTMLElement;
};
export const hoverUnreadableRow = async (
context: PlayContext,
): Promise<void> => {
const card = await hoverRow(await findUnreadableRow(context));
await expect(
await within(card).findByText(/not authorized/i, undefined, UNTIL_LOADED),
).toBeVisible();
await expect(
within(card).queryByTestId('saved-view-hover-card-query'),
).toBeNull();
};
export const openUnreadableView = async (
context: PlayContext,
): Promise<void> => {
const { canvasElement } = context;
const denied = await findUnreadableRow(context);
await userEvent.click(rowName(denied)());
await expect(
await within(canvasElement).findByText(
'No view access',
undefined,
UNTIL_LOADED,
),
).toBeVisible();
};

View File

@@ -6,10 +6,17 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import SavedViewsPanel from '../SavedViewsPanel';
import {
findRows,
hoverFirstRow,
hoverFirstRowLoading,
openCreateModal,
openDeleteDialog,
openEditDetailsModal,
openRowMenu,
openSaveAsNewModal,
openSaveChangesMenu,
SavedViewsSidebar,
savedViewsMocks,
searchWithNoResults,
sidebarCanvas,
} from './SavedViews.stories.mocks';
@@ -78,3 +85,48 @@ export const SaveChangesMenu: Story = {
await openSaveChangesMenu(canvasElement);
},
};
/**
* Views whose names run past the sidebar, with one of them open: each row and
* the open view name truncate.
*/
export const LongNames: Story = {
args: { longNames: true, openView: 'created-by-me' },
};
/** The hover card of the first row: its query, who updated it and when. */
export const HoverCard: Story = {
play: hoverFirstRow,
};
/** The hover card while its view is still loading: a skeleton in place of the query. */
export const HoverCardLoading: Story = {
args: { viewPending: true },
play: hoverFirstRowLoading,
};
/** A search that matches no view: the list says so in place of the rows. */
export const SearchNoResults: Story = {
play: searchWithNoResults,
};
/** The + in the header opens Save view for the current query. */
export const CreateModal: Story = {
play: openCreateModal,
};
/** Edit details from a row's menu: the modal starts on the view's name. */
export const EditDetailsModal: Story = {
play: openEditDetailsModal,
};
/** Save as new view from the ✓ menu on unsaved changes. */
export const SaveAsNewModal: Story = {
args: { openView: 'unsaved-changes' },
play: openSaveAsNewModal,
};
/** Delete from a row's menu asks to confirm before the view goes. */
export const DeleteDialog: Story = {
play: openDeleteDialog,
};

View File

@@ -209,6 +209,18 @@ export const createdSavedView = (
spec,
});
const LONG_NAME_SUFFIX = ', every environment and region, last 30 days';
export const withLongName = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({
...view,
spec: {
...view.spec,
displayName: `${view.spec.displayName}${LONG_NAME_SUFFIX}`,
},
});
export const savedViewResponse = (
view: SavedviewtypesSavedViewDTO,
): GetSavedView200 => ({ status: 'success', data: view });

View File

@@ -4,14 +4,15 @@ import { expect, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { unreadableViewId } from '../__story_mockdata__/savedViews';
import {
byTestId,
expectDisabled,
expectPending,
findRows,
hoverUnreadableRow,
openRowMenu,
openSaveChangesMenu,
openUnreadableView,
rowMenuItem,
rowName,
saveChangesItem,
@@ -99,21 +100,18 @@ export const NoCreateSaveMenu: Story = {
};
/**
* `read` denied on one view made by someone else: its name is disabled and its
* hover card never opens, while every other view still opens.
* `read` denied on one view made by someone else: the list checks no `read`, so
* its row opens it like any other, and the header then says No view access.
*/
export const NoRead: Story = {
args: { unreadableView: true },
play: async ({ canvasElement, args }) => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
play: openUnreadableView,
};
await Promise.all(
rows.map((row) =>
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
),
);
},
/** The hover card of the unreadable view: the permission callout in place of its query. */
export const NoReadHoverCard: Story = {
args: { unreadableView: true },
play: hoverUnreadableRow,
};
/** The unreadable view open from its link: the header names it No view access. */

View File

@@ -8,12 +8,9 @@ import {
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom } from './events';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
OptionsQuery,
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
@@ -29,6 +26,7 @@ export interface ToSavedViewSpecArgs {
export interface HasUnsavedViewChangesArgs {
view: SavedviewtypesSavedViewDTO;
source: SavedviewtypesSourceDTO;
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
@@ -128,6 +126,11 @@ export interface SavedViewsListRowProps {
hover: SavedViewsListRowHoverProps;
}
export type SavedViewsRowLinkProps = Pick<
SavedViewsListRowProps,
'view' | 'isActive' | 'onSelect'
>;
export type SavedViewRowActionKey =
| 'copyLink'
| 'copyId'
@@ -181,7 +184,7 @@ export interface UseSavedViewRowActionsResult {
}
export interface SavedViewHoverCardTarget {
view: SavedviewtypesSavedViewDTO;
viewId: string;
anchorTop: number;
anchorHeight: number;
}
@@ -202,8 +205,14 @@ export interface SavedViewHoverCardProps {
onClose: () => void;
}
export interface SavedViewHoverCardDetailsProps {
viewId: string;
/** `read` is granted on every view, so the view needs no check of its own. */
canReadAll: boolean;
}
export interface SavedViewsListRowHoverProps {
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
onRowEnter: (viewId: string, row: HTMLElement) => void;
onRowLeave: () => void;
onMenuOpen: () => void;
}

View File

@@ -0,0 +1,34 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { makeView } from '../../__tests__/savedViewsTestUtils';
import { getSavedViewPath } from '../getSavedViewPath';
const view = makeView({ id: 'view-1', displayName: 'Errors' });
function paramsOf(path: string): URLSearchParams {
return new URL(path, window.location.origin).searchParams;
}
describe('getSavedViewPath', () => {
it('opens the view on the given explorer in the given time range', () => {
const path = getSavedViewPath(
ROUTES.TRACES_EXPLORER,
view,
'startTime=1700000000000&endTime=1700003600000',
);
const params = paramsOf(path);
expect(path.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
expect(params.get(QueryParams.viewKey)).toBe(JSON.stringify(view.id));
expect(params.get(QueryParams.startTime)).toBe('1700000000000');
expect(params.get(QueryParams.endTime)).toBe('1700003600000');
});
it('adds no time range when there is none', () => {
const path = getSavedViewPath(ROUTES.TRACES_EXPLORER, view, '');
expect(path.endsWith('&')).toBe(false);
expect(paramsOf(path).has(QueryParams.relativeTime)).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
@@ -118,6 +119,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view,
source: SavedviewtypesSourceDTO.traces,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: [columns[0]] },
@@ -137,6 +139,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: bare,
source: SavedviewtypesSourceDTO.traces,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
@@ -161,6 +164,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
source: SavedviewtypesSourceDTO.logs,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting },
@@ -172,6 +176,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
source: SavedviewtypesSourceDTO.logs,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
@@ -191,6 +196,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: bare,
source: SavedviewtypesSourceDTO.logs,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
options: {
@@ -234,6 +240,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: olderView,
source: SavedviewtypesSourceDTO.metrics,
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
panelType: PANEL_TYPES.TIME_SERIES,
}),
@@ -281,6 +288,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: storedView,
source: SavedviewtypesSourceDTO.metrics,
stagedQuery: stagedWithTag,
panelType: PANEL_TYPES.TIME_SERIES,
}),
@@ -320,6 +328,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: noOrder,
source: SavedviewtypesSourceDTO.logs,
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
}),
@@ -335,6 +344,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: noOrder,
source: SavedviewtypesSourceDTO.logs,
stagedQuery: sorted,
panelType: PANEL_TYPES.LIST,
}),
@@ -344,6 +354,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: saved,
source: SavedviewtypesSourceDTO.logs,
stagedQuery: stagedFrom(saved, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
}),
@@ -360,6 +371,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view: legacy,
source: SavedviewtypesSourceDTO.logs,
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
}),
@@ -371,6 +383,7 @@ describe('hasUnsavedViewChanges', () => {
expect(
hasUnsavedViewChanges({
view,
source: SavedviewtypesSourceDTO.traces,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, ...formatting, maxLines: 5 },

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { toSavedViewSource } from '../toSavedViewSource';
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});

View File

@@ -0,0 +1,17 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { getSavedViewQuery } from './getSavedViewQuery';
export function getSavedViewPath(
pathname: string,
view: SavedviewtypesSavedViewDTO,
timeSearch: string,
): string {
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
[QueryParams.panelTypes]: view.spec.panelType,
[QueryParams.viewKey]: view.id,
});
return timeSearch ? `${path}&${timeSearch}` : path;
}

View File

@@ -1,4 +1,7 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
@@ -7,7 +10,6 @@ import {
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
columns: TelemetryFieldKey[] | undefined,
@@ -25,11 +27,11 @@ function withColumnNames(
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
sourcePage: SavedviewtypesSourceDTO,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {
if (sourcePage === SavedviewtypesSourceDTO.logs) {
return {
columns: ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
@@ -42,7 +44,7 @@ export function getViewColumnsAndFormatting(
},
};
}
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
return { columns: [] };

View File

@@ -1,7 +1,6 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { getSavedViewQuery } from './getSavedViewQuery';
@@ -47,6 +46,7 @@ function withoutEmptyValues(value: unknown): unknown {
// a graph in older views) drop out; the builder's shape gets different defaults.
export function hasUnsavedViewChanges({
view,
source,
stagedQuery,
panelType,
options,
@@ -76,18 +76,14 @@ export function hasUnsavedViewChanges({
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
);
const { columns, formatting } = getViewColumnsAndFormatting(view.spec, source);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
if (!formatting) {
return false;
}
return (

View File

@@ -1,23 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}

View File

@@ -11,6 +11,7 @@ import {
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
@@ -86,7 +87,7 @@ function ListView({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,

View File

@@ -1,64 +0,0 @@
import { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { TestRouter } from 'tests/router';
// jest.config.ts maps every `useSafeNavigate` import to a mock; the extension
// slips past that mapping.
const { useSafeNavigate } = jest.requireActual<
typeof import('hooks/useSafeNavigate')
>('../useSafeNavigate.ts');
function wrapper({ children }: { children: ReactNode }): JSX.Element {
return <TestRouter initialRoute="/page?a=1">{children}</TestRouter>;
}
describe('useSafeNavigate same-URL check', () => {
let pushState: jest.SpyInstance;
let replaceState: jest.SpyInstance;
beforeEach(() => {
pushState = jest.spyOn(window.history, 'pushState');
replaceState = jest.spyOn(window.history, 'replaceState');
});
afterEach(() => {
jest.restoreAllMocks();
});
function render(): ReturnType<typeof useSafeNavigate>['safeNavigate'] {
const { result } = renderHook(() => useSafeNavigate(), { wrapper });
pushState.mockClear();
replaceState.mockClear();
return result.current.safeNavigate;
}
it('navigates to a different URL', () => {
const safeNavigate = render();
safeNavigate('/page?a=2');
expect(pushState).toHaveBeenCalledTimes(1);
expect(window.location.search).toBe('?a=2');
});
it('skips the URL the router is on', () => {
const safeNavigate = render();
safeNavigate('/page?a=1');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
});
it('skips the URL a shallow write published ahead of the router', () => {
const safeNavigate = render();
// A nuqs write: it reaches the address bar but not the router.
window.history.replaceState(window.history.state, '', '/page?a=1&b=1');
replaceState.mockClear();
safeNavigate('/page?a=1&b=1');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,7 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useTimeSearchParams } from '../useTimeSearchParams';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
let mockGlobalTime = {
selectedTime: '30m',

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetCompositeQueryParam } from './useGetCompositeQueryParam';
@@ -18,19 +17,10 @@ export const useShareBuilderUrl = ({
}: UseShareBuilderUrlParams): void => {
const { resetQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const urlQuery = useUrlQuery();
const { pathname } = useAppLocation();
// The page that owns this query. A caller can keep rendering for a beat after
// a sibling pushes the next route (tab switches do this), and publishing then
// writes this page's query onto the URL of the page being entered.
const ownerPathname = useRef(pathname);
const compositeQuery = useGetCompositeQueryParam();
useEffect(() => {
if (pathname !== ownerPathname.current) {
return;
}
if (!compositeQuery || forceReset) {
resetQuery(defaultValue);
redirectWithQueryBuilderData(defaultValue);
@@ -42,6 +32,5 @@ export const useShareBuilderUrl = ({
compositeQuery,
resetQuery,
forceReset,
pathname,
]);
};

View File

@@ -12,12 +12,13 @@ import {
IOption,
IResourceAttribute,
IResourceAttributeProps,
OperatorValues,
Tags,
} from 'hooks/useResourceAttribute/types';
import { decode } from 'js-base64';
import { getCurrentLocation } from 'lib/router/navigation';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { OperatorValues, Tags } from 'hooks/useResourceAttribute/types';
import { v4 as uuid } from 'uuid';
import { whilelistedKeys } from './config';

View File

@@ -1,12 +1,11 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
interface NavigateOptions {
replace?: boolean;
state?: unknown;
state?: any;
newTab?: boolean;
}
@@ -109,12 +108,6 @@ export const useSafeNavigate = (
// oxlint-disable-next-line signoz/no-raw-absolute-path
const base = window.location.origin;
const currentUrl = new URL(`${location.pathname}${location.search}`, base);
// nuqs writes shallowly, so the address bar can be ahead of the router.
// Navigating to it anyway makes nuqs drop its queued writes.
const publishedUrl = new URL(
`${location.pathname}?${getUnstableCurrentSearchParams().toString()}`,
base,
);
let targetUrl: URL;
@@ -138,9 +131,7 @@ export const useSafeNavigate = (
return;
}
const urlsAreSame =
areUrlsEffectivelySame(currentUrl, targetUrl) ||
areUrlsEffectivelySame(publishedUrl, targetUrl);
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
if (preventSameUrlNavigation && urlsAreSame) {
@@ -153,9 +144,9 @@ export const useSafeNavigate = (
};
if (typeof to === 'string') {
void navigate(to, navigationOptions);
navigate(to, navigationOptions);
} else {
void navigate(
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,

View File

@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
/** Active time window as a query string (no leading `?`), or `''` when unset. */
export function useTimeSearchParams(): string {

View File

@@ -2,11 +2,10 @@ import { ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient } from 'react-query';
import AppProviders from 'app/AppProviders';
import { appRouter } from 'app/AppRouter';
import AppRoutes from 'AppRoutes';
import { AxiosError } from 'axios';
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppProvider } from 'providers/App/App';
import store from 'store';
import APIError from 'types/api/error';
@@ -62,7 +61,6 @@ if (container) {
store={store}
queryClient={queryClient}
appContext={appContext}
router={appRouter}
searchParams={searchParams}
>
<AppRoutes />

View File

@@ -6,6 +6,7 @@ export const SavedViewCreatePermission = buildPermission(
'create',
'saved-view:*',
);
export const SavedViewReadPermission = buildPermission('read', 'saved-view:*');
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `saved-view:${id}`);

View File

@@ -1,51 +1,10 @@
import {
type BrowserHistory,
createBrowserHistory,
createPath,
type Path,
type To,
} from 'history';
import type { HistoryRouterProps } from 'react-router';
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
// No `basename` here: history@5 dropped the option, so the base path lives on
// the router (`AppRoutes/index.tsx`) for component navigation and on
// `lib/router/navigation` for module-level navigation.
const browserHistory = createBrowserHistory();
function createURL(to: To): URL {
// window.location.origin is the literal string "null" in Firefox when the
// page is served from a local file.
// The raw origin, not getBaseUrl(): `to` already carries the base path when
// it has one, and this URL is only ever read back for its encoding.
// oxlint-disable-next-line signoz/no-raw-absolute-path
const origin = window.location.origin;
const base = origin !== 'null' ? origin : window.location.href;
const href = typeof to === 'string' ? to : createPath(to);
// A trailing space would be stripped by the URL parser, and it may be part
// of the path.
return new URL(href.replace(/ $/, '%20'), base);
}
/**
* v7 navigates through whatever history it is handed, and expects two members
* history@5 does not ship — `@remix-run/router` grew its own fork for them.
* We stay on history@5 because that fork dropped `block()`, which the
* unsaved-changes blocker still needs, so the two are added back here with the
* same implementations react-router would have used.
*/
// The cast covers one remaining difference: v7 declares `listen` with a
// `delta` on the update, which history@5 does not report and the component
// router never reads — it only forwards `action` and `location` into state.
const history = Object.assign(browserHistory, {
createURL,
encodeLocation(to: To): Path {
const url = createURL(to);
return { pathname: url.pathname, search: url.search, hash: url.hash };
},
}) as unknown as HistoryRouterProps['history'] & Pick<BrowserHistory, 'block'>;
const history = createBrowserHistory({ basename: getBasePath() });
let inAppPushCount = 0;
history.listen(({ action }) => {
history.listen((_, action) => {
if (action === 'PUSH') {
inAppPushCount += 1;
}

View File

@@ -1,8 +1,10 @@
import { forwardRef } from 'react';
import type { AnchorHTMLAttributes, ReactNode } from 'react';
import { Link } from 'react-router';
import { Link } from 'react-router-dom';
import type { To } from './types';
import { useAppLocation } from './useAppLocation';
import { withState } from './utils';
export interface AppLinkProps extends Omit<
AnchorHTMLAttributes<HTMLAnchorElement>,
@@ -16,8 +18,15 @@ export interface AppLinkProps extends Omit<
export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(
function AppLink({ to, replace, state, children, ...rest }, ref) {
const location = useAppLocation();
return (
<Link ref={ref} to={to} replace={replace} state={state} {...rest}>
<Link
ref={ref}
to={withState(to, state, location)}
replace={replace}
{...rest}
>
{children}
</Link>
);

View File

@@ -1,11 +1,12 @@
import { Navigate } from 'react-router';
import { Redirect as RouterRedirect } from 'react-router-dom';
import type { To } from './types';
import { withState } from './utils';
export interface RedirectProps {
to: To;
/**
* Defaults to `true`, matching v5's `<Redirect>`. v7's `<Navigate>` pushes
* Defaults to `true`, matching v5's `<Redirect>`. v6's `<Navigate>` pushes
* unless told otherwise, which is invisible until someone presses Back.
*/
replace?: boolean;
@@ -17,7 +18,7 @@ export function Redirect({
replace = true,
state,
}: RedirectProps): JSX.Element {
return <Navigate to={to} replace={replace} state={state} />;
return <RouterRedirect to={withState(to, state)} push={!replace} />;
}
Redirect.defaultProps = {

View File

@@ -1,14 +1,14 @@
import { createRef } from 'react';
import type { ReactNode } from 'react';
import { useHistory } from 'react-router-dom';
import { cleanup, fireEvent, render, screen } from 'tests/test-utils';
import { AppLink, type AppLinkProps } from '../AppLink';
import { useAppLocation } from '../useAppLocation';
import { useAppNavigationType } from '../useAppNavigationType';
function LocationProbe(): JSX.Element {
const location = useAppLocation();
const navigationType = useAppNavigationType();
const history = useHistory();
return (
<div>
@@ -16,7 +16,7 @@ function LocationProbe(): JSX.Element {
<span data-testid="search">{location.search}</span>
<span data-testid="hash">{location.hash}</span>
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
<span data-testid="action">{navigationType}</span>
<span data-testid="length">{history.length}</span>
</div>
);
}
@@ -146,23 +146,17 @@ describe('AppLink', () => {
expect(screen.getByTestId('state')).toHaveTextContent('{"from":"test"}');
});
it('pushes by default', () => {
renderLink({ to: '/logs', 'data-testid': 'link' } as AppLinkProps);
fireEvent.click(screen.getByTestId('link'));
expect(screen.getByTestId('action')).toHaveTextContent('PUSH');
});
it('replaces the entry when replace is set', () => {
renderLink({
to: '/logs',
replace: true,
'data-testid': 'link',
} as AppLinkProps);
const before = screen.getByTestId('length').textContent;
fireEvent.click(screen.getByTestId('link'));
expect(screen.getByTestId('pathname')).toHaveTextContent('/logs');
expect(screen.getByTestId('action')).toHaveTextContent('REPLACE');
expect(screen.getByTestId('length').textContent).toBe(before);
});
it('forwards a ref to the anchor', () => {

View File

@@ -1,19 +1,19 @@
import { useHistory } from 'react-router-dom';
import { render, screen } from 'tests/test-utils';
import { Redirect, type RedirectProps } from '../Redirect';
import { useAppLocation } from '../useAppLocation';
import { useAppNavigationType } from '../useAppNavigationType';
function LocationProbe(): JSX.Element {
const location = useAppLocation();
const navigationType = useAppNavigationType();
const history = useHistory();
return (
<div>
<span data-testid="pathname">{location.pathname}</span>
<span data-testid="search">{location.search}</span>
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
<span data-testid="action">{navigationType}</span>
<span data-testid="action">{history.action}</span>
</div>
);
}

View File

@@ -3,9 +3,8 @@ import ROUTES from 'constants/routes';
import { buildRoutePath } from '../buildRoutePath';
/**
* The encoding contract. v7's `generatePath` percent-encodes every param with
* `encodeURIComponent`, so `buildRoutePath` no longer encodes anything itself
* and these assertions are what pins the library's behaviour.
* These assertions pin every URL `buildRoutePath` produces, so a router
* upgrade cannot change one unnoticed.
*/
describe('buildRoutePath', () => {
it('fills a single param', () => {
@@ -46,15 +45,9 @@ describe('buildRoutePath', () => {
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: value })).toBe(expected);
});
it('percent-encodes a colon', () => {
it('leaves a colon alone', () => {
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: 'a:b' })).toBe(
'/trace/a%3Ab',
);
});
it('encodes a raw value rather than passing an encoded one through', () => {
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: 'a%2Fb' })).toBe(
'/trace/a%252Fb',
'/trace/a:b',
);
});
});

View File

@@ -21,17 +21,6 @@ function loadNavigation(baseHref?: string): NavigationModule {
return mod;
}
function nextUpdate(
nav: NavigationModule,
): Promise<{ location: { pathname: string }; action: string }> {
return new Promise((resolve) => {
const unsubscribe = nav.subscribe((update) => {
unsubscribe();
resolve(update);
});
});
}
function settle(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 60);
@@ -123,79 +112,23 @@ describe('basename ownership', () => {
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
it('round-trips getCurrentLocation through navigate', () => {
it('does not double-prefix a path that already carries the base path', () => {
const nav = loadNavigation('/signoz/');
nav.navigate('/logs');
nav.navigate(nav.getCurrentLocation().pathname);
expect(window.location.pathname).toBe('/signoz/logs');
});
it('prefixes a path whose first segment equals the base path', () => {
const nav = loadNavigation('/logs/');
nav.navigate('/logs/logs-explorer');
expect(window.location.pathname).toBe('/logs/logs/logs-explorer');
expect(nav.getCurrentLocation().pathname).toBe('/logs/logs-explorer');
});
it('prefixes the pathname of an object target', () => {
const nav = loadNavigation('/signoz/');
nav.navigate({ pathname: '/logs', search: '?a=1' });
expect(window.location.pathname).toBe('/signoz/logs');
expect(window.location.search).toBe('?a=1');
});
});
describe('subscribe', () => {
it('reports a single update object, not two arguments', async () => {
const nav = loadNavigation();
const listener = jest.fn();
const unsubscribe = nav.subscribe(listener);
nav.navigate('/logs');
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0]).toHaveLength(1);
expect(listener.mock.calls[0][0]).toMatchObject({
location: { pathname: '/logs' },
action: 'PUSH',
});
unsubscribe();
});
it('reports REPLACE', () => {
const nav = loadNavigation();
const listener = jest.fn();
const unsubscribe = nav.subscribe(listener);
nav.navigate('/logs', { replace: true });
expect(listener.mock.calls[0][0].action).toBe('REPLACE');
unsubscribe();
});
it('stops reporting after unsubscribe', () => {
const nav = loadNavigation();
const listener = jest.fn();
nav.subscribe(listener)();
nav.navigate('/logs');
expect(listener).not.toHaveBeenCalled();
});
});
describe('back', () => {
it('reports POP and restores the previous location', async () => {
it('restores the previous location', async () => {
const nav = loadNavigation();
nav.navigate('/logs');
nav.navigate('/traces');
const popped = nextUpdate(nav);
nav.back();
const update = await popped;
await settle();
expect(update.action).toBe('POP');
expect(update.location.pathname).toBe('/logs');
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
});
@@ -410,6 +343,25 @@ describe('blockNavigation', () => {
expect(window.location.pathname).toBe('/b');
});
it('catches the router up when history@4 does not revert a blocked POP', async () => {
const nav = loadNavigation();
nav.navigate('/p');
// nuqs pushes like this, so both entries carry the same history@4 key.
window.history.pushState(window.history.state, '', '/p?x=1');
window.history.back();
await settle();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.forward();
await settle();
unblock();
seen[0].retry();
expect(nav.getCurrentLocation().search).toBe('?x=1');
expect(window.location.search).toBe('?x=1');
});
it('keeps the blocker armed when retry runs inside it', () => {
const nav = loadNavigation();
let allowNext = true;

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { TestRouter } from 'tests/router';
import { act, renderHook } from 'tests/test-utils';
import { renderHook } from 'tests/test-utils';
import { navigate } from '../navigation';
import { useAppLocation } from '../useAppLocation';
@@ -25,10 +25,10 @@ describe('useAppLocation', () => {
});
it('returns the entry state', () => {
navigate('/logs', { replace: true, state: { from: 'test' } });
const { result } = renderHook(() => useAppLocation<{ from: string }>(), {
wrapper: TestRouter,
});
act(() => navigate('/logs', { replace: true, state: { from: 'test' } }));
expect(result.current.state.from).toBe('test');
});

View File

@@ -1,9 +1,9 @@
import { useHistory } from 'react-router-dom';
import { fireEvent, render, screen } from 'tests/test-utils';
import type { NavigateOptions, To } from '../types';
import { useAppLocation } from '../useAppLocation';
import { useAppNavigate } from '../useAppNavigate';
import { useAppNavigationType } from '../useAppNavigationType';
function Probe({
to,
@@ -14,7 +14,7 @@ function Probe({
}): JSX.Element {
const navigate = useAppNavigate();
const location = useAppLocation();
const navigationType = useAppNavigationType();
const history = useHistory();
return (
<div>
@@ -27,7 +27,7 @@ function Probe({
<span data-testid="pathname">{location.pathname}</span>
<span data-testid="search">{location.search}</span>
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
<span data-testid="action">{navigationType}</span>
<span data-testid="length">{history.length}</span>
</div>
);
}
@@ -62,16 +62,20 @@ describe('useAppNavigate', () => {
it('adds a history entry by default', () => {
renderProbe('/logs');
const before = screen.getByTestId('length').textContent;
fireEvent.click(screen.getByTestId('navigate'));
expect(screen.getByTestId('action')).toHaveTextContent('PUSH');
expect(Number(screen.getByTestId('length').textContent)).toBe(
Number(before) + 1,
);
});
it('does not add a history entry when replacing', () => {
renderProbe('/logs', { replace: true });
const before = screen.getByTestId('length').textContent;
fireEvent.click(screen.getByTestId('navigate'));
expect(screen.getByTestId('pathname')).toHaveTextContent('/logs');
expect(screen.getByTestId('action')).toHaveTextContent('REPLACE');
expect(screen.getByTestId('length').textContent).toBe(before);
});
});

View File

@@ -1,14 +1,22 @@
import type { NavigableHistory } from '../types';
import { applyNavigate } from '../utils';
import type { History } from 'history';
import { applyNavigate, retryTransition, withState } from '../utils';
function fakeHistory(): {
history: NavigableHistory;
history: History;
push: jest.Mock;
replace: jest.Mock;
go: jest.Mock;
} {
const push = jest.fn();
const replace = jest.fn();
return { history: { push, replace }, push, replace };
const go = jest.fn();
return {
history: { push, replace, go } as unknown as History,
push,
replace,
go,
};
}
describe('applyNavigate', () => {
@@ -36,13 +44,11 @@ describe('applyNavigate', () => {
expect(push).toHaveBeenCalledWith('/logs', { from: '/home' });
});
it('attaches state to an object target as its own argument', () => {
it('attaches state to an object target', () => {
const { history, push } = fakeHistory();
applyNavigate(history, { pathname: '/logs' }, { state: { from: '/home' } });
// Not `{ pathname, state }`: history@5's `getNextLocation` overwrites a
// `state` key inside the target with the second argument.
expect(push).toHaveBeenCalledWith({ pathname: '/logs' }, { from: '/home' });
});
@@ -63,3 +69,78 @@ describe('applyNavigate', () => {
expect(replace).toHaveBeenCalledWith('/logs', { a: 1 });
});
});
describe('retryTransition', () => {
const location = { pathname: '/logs', search: '', hash: '', state: undefined };
it('walks a blocked POP by its distance', () => {
const { history, go } = fakeHistory();
retryTransition(history, location, 'POP', 2);
expect(go).toHaveBeenCalledWith(2);
});
it('does not reload when a blocked POP has no distance', () => {
const { history, go } = fakeHistory();
retryTransition(history, location, 'POP', 0);
expect(go).not.toHaveBeenCalled();
});
it('settles on the carried location when a blocked POP has an unknown distance', () => {
const { history, go, replace } = fakeHistory();
retryTransition(history, location, 'POP', null);
expect(go).not.toHaveBeenCalled();
expect(replace).toHaveBeenCalledWith(location);
});
it('replays PUSH and REPLACE from the carried location', () => {
const { history, push, replace } = fakeHistory();
retryTransition(history, location, 'PUSH', 0);
retryTransition(history, location, 'REPLACE', 0);
expect(push).toHaveBeenCalledWith(location);
expect(replace).toHaveBeenCalledWith(location);
});
});
describe('withState', () => {
it('returns the target untouched when there is no state', () => {
expect(withState('/logs?a=1', undefined)).toBe('/logs?a=1');
});
it('splits a string target before attaching state', () => {
expect(withState('/logs?a=1#top', { from: '/home' })).toStrictEqual({
pathname: '/logs',
search: '?a=1',
hash: '#top',
state: { from: '/home' },
});
});
it('resolves a search-only string against the current location', () => {
expect(
withState(
'?tab=2',
{ from: '/home' },
{ pathname: '/x/y', search: '?q=1', hash: '', state: null },
),
).toMatchObject({
pathname: '/x/y',
search: '?tab=2',
state: { from: '/home' },
});
});
it('attaches state to an object target', () => {
expect(withState({ pathname: '/logs' }, { from: '/home' })).toStrictEqual({
pathname: '/logs',
state: { from: '/home' },
});
});
});

View File

@@ -1,31 +1,16 @@
import { generatePath } from 'react-router';
import { generatePath } from 'react-router-dom';
/**
* Fills `:param` segments in a route pattern.
*
* Param values are passed raw and come back percent-encoded. `generatePath`
* runs each one through `encodeURIComponent`, so a value can never introduce
* extra path segments or a query string. A value that is already encoded gets
* encoded twice: read params back with `matchRoute` / `useAppParams`, which
* un-escape only `%2F`, and decode before passing one here.
*
* v7's `generatePath` types params as `string | null`, so non-strings are
* stringified here. `undefined` values are dropped, which leaves an optional
* segment out and makes a required one throw.
* Param values are URI-encoded. `/`, `?` and `#` come back percent-encoded, so
* a value can never introduce extra path segments or a query string. v5's
* `generatePath` does this via path-to-regexp; v7's runs `encodeURIComponent`,
* which also encodes `:`.
*/
export function buildRoutePath(
pattern: string,
params?: Record<string, string | number | boolean | undefined>,
): string {
if (params === undefined) {
return generatePath(pattern);
}
const stringified = Object.fromEntries(
Object.entries(params)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [key, String(value)]),
);
return generatePath(pattern, stringified);
return generatePath(pattern, params);
}

View File

@@ -1,10 +1,10 @@
import { matchPath } from 'react-router';
import { matchPath } from 'react-router-dom';
import type { AppParams, RouteMatch } from './types';
interface MatchRouteOptions {
/**
* Defaults to `false`, matching v5's string form. v7's `end` defaults to
* Defaults to `false`, matching v5's string form. v6's `end` defaults to
* `true`, so callers that pass no options rely on this default and would
* silently flip to exact matching without it.
*/
@@ -18,22 +18,29 @@ export function matchRoute<Key extends string = string>(
): RouteMatch<Key> | null {
const end = options.exact ?? false;
// v7 un-escapes `%2F` in param values itself (and nothing else), which is
// what keeps `buildRoutePath` -> `matchRoute` a round trip for a value
// containing a slash.
const match = matchPath(
{ path: pattern, end, caseSensitive: false },
pathname,
);
const match = matchPath<Record<string, string | undefined>>(pathname, {
path: pattern,
exact: end,
});
if (match === null) {
return null;
}
// v6.30 un-escapes `%2F` in param values (and nothing else); v5 hands them
// back raw. Doing it here keeps `buildRoutePath` -> `matchRoute` a round trip
// for values containing a slash, on both versions.
const params = Object.fromEntries(
Object.entries(match.params).map(([key, value]) => [
key,
value?.replace(/%2F/g, '/'),
]),
);
return {
params: match.params as AppParams<Key>,
pathname: match.pathname,
pathnameBase: match.pathnameBase,
params: params as AppParams<Key>,
pathname: match.url,
pathnameBase: match.url,
pattern: { path: pattern, caseSensitive: false, end },
};
}

View File

@@ -1,5 +1,4 @@
import history from 'lib/history';
import { getBasePath, stripBasePath } from 'utils/basePath';
import type {
AppLocation,
@@ -8,7 +7,29 @@ import type {
NavigationBlocker,
To,
} from './types';
import { applyNavigate, toAppLocation } from './utils';
import { applyNavigate, retryTransition, toAppLocation } from './utils';
let entryKeys: Array<string | undefined> = [history.location.key];
let currentKey = history.location.key;
// history@4 reports a blocked POP's target but not how far away it is, and the
// POP is already reverted by the time `retry` runs. Mirroring its entry keys
// gives the distance back. Like history@4, an entry it never saw (one from
// before a reload) counts as the first one, so a POP whose ends both land on
// index 0 is not reverted.
history.listen((location, action) => {
const index = entryKeys.indexOf(currentKey);
currentKey = location.key;
if (action === 'PUSH') {
entryKeys = [...entryKeys.slice(0, index + 1), location.key];
} else if (action === 'REPLACE' && index !== -1) {
entryKeys[index] = location.key;
}
});
function entryIndex(key: string | undefined): number {
return Math.max(0, entryKeys.indexOf(key));
}
/**
* Imperative navigation on the app's history singleton, usable from any code.
@@ -16,104 +37,49 @@ import { applyNavigate, toAppLocation } from './utils';
* `useSafeNavigate` it does not suppress same-URL navigation and has no new-tab
* branch, because it cannot see the current render.
*
* This module owns the base path. history@5 has no `basename` option, so the
* router carries it for component navigation and never sees what goes through
* here: callers pass and receive basename-free app paths, and the prepend on
* write and the strip on read happen below.
* Callers pass and receive basename-free app paths.
*/
function toBrowserTarget(to: To): To {
if (typeof to === 'string') {
return toBrowserPath(to);
}
return to.pathname === undefined
? to
: { ...to, pathname: toBrowserPath(to.pathname) };
}
// Unconditional, like the router's basename. `withBasePath` skips a path that
// already starts with the base path, which misses an app path whose first
// segment is the base path's name.
function toBrowserPath(path: string): string {
const basePath = getBasePath();
return basePath === '/' || !path.startsWith('/')
? path
: `${basePath}${path.slice(1)}`;
}
function toAppPath<S>(location: AppLocation<S>): AppLocation<S> {
return { ...location, pathname: stripBasePath(location.pathname) };
}
export function navigate(to: To, options?: NavigateOptions): void {
applyNavigate(history, toBrowserTarget(to), options);
applyNavigate(history, to, options);
}
export function back(): void {
history.go(-1);
history.goBack();
}
export function getCurrentLocation(): AppLocation {
return toAppPath(toAppLocation(history.location));
}
export function subscribe(
listener: (update: {
location: AppLocation;
action: NavigationAction;
}) => void,
): () => void {
return history.listen(({ location, action }) => {
listener({
location: toAppPath(toAppLocation(location)),
action: action as NavigationAction,
});
});
return toAppLocation(history.location);
}
export function blockNavigation(blocker: NavigationBlocker): () => void {
let stopWaiting: (() => void) | null = null;
let unblock = register();
return history.block((location, action) => {
const appLocation = toAppLocation(location);
const navigationAction = action as NavigationAction;
const delta = entryIndex(location.key) - entryIndex(currentKey);
const popDelta = delta === 0 ? null : delta;
let deciding = true;
let allowed = false;
function register(): () => void {
return history.block((transition) => {
let deciding = true;
let allowed = false;
blocker({
location: toAppPath(toAppLocation(transition.location)),
action: transition.action as NavigationAction,
retry: () => {
if (deciding) {
allowed = true;
return;
}
transition.retry();
},
});
deciding = false;
if (!allowed) {
return;
}
// history@5 cancels every transition while a blocker is registered, so
// letting one through means retrying it unblocked. A retried POP lands
// on a later popstate, so the blocker comes back once the transition
// has, not straight after `retry()`.
unblock();
stopWaiting = history.listen(() => {
stopWaiting?.();
stopWaiting = null;
unblock = register();
});
transition.retry();
blocker({
location: appLocation,
action: navigationAction,
retry: () => {
if (deciding) {
allowed = true;
return;
}
retryTransition(history, appLocation, navigationAction, popDelta);
},
});
}
deciding = false;
return (): void => {
stopWaiting?.();
stopWaiting = null;
unblock();
};
// history@4 cancels the transition when the prompt returns `false`;
// history@5 cancels whenever a blocker is registered and hands control to
// `retry()`. A `retry()` inside the blocker means "let it through", and
// history@4 does that by not cancelling: re-issuing a POP would walk the
// stack twice.
return allowed ? undefined : false;
});
}
export { hasInAppHistory } from 'lib/history';

View File

@@ -1,6 +1,7 @@
/**
* Facade contracts for the router. Every shape here is defined the way
* react-router v7 / history@5 behaves.
* react-router v6+ / history@5 behaves; the v5-backed implementations adapt up
* to it.
*/
export type NavigationAction = 'PUSH' | 'REPLACE' | 'POP';
@@ -10,7 +11,7 @@ export interface AppLocation<S = unknown> {
search: string;
hash: string;
state: S;
/** Always set by v7; optional for callers that build a location by hand. */
/** history@4 leaves this undefined on the initial entry; v6 always sets it. */
key?: string;
}
@@ -23,7 +24,7 @@ export type AppParams<Key extends string = string> = {
};
/**
* Mirrors v7's `useParams` return type so both call shapes keep working:
* Mirrors v6's `useParams` return type so both call shapes keep working:
* a union of param names (`useAppParams<'dashboardId'>()`) or a record
* (`useAppParams<{ dashboardId: string }>()`).
*/
@@ -57,15 +58,6 @@ export interface NavigateOptions {
export type NavigateFn = (to: To, options?: NavigateOptions) => void;
/**
* The slice of a history object `applyNavigate` needs, so the adapter does not
* have to name a particular history package.
*/
export interface NavigableHistory {
push(to: To, state?: unknown): void;
replace(to: To, state?: unknown): void;
}
export interface BlockedTransition {
location: AppLocation;
action: NavigationAction;

View File

@@ -1,7 +1,7 @@
import { useLocation } from 'react-router';
import { useLocation } from 'react-router-dom';
import type { AppLocation } from './types';
export function useAppLocation<S = unknown>(): AppLocation<S> {
return useLocation() as AppLocation<S>;
return useLocation<S>();
}

View File

@@ -1,20 +1,21 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useHistory } from 'react-router-dom';
import type { NavigateFn, NavigateOptions, To } from './types';
import { applyNavigate } from './utils';
/**
* Hook-context imperative navigation, for the call sites that only push or
* replace. Prefer `useSafeNavigate` when the call site wants same-URL
* Hook-context imperative navigation, for `useHistory()` call sites that only
* push or replace. Prefer `useSafeNavigate` when the call site wants same-URL
* suppression or new-tab handling.
*/
export function useAppNavigate(): NavigateFn {
const navigate = useNavigate();
const history = useHistory();
return useCallback(
(to: To, options?: NavigateOptions): void => {
navigate(to, { replace: options?.replace, state: options?.state });
applyNavigate(history, to, options);
},
[navigate],
[history],
);
}

View File

@@ -1,10 +1,10 @@
import { useNavigationType } from 'react-router';
import { useNavigationType } from 'react-router-dom-v5-compat';
import type { NavigationAction } from './types';
/**
* The action that produced the current location (`POP` for browser back and
* forward).
* forward). v6 only; v5 exposes this as `history.action`.
*/
export function useAppNavigationType(): NavigationAction {
return useNavigationType();

View File

@@ -1,9 +1,10 @@
import { useParams } from 'react-router';
import { useParams } from 'react-router-dom';
import type { AppParamsResult } from './types';
/**
* Params are typed `string | undefined`, which is what v7 returns.
* Types every param `string | undefined`, as v6 does. v5 types them as required
* strings.
*/
export function useAppParams<
ParamsOrKey extends string | Record<string, string | undefined> = string,

View File

@@ -1,7 +1,8 @@
import { useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router-dom-v5-compat';
/**
* Its setter navigates through the router.
* v6's `useSearchParams`, which has no v5 equivalent. Its setter navigates
* through the router.
*/
export function useAppSearchParams(): ReturnType<typeof useSearchParams> {
return useSearchParams();

View File

@@ -1,40 +1,65 @@
import type { History, Location, LocationDescriptor } from 'history';
import { parsePath } from 'history';
import type {
AppLocation,
NavigableHistory,
NavigateOptions,
NavigationAction,
To,
} from './types';
export function toAppLocation<S = unknown>(
location: AppLocation<unknown>,
location: Location<S>,
): AppLocation<S> {
return {
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: location.state as S,
state: location.state,
key: location.key,
};
}
/**
* history@5 takes `state` as its own argument for both the string and the
* object target form. A `state` key inside the target object is overwritten
* with `null` by `getNextLocation`, so it cannot be smuggled in there.
*
* `state` is omitted rather than passed as `undefined`, so a stateless
* `navigate(path)` reaches the history as the bare `push(path)` the call sites
* used before the facade, which is what the suites that mock `lib/history`
* assert on.
* v5 carries state inside `to`. The string form is parsed first, or search and
* hash would land in `pathname`. Pass `currentLocation` where the target
* renders an href, so a search-only or hash-only string keeps the current
* pathname as it does without state; `history.push` resolves it on its own.
* Not `createLocation`: its `decodeURI` runs again when the link is followed.
*/
export function withState(
to: To,
state: unknown,
currentLocation?: AppLocation,
): LocationDescriptor {
if (state === undefined) {
return to;
}
if (typeof to === 'string') {
const parsed = parsePath(to);
return {
...parsed,
pathname: parsed.pathname || currentLocation?.pathname || '',
state,
};
}
return { ...to, state };
}
/**
* `state` always travels as the second argument, which history@4 and history@5
* both honour for string and object targets. It is omitted rather than passed
* as `undefined`, so a stateless `navigate(path)` reaches the history as the
* bare `push(path)` the call sites used before the facade.
*/
export function applyNavigate(
history: NavigableHistory,
history: History,
to: To,
options?: NavigateOptions,
): void {
const { replace = false, state } = options ?? {};
const args: Parameters<NavigableHistory['push']> =
const args: Parameters<History['push']> =
state === undefined ? [to] : [to, state];
if (replace) {
@@ -43,3 +68,31 @@ export function applyNavigate(
history.push(...args);
}
}
/**
* Replays a transition that a blocker cancelled. history@4 discards the
* transition on cancel, so it has to be re-issued from the location it carried.
* `popDelta` walks the browser from where history@4 reverted the blocked POP
* back to its target. It is `null` when history@4 did not revert, so the
* browser is already on the target and only the router has to catch up.
*/
export function retryTransition(
history: History,
location: AppLocation,
action: NavigationAction,
popDelta: number | null,
): void {
if (action === 'POP') {
if (popDelta === null) {
history.replace(location);
} else if (popDelta !== 0) {
history.go(popDelta);
}
return;
}
if (action === 'REPLACE') {
history.replace(location);
return;
}
history.push(location);
}

View File

@@ -9,7 +9,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from './useTimeSearchParams';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import logEvent from '@/api/common/logEvent';
import { DashboardDetailEvents } from '../../constants/events';

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