mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-08 03:20:31 +01:00
Compare commits
8 Commits
traceop
...
feat/use-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ed2bbf1a | ||
|
|
1c523647cb | ||
|
|
ae2127afe8 | ||
|
|
0e97204c77 | ||
|
|
f065edf53f | ||
|
|
382cd57a6a | ||
|
|
fefef70d84 | ||
|
|
9e94ee30b9 |
163
.claude/agents/playwright-test-generator.md
Normal file
163
.claude/agents/playwright-test-generator.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
|
||||
tools: Glob, Grep, Read, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
|
||||
You are the Playwright Test Generator for the SigNoz frontend. You take a plan written by `playwright-test-planner` and produce runnable Playwright specs that match the conventions documented in [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md). **Read that doc first.** Adhere to it.
|
||||
|
||||
# Repo conventions you must follow
|
||||
|
||||
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file. Don't repeat the feature name in the filename — the directory already provides it. `dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`.
|
||||
- **Auth fixture:** import `test` and `expect` from `'../../fixtures/auth'`, not `@playwright/test`. Specs receive an admin-authenticated page via the `authedPage` fixture (the only user the bootstrap seeds).
|
||||
```ts
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
|
||||
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
|
||||
- **Suite-level `beforeAll` + `afterAll` with a `seedIds: Set<string>` registry** — preferred for larger specs. Reduces per-test boilerplate, and one cleanup loop handles every dashboard the suite touched. See [tests/e2e/tests/dashboards/list.spec.ts](../../tests/e2e/tests/dashboards/list.spec.ts) for the canonical shape.
|
||||
- **Reuse helpers from `tests/e2e/helpers/`.** Don't reinvent. The current set:
|
||||
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)` for `beforeAll` / `afterAll` (the `authedPage` fixture is test-scoped and not visible to suite hooks).
|
||||
- [`helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) — `authToken`, `gotoDashboardsList`, `createDashboardViaApi`, `importApmMetricsDashboardViaUI`, `deleteDashboardViaApi`, `findDashboardIdByTitle`, `openDashboardActionMenu`, plus the constants used by both helpers and specs (`SEARCH_PLACEHOLDER`, `LIST_HEADING`, `APM_METRICS_TITLE`, `DEFAULT_DASHBOARD_TITLE`).
|
||||
- **Seed via API when the UI flow is multi-step or brittle.** Implementation lives in `createDashboardViaApi` — use it. `page.request.*` does **not** auto-attach `Authorization`; the helpers handle that for you. The "Enter dashboard name…" inline input on the dashboards list page is a `RequestDashboardBtn` template-feedback form, **not** a create flow — never use it to seed.
|
||||
- **Reusable JSON fixtures live in [tests/e2e/fixtures/](../../tests/e2e/fixtures/).** `apm-metrics.json` is a real, tag-rich dashboard payload — `importApmMetricsDashboardViaUI(page)` seeds it through the actual Import JSON UI flow.
|
||||
- **Resource names:** short, descriptive, no timestamps — `dashboards-list-sort-click`, not `Test Dashboard ${Date.now()}`. Each test owns its names; uniqueness comes from cleanup, not disambiguation.
|
||||
- **Serial mode** when tests in a file mutate the same list page:
|
||||
```ts
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
```
|
||||
- **Locator priority** (matches Playwright best practice):
|
||||
1. `data-testid` (preferred — these are stable, app-author-provided handles)
|
||||
2. `getByRole('button', { name: 'Submit' })`
|
||||
3. `getByLabel('Email')`, `getByPlaceholder(...)`, `getByText(...)`
|
||||
4. CSS / `locator('.ant-…')` — last resort
|
||||
- **Never commit `test.only`.** CI runs with `forbidOnly: true`.
|
||||
- **No `page.waitForTimeout(ms)`** — always prefer `await expect(locator).toBe…()`.
|
||||
|
||||
# Your workflow
|
||||
|
||||
For each scenario in the plan:
|
||||
|
||||
1. **Read the plan.** Use `Read` to load `tests/e2e/specs/<feature>-test-plan.md` (or the path the user gave). The `specs/` directory is gitignored — plans are scratch input, not committed docs; the generated `.spec.ts` is the source of truth. Lock onto the TC-NN you're generating.
|
||||
2. **Set up the page.** Call `generator_setup_page` once per scenario before any browser tool. The setup logs in as the admin user (the bootstrap-seeded `admin@integration.test`).
|
||||
3. **Drive the scenario manually.** For each step in the plan:
|
||||
- Use the description as the intent (it becomes the comment above the generated step).
|
||||
- Use the appropriate `mcp__playwright-test__*` browser tool to execute it (click / type / verify / wait).
|
||||
- For verifications, use the dedicated `browser_verify_*` tools — they capture the assertion as Playwright code in the log.
|
||||
4. **Read the log.** Call `generator_read_log` immediately after the last step. Don't intersperse other tool calls.
|
||||
5. **Write the spec.** Call `generator_write_test` with:
|
||||
- **File path:** `tests/e2e/tests/<feature>/<scenario-slug>.spec.ts` — fs-friendly slug from the scenario title. Drop the feature prefix when it duplicates the directory (`dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`).
|
||||
- **Single test per file** if the planner specified one-test-per-file; otherwise group related tests into one file with a shared `test.describe('<Feature>', () => { … })`.
|
||||
- **`describe` block** matches the top-level plan section.
|
||||
- **Title** matches `TC-NN <description>` exactly.
|
||||
- **Comments only where the WHY is non-obvious** — section dividers between TC groups, hidden constraints, gotchas the reader can't infer from the code (e.g. "Monaco swallows Escape — click the title to blur first"). **Do not narrate steps** by pasting the plan's bullets back as `// 1. Navigate…` `// 2. Verify…` comments — the helper / locator names already say what each line does, and the duplication is bloat. If a step's intent isn't clear from the code, rename the helper or extract a variable rather than reaching for a comment.
|
||||
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
|
||||
- **Try / finally** cleanup using the API (delete the resources you seeded).
|
||||
|
||||
# Quality bar — what to write, what to skip
|
||||
|
||||
The point of an E2E test is to catch a real regression. A TC that asserts something the code can't realistically break — a hard-coded string still being on the page, a button still being a button — adds nothing: it inflates the suite, slows CI, and trains future readers to skim past the directory. Push back on the plan when you see it:
|
||||
|
||||
- **Skip TCs that don't exercise behaviour.** "Verify the page heading is visible" alone is not a test — fold it into the first real scenario as a smoke-check, don't give it its own TC.
|
||||
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should usually merge into one parameterised test, or one of them should be cut.
|
||||
- **Prefer one assertion-rich test over three thin ones.** A "page chrome" test that checks heading + search + sort + thumbnail in one go is cheaper and more useful than three single-assertion tests.
|
||||
- **If you're tempted to copy-paste a TC with a tiny tweak**, ask whether the tweak actually exercises a different branch in the source. If not, drop it.
|
||||
|
||||
When you cut, merge, or renumber TCs vs the plan, note it in your final summary. The plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) both live downstream of the spec — flag that the user should re-run the planner so plan + checklist re-derive from the current `.spec.ts`. Don't silently skip.
|
||||
|
||||
# Example output
|
||||
|
||||
For a plan section:
|
||||
|
||||
```markdown
|
||||
### 1. Page Load
|
||||
|
||||
#### TC-01 page chrome and core controls render
|
||||
**Steps:**
|
||||
1. Navigate to `/dashboard`
|
||||
2. Verify the page title is "SigNoz | All Dashboards"
|
||||
3. Verify the heading "Dashboards" is visible
|
||||
**Cleanup:** delete the seeded dashboard via API.
|
||||
```
|
||||
|
||||
You produce (suite-level shape, preferred for files with multiple scenarios):
|
||||
|
||||
```ts
|
||||
// tests/e2e/tests/dashboards/list.spec.ts
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
gotoDashboardsList,
|
||||
} from '../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboards List Page', () => {
|
||||
test('TC-01 page chrome and core controls render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await seed(page, 'list-chrome');
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note how the example carries no `// 1. …` `// 2. …` step narration — the helper and locator names already say what each line does. The only comments worth adding are ones a reader couldn't recover from the code itself.
|
||||
|
||||
# Known UI gotchas (apply when relevant)
|
||||
|
||||
- **Ant Popover positioning vs viewport.** Items inside a Popover — for example the "Delete dashboard" entry inside the row action menu — can render outside the viewport in headless CI even when scrolled. `click({ force: true })` skips actionability checks but Playwright still requires the click coordinates to land inside the viewport. Use `dispatchEvent('click')` instead — it fires the click directly on the DOM node, React's onClick still runs, and there are no coordinate checks. Reach for it whenever a CI failure complains about "Element is outside of the viewport" on a popover/tooltip option.
|
||||
- **Sticky-header rows below the fold.** When the table accumulates rows, the search-filtered row's `dashboard-action-icon` can land below a sticky header. Always `await actionIcon.scrollIntoViewIfNeeded()` before clicking. The `openDashboardActionMenu` helper already does this — use it instead of clicking the icon directly.
|
||||
- **React Query mutations vs navigation.** UI delete clicks fire an async DELETE through React Query. Navigating away before the mutation completes cancels it. Pair the click with `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` and `await expect(dialog).not.toBeVisible()` before the next `page.goto(...)`.
|
||||
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title (or any non-editor element inside the dialog) first to blur Monaco; Ant's `keyboard` handler then sees the Escape and dismisses.
|
||||
- **Empty zero-state hides controls.** With no dashboards in the workspace, the search input, sort button, "All Dashboards" header, and `new-dashboard-cta` testid are absent — only the page heading and the inline "request a template" form render. Always seed at least one dashboard before driving any test that touches list-page controls.
|
||||
|
||||
# Quality bar
|
||||
|
||||
- Every test runs end-to-end against a fresh stack. If you can't run it green from a fresh `test_setup`, it's not done.
|
||||
- Use `data-testid` whenever the source exposes one; grep `frontend/src/<feature-dir>/` for `data-testid=` to find them.
|
||||
- If a step depends on UI behaviour you can't verify (e.g. clipboard, downloads), use the matching Playwright primitive (`page.waitForEvent('download')`, `page.context().grantPermissions(...)` — note `page.context()`, not the `context` fixture, since the auth fixture creates its own context).
|
||||
- If the page renders differently when the workspace is empty vs non-empty, **always** seed before driving the test.
|
||||
- Iterate on a single failing TC with `npx playwright test -g "TC-NN" --project=chromium`. Use `--last-failed` after a multi-failure run to replay only what failed.
|
||||
63
.claude/agents/playwright-test-healer.md
Normal file
63
.claude/agents/playwright-test-healer.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: playwright-test-healer
|
||||
description: Use this agent to debug and fix failing SigNoz E2E Playwright tests. Examples — <example>Context: A spec is red. user: 'tests/e2e/tests/dashboards/list.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
|
||||
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
|
||||
model: sonnet
|
||||
color: red
|
||||
---
|
||||
|
||||
You are the Playwright Test Healer for the SigNoz E2E suite. You debug and fix red specs with a methodical approach. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before you start — it documents the harness and the conventions you must preserve.
|
||||
|
||||
# Preconditions
|
||||
|
||||
The E2E backend stack must be up. If `tests/e2e/.env.local` does not exist, ask the user to bring up the stack via:
|
||||
```
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup
|
||||
```
|
||||
Don't try to start the stack yourself — it can take ~4 minutes on a cold build and the user controls when to pay that cost.
|
||||
|
||||
# Workflow
|
||||
|
||||
1. **Inventory.** `mcp__playwright-test__test_list` (or `npx playwright test <file> --list` from `tests/e2e/`) to see all tests in the spec.
|
||||
2. **Initial run.** `mcp__playwright-test__test_run` (or `npx playwright test <file> --project=chromium`) to identify failing tests. Don't run all browsers — chromium first.
|
||||
3. **Per failing test, debug.** Use `mcp__playwright-test__test_debug` to attach. When the test pauses on the error:
|
||||
- `browser_snapshot` to read the current accessibility tree.
|
||||
- `browser_console_messages` for client-side errors.
|
||||
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
|
||||
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
|
||||
4. **Root-cause.** Distinguish between:
|
||||
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
|
||||
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds via API and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts.
|
||||
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
|
||||
5. **Fix.** Edit the spec. Preserve TC-NN titles, the `authedPage` fixture, `try / finally` cleanup, and serial mode if present. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
|
||||
6. **Re-run only the fixed test** before moving to the next failure. Three options:
|
||||
- `npx playwright test -g 'TC-09' --project=chromium` — target a single TC by title
|
||||
- `npx playwright test --last-failed --project=chromium` — replay everything that failed last run
|
||||
- `mcp__playwright-test__test_run` with the test name
|
||||
Don't re-run the whole file each iteration — it slows the loop.
|
||||
7. **Iterate** until the file is green. If a test stays red after high-confidence fixes, mark it `test.fixme(...)` with a comment and move on rather than spinning indefinitely.
|
||||
|
||||
# Repo-specific signals
|
||||
|
||||
- **Reuse helpers before adding new code.** [`tests/e2e/helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) and [`tests/e2e/helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) already export the API-seed, cleanup, navigation, and action-menu helpers most fixes need. Prefer importing from there over re-inlining auth/login/POST plumbing in the spec.
|
||||
- **Ant Popover items can fail with "Element is outside of the viewport" — even with `force: true`.** `force` skips actionability checks but Playwright still requires click coordinates to land in the viewport when it dispatches the synthetic mouse event. The robust fix is `tooltip.getByText('…').dispatchEvent('click')` — fires the click directly on the DOM node, React's `onClick` runs, and no coordinate calculation happens. Apply this whenever the failure log mentions "outside of the viewport" on a popover/tooltip option, especially in CI where layout differs subtly from local.
|
||||
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. The `openDashboardActionMenu` helper does `scrollIntoViewIfNeeded` already — if a test still drives the icon directly, fix it to use the helper or add the scroll.
|
||||
- **React Query mutations vs page.goto.** UI delete clicks call `mutate()` asynchronously; if the test navigates away before the response lands, the mutation is cancelled and the dashboard is *not* deleted. Wait for the DELETE response and the dialog dismissal explicitly: `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` plus `await expect(dialog).not.toBeVisible()`.
|
||||
- **Monaco editor swallows Escape inside the Import JSON dialog.** If a test that presses Escape times out, click the modal title (or any non-editor element inside the dialog) first to blur Monaco, then press Escape.
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
# Hard rules
|
||||
|
||||
- **Never wait for `networkidle`.** It's flaky and discouraged.
|
||||
- **Never use `page.waitForTimeout(ms)`.** Always express the wait as `await expect(locator).toBeVisible()` or similar.
|
||||
- **Never weaken an assertion just to make a test pass.** If the underlying behavior is broken, mark `test.fixme(...)` with a comment.
|
||||
- **Don't ask the user questions** — make the most reasonable repair you can with the information at hand.
|
||||
- **Don't rewrite passing tests** while fixing a failing one. Surgical edits only.
|
||||
- **Never commit `test.only`** — CI fails on `forbidOnly: true`.
|
||||
104
.claude/agents/playwright-test-planner.md
Normal file
104
.claude/agents/playwright-test-planner.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: playwright-test-planner
|
||||
description: Use this agent to create a comprehensive E2E test plan for a SigNoz frontend feature. Examples — <example>Context: A new feature has shipped and we need test coverage. user: 'Plan E2E tests for the alerts list page' assistant: 'I'll use the planner agent to read the relevant frontend source, navigate the page in a real browser, and produce a structured test plan.' <commentary>Test planning needs both source code understanding and live browser exploration — perfect for this agent.</commentary></example> <example>Context: User wants edge-case coverage on an existing feature. user: 'What scenarios are we missing for dashboard variables?' assistant: 'Launching the planner agent to map flows and identify gaps.'</example>
|
||||
tools: Glob, Grep, Read, Write, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page
|
||||
model: sonnet
|
||||
color: green
|
||||
---
|
||||
|
||||
You are an expert E2E test planner for the SigNoz frontend, working inside the SigNoz monorepo. Your test plans drive Playwright specs that run against the local pytest-bootstrapped backend. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before planning — it documents the harness, the `authedPage` fixture, the TC-NN naming convention, and the self-contained-state principle that every plan you write must respect.
|
||||
|
||||
You will:
|
||||
|
||||
1. **Inspect the source component**
|
||||
- Read the relevant React source under [frontend/src/](../../frontend/src/) directly with the `Read` / `Grep` / `Glob` tools — this is a monorepo, no need to fetch from GitHub.
|
||||
- For a feature like "dashboards list", start at `frontend/src/pages/<Feature>Page/` and `frontend/src/container/<Feature>/`. Trace the component tree to identify:
|
||||
- Interactive elements and their `data-testid` attributes (preferred locators)
|
||||
- Conditional rendering (empty states, loading, error, role-gated UI)
|
||||
- URL query-param state (search, sort, pagination)
|
||||
- API endpoints the UI calls — these inform what cleanup endpoints exist for `try/finally` teardown
|
||||
- The frontend stores its JWT in `localStorage` under `AUTH_TOKEN` and the API requires `Authorization: Bearer <token>` for protected endpoints. Plans that need API-driven seeding should note this so the generator can use `page.request.*`.
|
||||
|
||||
2. **Check what's already wired up.**
|
||||
- [tests/e2e/helpers/dashboards.ts](../../tests/e2e/helpers/dashboards.ts) and [tests/e2e/helpers/auth.ts](../../tests/e2e/helpers/auth.ts) hold reusable helpers (`createDashboardViaApi`, `gotoDashboardsList`, `openDashboardActionMenu`, `newAdminContext`, `importApmMetricsDashboardViaUI`, etc.). When the plan touches dashboards, reference these by name in the steps so the generator can reuse them rather than reinvent.
|
||||
- [tests/e2e/fixtures/apm-metrics.json](../../tests/e2e/fixtures/apm-metrics.json) is a real-world dashboard payload (rich tags, panels, description) suitable as a seed fixture — note in the plan if a scenario benefits from it.
|
||||
|
||||
3. **Navigate and explore**
|
||||
- Invoke `planner_setup_page` once before any other browser tool.
|
||||
- Use `browser_snapshot` to read the page's accessibility tree. **Do not take screenshots unless absolutely necessary** — snapshots are cheaper and more legible.
|
||||
- Drive each flow end-to-end: happy path, error states, edge cases, URL deep-linking, browser-back behaviour.
|
||||
|
||||
4. **Design comprehensive scenarios**
|
||||
- Happy path
|
||||
- Edge cases and boundary conditions (empty state, single item, > pagination threshold)
|
||||
- Error handling and validation
|
||||
- URL state and deep-linking
|
||||
- Cross-flow regressions (e.g. searching while paginated)
|
||||
|
||||
5. **Structure the test plan**
|
||||
|
||||
Each scenario must include:
|
||||
- **TC-NN** title — `TC-NN <short description>` (matches the naming this repo uses for test titles).
|
||||
- Preconditions (what state the test expects — note that the bootstrap creates a fresh stack with **zero dashboards / alerts / etc.**, so plans must seed their own data).
|
||||
- Step-by-step user actions.
|
||||
- Expected outcomes per step.
|
||||
- Cleanup notes (what gets created and how to remove it — usually via API).
|
||||
|
||||
6. **Save the plan and the checklist**
|
||||
- **Plan:** `tests/e2e/specs/<feature>/<feature>-test-plan.md`. **`tests/e2e/specs/` is gitignored** — plans are scratch artifacts: working input for the generator, regenerable, not committed. Don't treat them as durable documentation. The committed tests are the source of truth.
|
||||
- **Checklist:** `tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`. A manual-verification runbook that mirrors the TC list one-to-one (one checkbox per TC) for QA hand-off. Same gitignore, same scratch status.
|
||||
- **The checklist must stay in sync with the TCs.** When you regenerate the plan, regenerate the checklist alongside it — they share TC IDs and titles, and the checklist ordering must match. If the existing spec under `tests/e2e/tests/<feature>/` has more / fewer / different TCs than the prior plan, the spec is authoritative: re-derive plan and checklist from it.
|
||||
- **On re-runs against an evolved feature:** read the existing `.spec.ts` files first. Treat the committed tests as ground truth; produce a plan and checklist that reflect *what is currently in the spec*, not what the prior plan said. This is how the planner handles TC additions, deletions, merges, and renumbering performed by the generator or healer.
|
||||
- Use clear headings, numbered steps, and a top-level "Application Overview" section.
|
||||
- At the top of the plan, list any pre-existing limitations (e.g. "ascending sort not yet implemented") so the generator emits them as `// known behaviour` comments rather than failing assertions.
|
||||
|
||||
<example-spec>
|
||||
# Dashboards List Page — Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
The dashboards list page (`/dashboard`) lists all dashboards in the workspace. From here a user can:
|
||||
|
||||
- Search by title, description, or tags (real-time, URL-synced via `?search=`)
|
||||
- Sort by last-updated (URL-synced via `?columnKey=&order=`)
|
||||
- Open per-row actions: View, Open in New Tab, Copy Link, Export JSON, Delete dashboard
|
||||
- Create a new dashboard via the "New dashboard" dropdown (Create dashboard / Import JSON / View templates)
|
||||
|
||||
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test must seed its own data. The "Enter dashboard name…" inline input is a "request a new template" feedback form — **not** a create flow. The only UI create path is the dropdown.
|
||||
|
||||
**Known limitations:**
|
||||
- Ascending sort is not yet implemented — repeated clicks on the sort button keep `order=descend`.
|
||||
- Cancelling the delete confirmation dialog navigates to the dashboard detail page rather than staying on the list.
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Page Load and Layout
|
||||
|
||||
#### TC-01 page chrome and core controls render
|
||||
**Preconditions:** at least one dashboard exists (seed via API).
|
||||
|
||||
**Steps:**
|
||||
1. Navigate to `/dashboard`.
|
||||
2. Verify URL is `/dashboard` (no query params).
|
||||
3. Verify the page heading "Dashboards" (level 1) is visible.
|
||||
4. ...
|
||||
|
||||
**Expected:**
|
||||
- All Dashboards section header rendered.
|
||||
- Search input, sort button, and at least one dashboard thumbnail visible.
|
||||
|
||||
**Cleanup:** delete the seeded dashboard via `DELETE /api/v1/dashboards/<id>`.
|
||||
|
||||
#### TC-02 ...
|
||||
</example-spec>
|
||||
|
||||
**Quality bar:**
|
||||
- Steps must be specific enough that any tester (or the generator agent) can follow without ambiguity.
|
||||
- Include negative scenarios — empty state, no-match search, validation errors.
|
||||
- Each scenario must own its preconditions and cleanup. **Do not invent cross-file global fixtures** — they break parallel-by-file execution. Suite-level `beforeAll` / `afterAll` *within* a single spec file is fine and is the preferred shape for files with > ~10 scenarios; per-test `try / finally` is fine for smaller specs.
|
||||
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
|
||||
- **Don't pad coverage.** Every TC must catch a regression that would actually ship if the test were missing — a real branch in the source, a real user-visible failure mode. A TC that asserts a hard-coded string is still rendered, or that a button is still a button, adds nothing but maintenance cost: it inflates the suite, slows CI, and trains readers to skim past the directory. Before writing a scenario, ask "what code change would break this?" — if the only answer is "deleting the literal under test," cut it or fold it into a richer scenario as one assertion among many.
|
||||
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should merge into one parameterised scenario unless each input genuinely exercises a distinct branch. Prefer one assertion-rich TC over three thin ones.
|
||||
- **Smoke-checks aren't TCs.** "Heading is visible" belongs as the first assertion inside a real scenario, not as its own numbered case.
|
||||
|
||||
**Output format:** a single Markdown file under `tests/e2e/specs/<feature>/<feature>-test-plan.md` (gitignored scratch path) ready to hand to the generator agent. The file is regenerable; once the spec is written, the plan can be discarded.
|
||||
@@ -7,4 +7,8 @@ deploy
|
||||
sample-apps
|
||||
|
||||
# frontend
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# local env files (tracked example.env templates are unaffected)
|
||||
**/.env
|
||||
**/.env.*
|
||||
@@ -2547,7 +2547,8 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
status:
|
||||
@@ -2597,6 +2598,103 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNodeCondition:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesNodeCountsByReadiness:
|
||||
properties:
|
||||
notReady:
|
||||
type: integer
|
||||
ready:
|
||||
type: integer
|
||||
required:
|
||||
- ready
|
||||
- notReady
|
||||
type: object
|
||||
InframonitoringtypesNodeRecord:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
nodeCPU:
|
||||
format: double
|
||||
type: number
|
||||
nodeCPUAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeCountsByReadiness:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
|
||||
nodeMemory:
|
||||
format: double
|
||||
type: number
|
||||
nodeMemoryAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeName:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- nodeCPU
|
||||
- nodeCPUAllocatable
|
||||
- nodeMemory
|
||||
- nodeMemoryAllocatable
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNodes:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPodCountsByPhase:
|
||||
properties:
|
||||
failed:
|
||||
type: integer
|
||||
pending:
|
||||
type: integer
|
||||
running:
|
||||
type: integer
|
||||
succeeded:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
required:
|
||||
- pending
|
||||
- running
|
||||
- succeeded
|
||||
- failed
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesPodPhase:
|
||||
enum:
|
||||
- pending
|
||||
@@ -2604,18 +2702,15 @@ components:
|
||||
- succeeded
|
||||
- failed
|
||||
- unknown
|
||||
- ""
|
||||
- no_data
|
||||
type: string
|
||||
InframonitoringtypesPodRecord:
|
||||
properties:
|
||||
failedPodCount:
|
||||
type: integer
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
pendingPodCount:
|
||||
type: integer
|
||||
podAge:
|
||||
format: int64
|
||||
type: integer
|
||||
@@ -2628,6 +2723,8 @@ components:
|
||||
podCPURequest:
|
||||
format: double
|
||||
type: number
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podMemory:
|
||||
format: double
|
||||
type: number
|
||||
@@ -2641,12 +2738,6 @@ components:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
|
||||
podUID:
|
||||
type: string
|
||||
runningPodCount:
|
||||
type: integer
|
||||
succeededPodCount:
|
||||
type: integer
|
||||
unknownPodCount:
|
||||
type: integer
|
||||
required:
|
||||
- podUID
|
||||
- podCPU
|
||||
@@ -2656,11 +2747,7 @@ components:
|
||||
- podMemoryRequest
|
||||
- podMemoryLimit
|
||||
- podPhase
|
||||
- pendingPodCount
|
||||
- runningPodCount
|
||||
- succeededPodCount
|
||||
- failedPodCount
|
||||
- unknownPodCount
|
||||
- podCountsByPhase
|
||||
- podAge
|
||||
- meta
|
||||
type: object
|
||||
@@ -2714,6 +2801,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableNodes:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostablePods:
|
||||
properties:
|
||||
end:
|
||||
@@ -11618,12 +11731,83 @@ paths:
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/nodes:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
|
||||
CPU usage, CPU allocatable, memory working set, memory allocatable, per-group
|
||||
nodeCountsByReadiness ({ ready, notReady } from each node''s latest k8s.node.condition_ready
|
||||
in the window) and per-group podCountsByPhase ({ pending, running, succeeded,
|
||||
failed, unknown } for pods scheduled on the listed nodes). Each node includes
|
||||
metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is
|
||||
''list'' for the default k8s.node.name grouping (each row is one node with
|
||||
its current condition string: ready / not_ready / no_data) or ''grouped_list''
|
||||
for custom groupBy keys (each row aggregates nodes in the group; condition
|
||||
stays no_data). Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
operationId: ListNodes
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodes'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Nodes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/pods:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes pods with key metrics:
|
||||
CPU usage, CPU request/limit utilization, memory working set, memory request/limit
|
||||
utilization, current pod phase (pending/running/succeeded/failed/unknown),
|
||||
utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data),
|
||||
and pod age (ms since start time). Each pod includes metadata attributes (namespace,
|
||||
node, workload owner such as deployment/statefulset/daemonset/job/cronjob,
|
||||
cluster). Supports filtering via a filter expression, custom groupBy to aggregate
|
||||
@@ -11631,13 +11815,13 @@ paths:
|
||||
cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit.
|
||||
The response type is ''list'' for the default k8s.pod.uid grouping (each row
|
||||
is one pod with its current phase) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates pods in the group with per-phase counts: pendingPodCount,
|
||||
runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived
|
||||
from each pod''s latest phase in the window). Also reports missing required
|
||||
metrics and whether the requested time range falls before the data retention
|
||||
boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory,
|
||||
podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no
|
||||
data is available for that field.'
|
||||
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
|
||||
{ pending, running, succeeded, failed, unknown } derived from each pod''s
|
||||
latest phase in the window). Also reports missing required metrics and whether
|
||||
the requested time range falls before the data retention boundary. Numeric
|
||||
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
|
||||
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
operationId: ListPods
|
||||
requestBody:
|
||||
content:
|
||||
|
||||
@@ -81,23 +81,52 @@ tests/
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── fixtures/
|
||||
│ └── auth.ts # authedPage Playwright fixture + per-worker storageState cache
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
│ ├── auth.ts
|
||||
│ └── dashboards.ts
|
||||
├── testdata/ # static data files (JSON) used by helpers and tests
|
||||
│ └── apm-metrics.json # (example)
|
||||
├── tests/ # Playwright .spec.ts files, one dir per feature area
|
||||
│ └── alerts/
|
||||
│ └── alerts.spec.ts
|
||||
│ └── alerts.spec.ts # (example)
|
||||
└── artifacts/ # per-run output (gitignored)
|
||||
├── html/ # HTML reporter output
|
||||
├── json/ # JSON reporter output
|
||||
└── results/ # per-test traces / screenshots / videos on failure
|
||||
```
|
||||
|
||||
### `fixtures/` vs `helpers/` — what goes where
|
||||
|
||||
These two folders look similar but mean different things:
|
||||
|
||||
- **`fixtures/`** holds *Playwright test fixtures* (created via `test.extend({...})`). By the canonical definition, a fixture is "a consistent, predefined set of data, objects, or environmental conditions used to ensure tests run in a stable state" — i.e. setup/teardown that runs *automatically* around each test or worker. `auth.ts` matches: it extends Playwright's `test` with an `authedPage` that's logged-in before every test runs and torn down after. If the only thing in this folder ever is `auth.ts`, that's fine — fixtures are a deliberately small surface.
|
||||
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoDashboardsList(page)`) and the constants those helpers and the tests both refer to (e.g. `SEARCH_PLACEHOLDER`). Constants live next to the helpers that use them so a single import line in a test covers both.
|
||||
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `apm-metrics.json`, a real dashboard payload uploaded through the UI by an importer helper.
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
|
||||
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
|
||||
4. **Self-contained state**: each spec creates what it needs and cleans up in `try/finally`. No global pre-seeding fixtures.
|
||||
4. **Self-contained state**: each spec seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two patterns work:
|
||||
- **Per-test seed + cleanup in `try / finally`** — small specs where each test owns its data.
|
||||
- **Suite-level seed + `afterAll` teardown** — preferred for larger specs. Each `createDashboard(...)` call adds the resulting ID to a module-level `Set<string>`, and one `test.afterAll(...)` deletes everything in the set. See `tests/e2e/tests/dashboards/list.spec.ts` for the full pattern. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAdminContext(browser)` from `helpers/auth.ts` instead — it performs one fresh login per suite hook.
|
||||
5. **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state. A typical pattern:
|
||||
```ts
|
||||
const token = await page.evaluate(
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
await page.request.post('/api/v1/dashboards', {
|
||||
data: { title: 'my-name', uploadedGrafana: false },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
```
|
||||
This is faster and more reliable than a multi-step UI seed. Reach for the UI flow only when the test's *purpose* is asserting that flow.
|
||||
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `apm-metrics.json` is a real dashboard payload that `importApmMetricsDashboardViaUI` (in `helpers/dashboards.ts`) uploads through the actual Import JSON UI flow to seed a richly-tagged dashboard for search/list tests.
|
||||
|
||||
## How to write an E2E test?
|
||||
|
||||
@@ -155,13 +184,23 @@ test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }
|
||||
|
||||
### Locator priority
|
||||
|
||||
1. `getByRole('button', { name: 'Submit' })`
|
||||
2. `getByLabel('Email')`
|
||||
3. `getByPlaceholder('...')`
|
||||
4. `getByText('...')`
|
||||
5. `getByTestId('...')`
|
||||
1. `getByTestId('...')` — preferred when the source exposes one. Stable, app-author-provided handle that survives copy-edits.
|
||||
2. `getByRole('button', { name: 'Submit' })`
|
||||
3. `getByLabel('Email')`
|
||||
4. `getByPlaceholder('...')`
|
||||
5. `getByText('...')`
|
||||
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
|
||||
|
||||
## Agents
|
||||
|
||||
Three Claude agents in `.claude/agents/` accelerate writing and maintaining E2E specs:
|
||||
|
||||
- **`playwright-test-planner`** — explores a feature in a real browser plus the local frontend source and writes a test plan as a scratch markdown file (under `tests/e2e/specs/`, which is gitignored — plans are working artifacts for the generator, not committed docs).
|
||||
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the `authedPage` fixture and the API-seed pattern.
|
||||
- **`playwright-test-healer`** — runs failing specs, debugs them with snapshots / console / network introspection, and edits the spec to fix selector drift, timing, or state-leak issues.
|
||||
|
||||
The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` tools). Configure it in your Claude MCP settings; the permission allowlist lives in [.claude/settings.local.json](../../../.claude/settings.local.json).
|
||||
|
||||
## How to run E2E tests?
|
||||
|
||||
### Running All Tests
|
||||
|
||||
@@ -143,7 +143,7 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.deletePublic(ctx, orgID, id)
|
||||
err := module.store.DeletePublic(ctx, id.String())
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
@@ -216,7 +216,3 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
|
||||
return module.store.DeletePublic(ctx, dashboardID.StringValue())
|
||||
}
|
||||
|
||||
@@ -120,10 +120,12 @@
|
||||
"react-helmet-async": "1.3.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-i18next": "^11.16.1",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lottie": "1.2.10",
|
||||
"react-markdown": "8.0.7",
|
||||
"react-query": "3.39.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-rnd": "^10.5.3",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom-v5-compat": "6.27.0",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
@@ -239,10 +241,12 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.(js|jsx|ts|tsx)": [
|
||||
"oxlint --fix",
|
||||
"oxfmt --write",
|
||||
"sh -c tsgo --noEmit"
|
||||
],
|
||||
"*.(js|jsx|ts|tsx|scss|css)": [
|
||||
"oxlint --fix --quiet --no-error-on-unmatched-pattern",
|
||||
"oxfmt --write"
|
||||
],
|
||||
"*.(scss|css)": [
|
||||
"stylelint"
|
||||
]
|
||||
|
||||
@@ -65,6 +65,13 @@ export const TraceDetail = Loadable(
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailV3 = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const UsageExplorerPage = Loadable(
|
||||
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
|
||||
);
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
StatusPage,
|
||||
SupportPage,
|
||||
TraceDetail,
|
||||
TraceDetailV3,
|
||||
TraceFilter,
|
||||
TracesExplorer,
|
||||
TracesFunnelDetails,
|
||||
@@ -138,6 +139,9 @@ const routes: AppRoutes[] = [
|
||||
exact: true,
|
||||
key: 'LOGS_SAVE_VIEWS',
|
||||
},
|
||||
// V3 trace details is gated until release: /trace serves V2 (public),
|
||||
// /trace-old serves V3 (URL-only access). Flip the two `component`
|
||||
// values back to release V3.
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
@@ -145,6 +149,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
component: TraceDetailV3,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL_OLD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.SETTINGS,
|
||||
exact: false,
|
||||
|
||||
@@ -13,8 +13,10 @@ import type {
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
InframonitoringtypesPostableNodesDTO,
|
||||
InframonitoringtypesPostablePodsDTO,
|
||||
ListHosts200,
|
||||
ListNodes200,
|
||||
ListPods200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -107,7 +109,91 @@ export const useListHosts = <
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const listNodes = (
|
||||
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNodes200>({
|
||||
url: `/api/v2/infra_monitoring/nodes`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableNodesDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNodesMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listNodes'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listNodes(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListNodesMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNodes>>
|
||||
>;
|
||||
export type ListNodesMutationBody =
|
||||
BodyType<InframonitoringtypesPostableNodesDTO>;
|
||||
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const useListNodes = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListNodesMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const listPods = (
|
||||
|
||||
@@ -4579,7 +4579,7 @@ export interface InframonitoringtypesHostFilterDTO {
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesHostRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesHostRecordDTO {
|
||||
@@ -4652,35 +4652,127 @@ export interface InframonitoringtypesHostsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesNodeConditionDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesNodeCountsByReadinessDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
notReady: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
ready: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesNodeRecordDTOMeta = {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesNodeRecordDTO {
|
||||
condition: InframonitoringtypesNodeConditionDTO;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesNodeRecordDTOMeta;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPUAllocatable: number;
|
||||
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemoryAllocatable: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nodeName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNodesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesNodeRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodCountsByPhaseDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failed: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pending: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
succeeded: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesPodPhaseDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
succeeded = 'succeeded',
|
||||
failed = 'failed',
|
||||
unknown = 'unknown',
|
||||
'' = '',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesPodRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesPodRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failedPodCount: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesPodRecordDTOMeta;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pendingPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -4701,6 +4793,7 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
* @format double
|
||||
*/
|
||||
podCPURequest: number;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -4721,18 +4814,6 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
* @type string
|
||||
*/
|
||||
podUID: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
runningPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
succeededPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknownPodCount: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodsDTO {
|
||||
@@ -4782,6 +4863,34 @@ export interface InframonitoringtypesPostableHostsDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableNodesDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostablePodsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -9133,6 +9242,14 @@ export type ListHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNodes200 = {
|
||||
data: InframonitoringtypesNodesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPods200 = {
|
||||
data: InframonitoringtypesPodsDTO;
|
||||
/**
|
||||
|
||||
72
frontend/src/api/trace/getTraceV3.tsx
Normal file
72
frontend/src/api/trace/getTraceV3.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ApiV3Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
SpanV3,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const getTraceV3 = async (
|
||||
props: GetTraceV3PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
(node) => node !== props.selectedSpanId,
|
||||
);
|
||||
} else if (
|
||||
props.selectedSpanId &&
|
||||
!uncollapsedSpans.includes(props.selectedSpanId)
|
||||
) {
|
||||
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
|
||||
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
|
||||
uncollapsedSpans.push(props.selectedSpanId);
|
||||
}
|
||||
const postData: GetTraceV3PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
};
|
||||
const response = await axios.post<GetTraceV3SuccessResponse>(
|
||||
`/traces/${props.traceId}/waterfall`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
// V3 API wraps response in { status, data }
|
||||
const rawPayload = (response.data as any).data || response.data;
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
|
||||
// Convert by using the first span's timestamp as the base if there's a mismatch.
|
||||
let { startTimestampMillis, endTimestampMillis } = rawPayload;
|
||||
if (
|
||||
spans.length > 0 &&
|
||||
spans[0].timestamp > 0 &&
|
||||
startTimestampMillis < spans[0].timestamp / 10
|
||||
) {
|
||||
const durationMillis = endTimestampMillis - startTimestampMillis;
|
||||
startTimestampMillis = spans[0].timestamp;
|
||||
endTimestampMillis = startTimestampMillis + durationMillis;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: {
|
||||
...rawPayload,
|
||||
spans,
|
||||
startTimestampMillis,
|
||||
endTimestampMillis,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV3;
|
||||
@@ -0,0 +1,37 @@
|
||||
.details-header {
|
||||
// ghost + secondary missing hover bg token in @signozhq/button
|
||||
--button-ghost-hover-background: var(--l3-background);
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 36px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import './DetailsHeader.styles.scss';
|
||||
|
||||
export interface HeaderAction {
|
||||
key: string;
|
||||
component: ReactNode; // check later if we can use direct btn itself or not.
|
||||
}
|
||||
|
||||
export interface DetailsHeaderProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
actions?: HeaderAction[];
|
||||
closePosition?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsHeader({
|
||||
title,
|
||||
onClose,
|
||||
actions,
|
||||
closePosition = 'right',
|
||||
className,
|
||||
}: DetailsHeaderProps): JSX.Element {
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
></Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`details-header ${className || ''}`}>
|
||||
{closePosition === 'left' && closeButton}
|
||||
|
||||
<span className="details-header__title">{title}</span>
|
||||
|
||||
{actions && (
|
||||
<div className="details-header__actions">
|
||||
{actions.map((action) => (
|
||||
<div key={action.key}>{action.component}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{closePosition === 'right' && closeButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsHeader;
|
||||
@@ -0,0 +1,7 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/DetailsPanel/DetailsPanelDrawer.tsx
Normal file
35
frontend/src/components/DetailsPanel/DetailsPanelDrawer.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DrawerWrapper } from '@signozhq/ui';
|
||||
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showOverlay={false}
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
>
|
||||
<div className="details-panel-drawer__body">{children}</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
8
frontend/src/components/DetailsPanel/index.ts
Normal file
8
frontend/src/components/DetailsPanel/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
DetailsHeaderProps,
|
||||
HeaderAction,
|
||||
} from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
|
||||
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
export { default as useDetailsPanel } from './useDetailsPanel';
|
||||
10
frontend/src/components/DetailsPanel/types.ts
Normal file
10
frontend/src/components/DetailsPanel/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface DetailsPanelState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface UseDetailsPanelOptions {
|
||||
entityId: string | undefined;
|
||||
onClose?: () => void;
|
||||
}
|
||||
29
frontend/src/components/DetailsPanel/useDetailsPanel.ts
Normal file
29
frontend/src/components/DetailsPanel/useDetailsPanel.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
|
||||
function useDetailsPanel({
|
||||
entityId,
|
||||
onClose,
|
||||
}: UseDetailsPanelOptions): DetailsPanelState {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const prevEntityIdRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = entityId || '';
|
||||
if (currentId && currentId !== prevEntityIdRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
prevEntityIdRef.current = currentId;
|
||||
}, [entityId]);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
return { isOpen, open, close };
|
||||
}
|
||||
|
||||
export default useDetailsPanel;
|
||||
@@ -4,7 +4,8 @@ import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Switch, Typography } from 'antd';
|
||||
import { Button, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -124,9 +125,8 @@ function ShareURLModal(): JSX.Element {
|
||||
<Info size={14} color={Color.BG_AMBER_600} />
|
||||
)}
|
||||
<Switch
|
||||
checked={enableAbsoluteTime}
|
||||
value={enableAbsoluteTime}
|
||||
disabled={!isValidateRelativeTime}
|
||||
size="small"
|
||||
onChange={(): void => {
|
||||
setEnableAbsoluteTime((prev) => !prev);
|
||||
}}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import JSONView from 'container/LogDetailedView/JsonView';
|
||||
import Overview from 'container/LogDetailedView/Overview';
|
||||
import {
|
||||
aggregateAttributesResourcesToString,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
TextSelect,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -562,7 +562,9 @@ function LogDetailInner({
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
|
||||
{selectedView === VIEW_TYPES.JSON && (
|
||||
<JsonView data={LogJsonData} height="68vh" />
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui';
|
||||
import { Skeleton, Switch, Tooltip, Typography } from 'antd';
|
||||
import { Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -278,9 +279,8 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
<div className="api-quick-filters-header">
|
||||
<Typography.Text>Show IP addresses</Typography.Text>
|
||||
<Switch
|
||||
size="small"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
checked={showIP ?? true}
|
||||
value={showIP ?? true}
|
||||
onChange={(checked): void => {
|
||||
logEvent('API Monitoring: Show IP addresses clicked', {
|
||||
showIP: checked,
|
||||
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps, Switch } from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -60,9 +61,7 @@ function DynamicColumnTable({
|
||||
|
||||
const onToggleHandler =
|
||||
(index: number, column: ColumnGroupType<any> | ColumnType<any>) =>
|
||||
(checked: boolean, event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
|
||||
(checked: boolean): void => {
|
||||
if (shouldSendAlertsLogEvent) {
|
||||
logEvent('Alert: Column toggled', {
|
||||
column: column?.title,
|
||||
@@ -88,10 +87,14 @@ function DynamicColumnTable({
|
||||
const items: MenuProps['items'] =
|
||||
dynamicColumns?.map((column, index) => ({
|
||||
label: (
|
||||
<div className="dynamicColumnsTable-items">
|
||||
<div
|
||||
className="dynamicColumnsTable-items"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div>{column.title?.toString()}</div>
|
||||
<Switch
|
||||
checked={columnsData?.findIndex((c) => c.key === column.key) !== -1}
|
||||
value={columnsData?.findIndex((c) => c.key === column.key) !== -1}
|
||||
onChange={onToggleHandler(index, column)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -589,6 +589,16 @@ function TanStackTableInner<TData>(
|
||||
{showPagination && pagination && (
|
||||
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
|
||||
{prefixPaginationContent}
|
||||
{pagination.showTotalCount && effectiveTotalCount > 0 && (
|
||||
<span
|
||||
className={viewStyles.paginationTotalCount}
|
||||
data-testid="pagination-total-count"
|
||||
>
|
||||
Showing {(page - 1) * limit + 1} -{' '}
|
||||
{Math.min(page * limit, effectiveTotalCount)} of {effectiveTotalCount}
|
||||
{pagination.totalCountLabel ? ` ${pagination.totalCountLabel}` : ''}
|
||||
</span>
|
||||
)}
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={limit}
|
||||
|
||||
@@ -117,6 +117,10 @@
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationPageSize {
|
||||
@@ -124,6 +128,11 @@
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.paginationTotalCount {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.tanstackLoadingOverlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -188,6 +188,87 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders total count when showTotalCount is true', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 100,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const totalCount = screen.getByTestId('pagination-total-count');
|
||||
expect(totalCount).toBeInTheDocument();
|
||||
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 100');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders total count with label when totalCountLabel is provided', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 50,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: 'Pods',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const totalCount = screen.getByTestId('pagination-total-count');
|
||||
expect(totalCount).toBeInTheDocument();
|
||||
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 50 Pods');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render total count when showTotalCount is false', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 100,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('pagination-total-count'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render total count when total is 0', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: {
|
||||
total: 0,
|
||||
defaultPage: 1,
|
||||
defaultLimit: 10,
|
||||
showTotalCount: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('pagination-total-count'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
|
||||
@@ -115,6 +115,8 @@ export type PaginationProps = {
|
||||
total: number;
|
||||
defaultPage?: number;
|
||||
defaultLimit?: number;
|
||||
showTotalCount?: boolean;
|
||||
totalCountLabel?: string;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
|
||||
20
frontend/src/components/TimelineV3/TimelineV3.styles.scss
Normal file
20
frontend/src/components/TimelineV3/TimelineV3.styles.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
.timeline-v3-container {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-v3-cursor-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
117
frontend/src/components/TimelineV3/TimelineV3.tsx
Normal file
117
frontend/src/components/TimelineV3/TimelineV3.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import {
|
||||
getIntervals,
|
||||
getIntervalUnit,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
Interval,
|
||||
} from './utils';
|
||||
|
||||
import './TimelineV3.styles.scss';
|
||||
|
||||
interface ITimelineV3Props {
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
timelineHeight: number;
|
||||
offsetTimestamp: number;
|
||||
/** Cursor X as a fraction of the timeline width (0–1). null = no cursor. */
|
||||
cursorXPercent?: number | null;
|
||||
}
|
||||
|
||||
function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
const {
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
timelineHeight,
|
||||
offsetTimestamp,
|
||||
cursorXPercent,
|
||||
} = props;
|
||||
const [intervals, setIntervals] = useState<Interval[]>([]);
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const spread = endTimestamp - startTimestamp;
|
||||
|
||||
useEffect(() => {
|
||||
if (spread < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalisedSpread = (spread / minIntervals) * 1.0;
|
||||
const newIntervals = getIntervals(
|
||||
intervalisedSpread,
|
||||
spread,
|
||||
offsetTimestamp,
|
||||
);
|
||||
|
||||
setIntervals(newIntervals);
|
||||
}, [startTimestamp, endTimestamp, width, offsetTimestamp, spread]);
|
||||
|
||||
// Compute cursor time label using the same unit as timeline ticks
|
||||
const cursorLabel = useMemo(() => {
|
||||
if (cursorXPercent == null || spread <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
|
||||
const unit = getIntervalUnit(spread, offsetTimestamp);
|
||||
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
|
||||
return `${formatted}${unit.name}`;
|
||||
}, [cursorXPercent, spread, offsetTimestamp]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
'endTimestamp cannot be less than startTimestamp',
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const strokeColor = 'var(--l3-foreground)';
|
||||
const svgHeight = timelineHeight * 2.5;
|
||||
const cursorX = cursorXPercent != null ? cursorXPercent * width : null;
|
||||
|
||||
return (
|
||||
<div ref={ref as never} className="timeline-v3-container">
|
||||
<svg
|
||||
width={width}
|
||||
height={svgHeight}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
overflow="visible"
|
||||
>
|
||||
{intervals &&
|
||||
intervals.length > 0 &&
|
||||
intervals.map((interval, index) => (
|
||||
<g
|
||||
transform={`translate(${(interval.percentage * width) / 100},0)`}
|
||||
key={`${interval.percentage + interval.label + index}`}
|
||||
textAnchor="middle"
|
||||
fontSize="0.6rem"
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Cursor time badge — DOM element for easy CSS styling */}
|
||||
{cursorX !== null && cursorLabel && (
|
||||
<div className="timeline-v3-cursor-badge" style={{ left: cursorX }}>
|
||||
{cursorLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineV3;
|
||||
109
frontend/src/components/TimelineV3/utils.ts
Normal file
109
frontend/src/components/TimelineV3/utils.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
IIntervalUnit,
|
||||
Interval,
|
||||
INTERVAL_UNITS,
|
||||
resolveTimeFromInterval,
|
||||
} from 'components/TimelineV2/utils';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
export type { Interval };
|
||||
|
||||
/**
|
||||
* Select the interval unit matching the timeline's logic.
|
||||
* Exported so crosshair labels use the same unit as timeline ticks.
|
||||
*/
|
||||
export function getIntervalUnit(
|
||||
spread: number,
|
||||
offsetTimestamp: number,
|
||||
): IIntervalUnit {
|
||||
const minIntervals = 6;
|
||||
const intervalSpread = spread / minIntervals;
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let unit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
if (valueForUnitSelection * INTERVAL_UNITS[idx].multiplier >= 1) {
|
||||
unit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
|
||||
export function getMinimumIntervalsBasedOnWidth(width: number): number {
|
||||
if (width < 640) {
|
||||
return 3;
|
||||
}
|
||||
if (width < 768) {
|
||||
return 4;
|
||||
}
|
||||
if (width < 1024) {
|
||||
return 5;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes timeline intervals with offset-aware labels.
|
||||
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
|
||||
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
|
||||
*/
|
||||
export function getIntervals(
|
||||
intervalSpread: number,
|
||||
baseSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): Interval[] {
|
||||
const integerPartString = intervalSpread.toString().split('.')[0];
|
||||
const integerPartLength = integerPartString.length;
|
||||
|
||||
const intervalSpreadNormalized =
|
||||
intervalSpread < 1.0
|
||||
? intervalSpread
|
||||
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
|
||||
10 ** (integerPartLength - 1);
|
||||
|
||||
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
|
||||
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
|
||||
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
const standardInterval = INTERVAL_UNITS[idx];
|
||||
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
|
||||
intervalUnit = INTERVAL_UNITS[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervals: Interval[] = [
|
||||
{
|
||||
label: `${toFixed(
|
||||
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
|
||||
2,
|
||||
)}${intervalUnit.name}`,
|
||||
percentage: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show even-interval ticks — skip the trailing partial tick at the edge.
|
||||
// The last even tick sits before the full width, so it doesn't conflict with
|
||||
// span duration labels that may have sub-millisecond precision.
|
||||
let elapsedIntervals = 0;
|
||||
|
||||
while (
|
||||
elapsedIntervals + intervalSpreadNormalized <= baseSpread &&
|
||||
intervals.length < 20
|
||||
) {
|
||||
elapsedIntervals += intervalSpreadNormalized;
|
||||
const labelTime = offsetTimestamp + elapsedIntervals;
|
||||
|
||||
intervals.push({
|
||||
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
|
||||
intervalUnit.name
|
||||
}`,
|
||||
percentage: (elapsedIntervals / baseSpread) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export enum LOCALSTORAGE {
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const REACT_QUERY_KEY = {
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
|
||||
@@ -8,6 +8,7 @@ const ROUTES = {
|
||||
SERVICE_MAP: '/service-map',
|
||||
TRACE: '/trace',
|
||||
TRACE_DETAIL: '/trace/:id',
|
||||
TRACE_DETAIL_OLD: '/trace-old/:id',
|
||||
TRACES_EXPLORER: '/traces-explorer',
|
||||
ONBOARDING: '/onboarding',
|
||||
GET_STARTED: '/get-started',
|
||||
|
||||
@@ -33,6 +33,102 @@ const themeColors = {
|
||||
purple: '#800080',
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
traceDetailColorsV3: {
|
||||
// Blues
|
||||
blue1: '#2F80ED',
|
||||
blue2: '#3366E6',
|
||||
blue3: '#4682B4',
|
||||
blue4: '#1F63E0',
|
||||
blue5: '#3A7AED',
|
||||
blue6: '#5A9DF5',
|
||||
blue7: '#2874A6',
|
||||
blue8: '#2E86C1',
|
||||
blue9: '#3498DB',
|
||||
blue10: '#1E90FF',
|
||||
blue11: '#4169E1',
|
||||
|
||||
// Cyans / Teals
|
||||
cyan1: '#00CEC9',
|
||||
cyan2: '#22A6F2',
|
||||
cyan3: '#00B0AA',
|
||||
cyan4: '#33D6C2',
|
||||
cyan5: '#66E9DA',
|
||||
cyan6: '#48DBFB',
|
||||
cyan7: '#00BFFF',
|
||||
cyan8: '#63B8FF',
|
||||
teal1: '#009688',
|
||||
teal2: '#1ABC9C',
|
||||
teal3: '#48C9B0',
|
||||
teal4: '#76D7C4',
|
||||
teal5: '#20B2AA',
|
||||
|
||||
// Greens
|
||||
green1: '#27AE60',
|
||||
green2: '#3CB371',
|
||||
green3: '#1E8449',
|
||||
green4: '#2ECC71',
|
||||
green5: '#58D68D',
|
||||
green6: '#229954',
|
||||
green7: '#52BE80',
|
||||
green8: '#82E0AA',
|
||||
green9: '#73C6B6',
|
||||
|
||||
// Limes
|
||||
lime1: '#A3E635',
|
||||
lime2: '#B9F18D',
|
||||
lime3: '#84CC16',
|
||||
lime4: '#65A30D',
|
||||
|
||||
// Yellows
|
||||
yellow1: '#F1C40F',
|
||||
yellow2: '#F7DC6F',
|
||||
yellow3: '#F9E79F',
|
||||
yellow4: '#F4D03F',
|
||||
yellow5: '#D4AC0D',
|
||||
|
||||
// Golds / Ambers
|
||||
gold1: '#F2C94C',
|
||||
gold2: '#FFD93D',
|
||||
gold3: '#FFCA28',
|
||||
gold4: '#B7950B',
|
||||
gold5: '#D4A017',
|
||||
|
||||
// Oranges (non-red)
|
||||
orange1: '#F39C12',
|
||||
orange2: '#E67E22',
|
||||
orange3: '#F5B041',
|
||||
orange4: '#D35400',
|
||||
orange5: '#EB984E',
|
||||
orange6: '#FAD7A0',
|
||||
|
||||
// Purples / Violets
|
||||
purple1: '#BB6BD9',
|
||||
purple2: '#9B51E0',
|
||||
purple3: '#DA77F2',
|
||||
purple4: '#C77DFF',
|
||||
purple5: '#6C5CE7',
|
||||
purple6: '#8E44AD',
|
||||
purple7: '#9B59B6',
|
||||
purple8: '#BB8FCE',
|
||||
purple9: '#7D3C98',
|
||||
purple10: '#A569BD',
|
||||
|
||||
// Lavenders
|
||||
lavender1: '#AF7AC5',
|
||||
lavender2: '#C39BD3',
|
||||
lavender3: '#D2B4DE',
|
||||
|
||||
// Pinks / Magentas
|
||||
pink1: '#E91E8C',
|
||||
pink2: '#FF6FD8',
|
||||
pink3: '#F06292',
|
||||
pink4: '#CE93D8',
|
||||
|
||||
// Salmons / Corals (distinct from error red)
|
||||
salmon1: '#FF8A65',
|
||||
salmon2: '#FFAB91',
|
||||
salmon3: '#E0876A',
|
||||
},
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
|
||||
import { Spin, Table, Tooltip, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
|
||||
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
@@ -170,11 +171,7 @@ function TopErrors({
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Switch
|
||||
checked={showStatusCodeErrors}
|
||||
onChange={setShowStatusCodeErrors}
|
||||
size="small"
|
||||
/>
|
||||
<Switch value={showStatusCodeErrors} onChange={setShowStatusCodeErrors} />
|
||||
<span style={{ color: 'white', fontSize: '14px' }}>
|
||||
Status Message Exists
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Flex, SelectProps, Switch, Typography } from 'antd';
|
||||
import { Button, Flex, SelectProps, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import type { BaseOptionType, DefaultOptionType } from 'antd/es/select';
|
||||
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
@@ -416,8 +417,8 @@ export function RoutingPolicyBanner({
|
||||
</Typography.Text>
|
||||
<div className="routing-policies-info-banner-right">
|
||||
<Switch
|
||||
checked={notificationSettings.routingPolicies}
|
||||
data-testid="routing-policies-switch"
|
||||
value={notificationSettings.routingPolicies}
|
||||
testId="routing-policies-switch"
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch, Tooltip, Typography } from 'antd';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { IAdvancedOptionItemProps } from '../types';
|
||||
@@ -48,7 +49,7 @@ function AdvancedOptionItem({
|
||||
>
|
||||
{input}
|
||||
</div>
|
||||
<Switch onChange={handleOnToggle} checked={showInput} />
|
||||
<Switch onChange={handleOnToggle} value={showInput} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Collapse, Input, Select, Switch, Tag, Typography } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Tag, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import cx from 'classnames';
|
||||
import Editor from 'components/Editor';
|
||||
@@ -762,7 +763,7 @@ function VariableItem({
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={variableMultiSelect}
|
||||
value={variableMultiSelect}
|
||||
onChange={(e): void => {
|
||||
setVariableMultiSelect(e);
|
||||
if (!e) {
|
||||
@@ -779,7 +780,7 @@ function VariableItem({
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={variableShowALLOption}
|
||||
value={variableShowALLOption}
|
||||
onChange={(e): void => setVariableShowALLOption(e)}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
max-width: 825px;
|
||||
gap: 25px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, ReactElement, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, FormInstance, Input, Select, Switch, Typography } from 'antd';
|
||||
import { Form, FormInstance, Input, Select, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import type { Store } from 'antd/lib/form/interface';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -81,8 +82,8 @@ function FormAlertChannels({
|
||||
name="send_resolved"
|
||||
>
|
||||
<Switch
|
||||
defaultChecked={initialValue?.send_resolved}
|
||||
data-testid="field-send-resolved-checkbox"
|
||||
defaultValue={initialValue?.send_resolved}
|
||||
testId="field-send-resolved-checkbox"
|
||||
onChange={(value): void => {
|
||||
setSelectedConfig((state) => ({
|
||||
...state,
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Form, Select, Switch, Tooltip } from 'antd';
|
||||
import { Button, Form, Select, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import getAll from 'api/channels/getAll';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
@@ -198,10 +199,10 @@ function BasicInfo({
|
||||
placement="right"
|
||||
>
|
||||
<Switch
|
||||
checked={shouldBroadCastToAllChannels}
|
||||
value={shouldBroadCastToAllChannels}
|
||||
onChange={handleBroadcastToAllChannels}
|
||||
disabled={noChannels || !!isLoading}
|
||||
data-testid="alert-broadcast-to-all-channels"
|
||||
testId="alert-broadcast-to-all-channels"
|
||||
/>
|
||||
</Tooltip>
|
||||
</FormItemMedium>
|
||||
|
||||
@@ -286,6 +286,8 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
total: totalCount,
|
||||
defaultLimit: 10,
|
||||
defaultPage: 1,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: entity.charAt(0).toUpperCase() + entity.slice(1),
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -227,9 +228,8 @@ function LiveLogsContainer({
|
||||
<div className="live-logs-frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showLiveLogsFrequencyChart}
|
||||
defaultChecked
|
||||
value={showLiveLogsFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infra-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.infra-metrics-card {
|
||||
margin: 1rem 0;
|
||||
height: 300px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -163,16 +163,16 @@ function NodeMetrics({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
<div className="infra-metrics-grid">
|
||||
{queries.map((query, idx) => (
|
||||
<Col span={12} key={widgetInfo[idx].title}>
|
||||
<div key={widgetInfo[idx].title}>
|
||||
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
@@ -146,16 +146,16 @@ function PodMetrics({
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
<div className="infra-metrics-grid">
|
||||
{queries.map((query, idx) => (
|
||||
<Col span={12} key={podWidgetInfo[idx].title}>
|
||||
<div key={podWidgetInfo[idx].title}>
|
||||
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="infra-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { JSONViewProps } from './LogDetailedView.types';
|
||||
@@ -81,7 +82,7 @@ function JSONView({ logData }: JSONViewProps): JSX.Element {
|
||||
<div className="log-switch">
|
||||
<div className="wrap-word-switch">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch checked={isWrapWord} onChange={handleWrapWord} size="small" />
|
||||
<Switch value={isWrapWord} onChange={handleWrapWord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ReactNode, useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Collapse, Divider, Input, Switch, Tag, Typography } from 'antd';
|
||||
import { Collapse, Divider, Input, Tag, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
@@ -136,7 +137,7 @@ function Overview({
|
||||
<div className="log-switch">
|
||||
<div className="wrap-word-switch">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch checked={isWrapWord} onChange={handleWrapWord} size="small" />
|
||||
<Switch value={isWrapWord} onChange={handleWrapWord} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
@@ -68,9 +69,8 @@ function LogsActionsContainer({
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showFrequencyChart}
|
||||
defaultChecked
|
||||
value={showFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Switch, Tooltip } from 'antd';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -313,10 +314,9 @@ function Explorer(): JSX.Element {
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
checked={showOneChartPerQuery}
|
||||
value={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Skeleton, Switch, Typography } from 'antd';
|
||||
import { Button, Skeleton, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -205,7 +206,7 @@ function GraphView({
|
||||
</Button.Group>
|
||||
<div className="view-toggle-button">
|
||||
<Switch
|
||||
checked={viewType === 'graph'}
|
||||
value={viewType === 'graph'}
|
||||
onChange={(checked): void => {
|
||||
const newViewType = checked ? 'graph' : 'table';
|
||||
setViewType(newViewType);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Switch } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Delete } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -49,10 +49,10 @@ function TimezoneAdaptation(): JSX.Element {
|
||||
<div className="timezone-adaption__header">
|
||||
<h2 className="timezone-adaption__title">Adapt to my timezone</h2>
|
||||
<Switch
|
||||
checked={isAdaptationEnabled}
|
||||
value={isAdaptationEnabled}
|
||||
onChange={handleSwitchChange}
|
||||
style={getSwitchStyles()}
|
||||
data-testid="timezone-adaptation-switch"
|
||||
testId="timezone-adaptation-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Radio, RadioChangeEvent, Switch, Tag } from 'antd';
|
||||
import { Radio, RadioChangeEvent, Tag } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
@@ -218,10 +219,10 @@ function MySettings(): JSX.Element {
|
||||
<div className="user-preference-section-content-item-title-action">
|
||||
Keep the primary sidebar always open{' '}
|
||||
<Switch
|
||||
checked={sideNavPinned}
|
||||
value={sideNavPinned}
|
||||
onChange={handleSideNavPinnedChange}
|
||||
loading={isUpdatingUserPreference}
|
||||
data-testid="side-nav-pinned-switch"
|
||||
disabled={isUpdatingUserPreference}
|
||||
testId="side-nav-pinned-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import {
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
@@ -72,7 +73,7 @@ export default function ChartAppearanceSection({
|
||||
Display individual data points on the chart
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
|
||||
<Switch value={showPoints} onChange={setShowPoints} />
|
||||
</section>
|
||||
)}
|
||||
{allowSpanGaps && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { InputNumber, Switch, Typography } from 'antd';
|
||||
import { InputNumber, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
|
||||
import SettingsSection from '../../components/SettingsSection/SettingsSection';
|
||||
|
||||
@@ -60,8 +61,7 @@ export default function HistogramBucketsSection({
|
||||
</span>
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={combineHistogram}
|
||||
size="small"
|
||||
value={combineHistogram}
|
||||
onChange={(checked): void => setCombineHistogram(checked)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { Select, Switch, Typography } from 'antd';
|
||||
import { Select, Typography } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
@@ -103,8 +104,7 @@ export default function VisualizationSettingsSection({
|
||||
<section className="stack-chart control-container">
|
||||
<Typography.Text className="section-heading">Stack series</Typography.Text>
|
||||
<Switch
|
||||
checked={stackedBarChart}
|
||||
size="small"
|
||||
value={stackedBarChart}
|
||||
onChange={(checked): void => setStackedBarChart(checked)}
|
||||
/>
|
||||
</section>
|
||||
@@ -119,8 +119,7 @@ export default function VisualizationSettingsSection({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isFillSpans}
|
||||
size="small"
|
||||
value={isFillSpans}
|
||||
onChange={(checked): void => setIsFillSpans(checked)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -341,10 +341,10 @@ describe('Stacking bar in new panel', () => {
|
||||
const section = container.querySelector('.stack-chart');
|
||||
expect(section).toBeInTheDocument();
|
||||
|
||||
// Verify switch is present and enabled (ant-switch-checked)
|
||||
const switchBtn = section?.querySelector('.ant-switch');
|
||||
// Verify switch is present and enabled
|
||||
const switchBtn = section?.querySelector('[role="switch"]');
|
||||
expect(switchBtn).toBeInTheDocument();
|
||||
expect(switchBtn).toHaveClass('ant-switch-checked');
|
||||
expect(switchBtn).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Check that stack series is present and checked
|
||||
checkStackSeriesState(container, true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Space, Switch, Tooltip } from 'antd';
|
||||
import { Form, Input, Space, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import { ProcessorData } from 'types/api/pipeline/def';
|
||||
|
||||
import { PREDEFINED_MAPPING } from '../config';
|
||||
@@ -58,11 +59,7 @@ function JsonFlattening({
|
||||
initialValue={isAdd ? true : selectedProcessorData?.enable_paths}
|
||||
>
|
||||
<Space>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enablePaths}
|
||||
onChange={handleEnablePathsChange}
|
||||
/>
|
||||
<Switch value={enablePaths} onChange={handleEnablePathsChange} />
|
||||
Enable Paths
|
||||
</Space>
|
||||
</Form.Item>
|
||||
@@ -79,11 +76,7 @@ function JsonFlattening({
|
||||
|
||||
<Form.Item className="json-flattening-form__item">
|
||||
<Space>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enableMapping}
|
||||
onChange={handleEnableMappingChange}
|
||||
/>
|
||||
<Switch value={enableMapping} onChange={handleEnableMappingChange} />
|
||||
Enable Mapping
|
||||
<Tooltip title="The order of filled keys will determine the priority of keys i.e. earlier keys have higher precedence">
|
||||
<InfoCircleOutlined />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Select, Space, Switch } from 'antd';
|
||||
import { Form, Input, Select, Space } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import { ModalFooterTitle } from 'container/PipelinePage/styles';
|
||||
import { ProcessorData } from 'types/api/pipeline/def';
|
||||
|
||||
@@ -92,8 +93,7 @@ function ProcessorFieldInput({
|
||||
>
|
||||
<Space>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={form.getFieldValue('enable_flattening')}
|
||||
value={form.getFieldValue('enable_flattening')}
|
||||
onChange={(checked: boolean): void => {
|
||||
form.setFieldValue('enable_flattening', checked);
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import { Switch } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
|
||||
import { holdIconStyle } from '../config';
|
||||
import { LastActionColumn } from '../styles';
|
||||
@@ -7,7 +7,7 @@ import { LastActionColumn } from '../styles';
|
||||
function DragAction({ isEnabled, onChange }: DragActionProps): JSX.Element {
|
||||
return (
|
||||
<LastActionColumn>
|
||||
<Switch defaultChecked={isEnabled} onChange={onChange} />
|
||||
<Switch defaultValue={isEnabled} onChange={onChange} />
|
||||
<HolderOutlined style={holdIconStyle} />
|
||||
</LastActionColumn>
|
||||
);
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('PipelinePage container test', () => {
|
||||
await fireEvent.click(expandIcon[0]);
|
||||
|
||||
const switchToggle = document.querySelector(
|
||||
'.ant-table-expanded-row .ant-switch',
|
||||
'.ant-table-expanded-row [role="switch"]',
|
||||
);
|
||||
|
||||
expect(switchToggle).toBeChecked();
|
||||
@@ -236,7 +236,7 @@ describe('PipelinePage container test', () => {
|
||||
const addNewPipelineBtn = getByText('add_new_pipeline');
|
||||
expect(addNewPipelineBtn).toBeInTheDocument();
|
||||
|
||||
const switchToggle = document.querySelectorAll('.ant-switch');
|
||||
const switchToggle = document.querySelectorAll('[role="switch"]');
|
||||
|
||||
expect(switchToggle[0]).not.toBeChecked();
|
||||
await fireEvent.click(switchToggle[0] as HTMLElement);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
<DocumentFragment>
|
||||
@@ -21,26 +21,25 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="ant-switch css-dev-only-do-not-override-2i2tap ant-switch-checked"
|
||||
role="switch"
|
||||
type="button"
|
||||
<div
|
||||
class="_switch-wrapper_jbsv7_1"
|
||||
>
|
||||
<div
|
||||
class="ant-switch-handle"
|
||||
/>
|
||||
<span
|
||||
class="ant-switch-inner"
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="_switch_jbsv7_1"
|
||||
data-color="robin"
|
||||
data-state="checked"
|
||||
id=":r0:"
|
||||
role="switch"
|
||||
type="button"
|
||||
value="on"
|
||||
>
|
||||
<span
|
||||
class="ant-switch-inner-checked"
|
||||
class="_switch__thumb_jbsv7_59"
|
||||
data-state="checked"
|
||||
/>
|
||||
<span
|
||||
class="ant-switch-inner-unchecked"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
aria-label="holder"
|
||||
class="anticon anticon-holder"
|
||||
|
||||
@@ -46,6 +46,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
|
||||
[ROUTES.TRACES_EXPLORER]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE_DETAIL]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.TRACE_DETAIL_OLD]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.UN_AUTHORIZED]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.USAGE_EXPLORER]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.VERSION]: [QueryParams.resourceAttributes],
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
&-empty-content {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -143,6 +143,7 @@ export const routesToSkip = [
|
||||
ROUTES.SETTINGS,
|
||||
ROUTES.LIST_ALL_ALERT,
|
||||
ROUTES.TRACE_DETAIL,
|
||||
ROUTES.TRACE_DETAIL_OLD,
|
||||
ROUTES.ALL_CHANNELS,
|
||||
ROUTES.USAGE_EXPLORER,
|
||||
ROUTES.GET_STARTED,
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { MouseEventHandler, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Accepts both V2 (spanId) and V3 (span_id) span shapes
|
||||
// TODO: Remove V2 (spanId) support when phasing out V2
|
||||
interface SpanLike {
|
||||
spanId?: string;
|
||||
span_id?: string;
|
||||
}
|
||||
|
||||
export const useCopySpanLink = (
|
||||
span?: Span,
|
||||
span?: SpanLike,
|
||||
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const onSpanCopy: MouseEventHandler<HTMLElement> = useCallback(
|
||||
(event) => {
|
||||
@@ -25,18 +30,19 @@ export const useCopySpanLink = (
|
||||
|
||||
urlQuery.delete('spanId');
|
||||
|
||||
if (span.spanId) {
|
||||
urlQuery.set('spanId', span?.spanId);
|
||||
const id = span.span_id || span.spanId;
|
||||
if (id) {
|
||||
urlQuery.set('spanId', id);
|
||||
}
|
||||
|
||||
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
|
||||
|
||||
setCopy(link);
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
toast.success('Copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
[span, urlQuery, pathname, setCopy, notifications],
|
||||
[span, urlQuery, pathname, setCopy],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,11 +13,7 @@ const useGetTraceFlamegraph = (
|
||||
useQuery({
|
||||
queryFn: () => getTraceFlamegraph(props),
|
||||
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
],
|
||||
queryKey: [REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH, props.traceId],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
29
frontend/src/hooks/trace/useGetTraceV3.tsx
Normal file
29
frontend/src/hooks/trace/useGetTraceV3.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceV3 from 'api/trace/getTraceV3';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceV3(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseTraceV3 = UseQueryResult<
|
||||
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceV3;
|
||||
@@ -7,6 +7,23 @@ export function hashFn(str: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function colorToRgb(color: string): string {
|
||||
// Handle hex colors
|
||||
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
if (hexMatch) {
|
||||
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
|
||||
hexMatch[2],
|
||||
16,
|
||||
)}, ${parseInt(hexMatch[3], 16)}`;
|
||||
}
|
||||
// Handle rgb() colors
|
||||
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
|
||||
if (rgbMatch) {
|
||||
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
|
||||
}
|
||||
return '136, 136, 136';
|
||||
}
|
||||
|
||||
export function generateColor(
|
||||
key: string,
|
||||
colorMap: Record<string, string>,
|
||||
|
||||
@@ -867,8 +867,12 @@ describe('TooltipPlugin', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
|
||||
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
|
||||
const resizeCall = addSpy.mock.calls.find(
|
||||
([type]) => type === ('resize' as keyof WindowEventMap),
|
||||
);
|
||||
const scrollCall = addSpy.mock.calls.find(
|
||||
([type]) => type === ('scroll' as keyof WindowEventMap),
|
||||
);
|
||||
|
||||
expect(resizeCall).toBeDefined();
|
||||
expect(scrollCall).toBeDefined();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd';
|
||||
import { Divider, Dropdown, MenuProps, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
@@ -127,11 +128,7 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
size="small"
|
||||
onChange={toggleAlertRule}
|
||||
checked={!isAlertRuleDisabled}
|
||||
/>
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
5
frontend/src/pages/TraceDetailV3Page/index.tsx
Normal file
5
frontend/src/pages/TraceDetailV3Page/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
|
||||
export default function TraceDetailV3Page(): JSX.Element {
|
||||
return <TraceDetailsV3 />;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.analytics-panel {
|
||||
&__body {
|
||||
padding: 12px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
|
||||
// TabsRoot — last direct child div
|
||||
> div:last-child {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[role='tablist'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
gap: 4px 8px;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__service-name {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__bar-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--l3-background);
|
||||
border-radius: 3px;
|
||||
min-width: 40px;
|
||||
|
||||
&--small {
|
||||
max-width: 80px;
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
}
|
||||
|
||||
&__bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&--wide {
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
&--narrow {
|
||||
min-width: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs root
|
||||
[class*='tabs__list-wrapper'] {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
|
||||
import { DetailsHeader } from 'components/DetailsPanel';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import './AnalyticsPanel.styles.scss';
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
// TODO: Re-enable when backend provides per-service span counts
|
||||
// spans?: Span[];
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
const PANEL_MARGIN_RIGHT = 100;
|
||||
const PANEL_MARGIN_TOP = 50;
|
||||
const PANEL_MARGIN_BOTTOM = 50;
|
||||
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
serviceExecTime = {},
|
||||
traceStartTime = 0,
|
||||
traceEndTime = 0,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const spread = traceEndTime - traceStartTime;
|
||||
|
||||
const execTimeRows = useMemo(() => {
|
||||
if (spread <= 0) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(serviceExecTime)
|
||||
.map(([service, duration]) => ({
|
||||
service,
|
||||
percentage: (duration * 100) / spread,
|
||||
color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
}, [serviceExecTime, spread]);
|
||||
|
||||
// const spanCountRows = useMemo(() => {
|
||||
// const counts: Record<string, number> = {};
|
||||
// for (const span of spans) {
|
||||
// const name = span.serviceName || 'unknown';
|
||||
// counts[name] = (counts[name] || 0) + 1;
|
||||
// }
|
||||
// return Object.entries(counts)
|
||||
// .map(([service, count]) => ({
|
||||
// service,
|
||||
// count,
|
||||
// color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
// }))
|
||||
// .sort((a, b) => b.count - a.count);
|
||||
// }, [spans]);
|
||||
|
||||
// const maxSpanCount = spanCountRows[0]?.count || 1;
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
className="analytics-panel"
|
||||
width={PANEL_WIDTH}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
y: PANEL_MARGIN_TOP,
|
||||
}}
|
||||
enableResizing={{
|
||||
top: true,
|
||||
bottom: true,
|
||||
left: false,
|
||||
right: false,
|
||||
topLeft: false,
|
||||
topRight: false,
|
||||
bottomLeft: false,
|
||||
bottomRight: false,
|
||||
}}
|
||||
>
|
||||
<DetailsHeader
|
||||
title="Analytics"
|
||||
onClose={onClose}
|
||||
className="floating-panel__drag-handle"
|
||||
/>
|
||||
|
||||
<div className="analytics-panel__body">
|
||||
<TabsRoot defaultValue="exec-time">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
</TabsTrigger>
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsTrigger value="spans" variant="secondary">
|
||||
Spans
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
</TabsList>
|
||||
|
||||
<div className="analytics-panel__tabs-scroll">
|
||||
<TabsContent value="exec-time">
|
||||
<div className="analytics-panel__list">
|
||||
{execTimeRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${Math.min(row.percentage, 100)}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="analytics-panel__value analytics-panel__value--wide">
|
||||
{row.percentage.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsContent value="spans">
|
||||
<div className="analytics-panel__list">
|
||||
{spanCountRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${(row.count / maxSpanCount) * 100}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="analytics-panel__value analytics-panel__value--narrow">
|
||||
{row.count}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
*/}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsPanel;
|
||||
@@ -0,0 +1,34 @@
|
||||
.linked-spans {
|
||||
position: relative;
|
||||
|
||||
&__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
&__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
|
||||
import './LinkedSpans.styles.scss';
|
||||
|
||||
interface SpanReference {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
interface LinkedSpansProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
references: any;
|
||||
}
|
||||
|
||||
interface LinkedSpansState {
|
||||
linkedSpans: SpanReference[];
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
export function useLinkedSpans(references: any): LinkedSpansState {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const linkedSpans: SpanReference[] = useMemo(
|
||||
() =>
|
||||
(references || []).filter(
|
||||
(ref: SpanReference) => ref.refType !== 'CHILD_OF',
|
||||
),
|
||||
[references],
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
return {
|
||||
linkedSpans,
|
||||
count: linkedSpans.length,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export function LinkedSpansToggle({
|
||||
count,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
}: {
|
||||
count: number;
|
||||
isOpen: boolean;
|
||||
toggleOpen: () => void;
|
||||
}): JSX.Element {
|
||||
if (count === 0) {
|
||||
return <span className="linked-spans__label">0 linked spans</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
|
||||
<span className="linked-spans__label">
|
||||
{count} linked span{count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkedSpansPanel({
|
||||
linkedSpans,
|
||||
isOpen,
|
||||
}: {
|
||||
linkedSpans: SpanReference[];
|
||||
isOpen: boolean;
|
||||
}): JSX.Element | null {
|
||||
const getLink = useCallback(
|
||||
(item: SpanReference): string =>
|
||||
`${ROUTES.TRACE}/${item.traceId}?spanId=${item.spanId}`,
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isOpen || linkedSpans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="linked-spans__list">
|
||||
{linkedSpans.map((item) => (
|
||||
<KeyValueLabel
|
||||
key={item.spanId}
|
||||
badgeKey="Linked Span ID"
|
||||
badgeValue={
|
||||
<Link to={getLink(item)}>
|
||||
<Badge color="vanilla">{item.spanId}</Badge>
|
||||
</Link>
|
||||
}
|
||||
direction="column"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
|
||||
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
|
||||
|
||||
return (
|
||||
<div className="linked-spans">
|
||||
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
|
||||
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkedSpans;
|
||||
@@ -0,0 +1,154 @@
|
||||
.span-details-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&__header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
background: var(--l1-background);
|
||||
font-size: 14px;
|
||||
gap: 16px;
|
||||
|
||||
.data-viewer {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-section {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__tabs-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// TabsRoot — direct child of tabs-section
|
||||
> div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[role='tablist'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[class*='tabs__list-wrapper'] {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
[role='tabpanel'] {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__span-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
&__span-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__highlighted-options {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
|
||||
.key-value-label {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&__service-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success-500);
|
||||
}
|
||||
|
||||
&__trace-id {
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__key-attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
&-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.48px;
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
&-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
|
||||
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
|
||||
// tooltip (z-index 50). Bump the tooltip above the panel.
|
||||
.dock-toggle-tooltip {
|
||||
--tooltip-z-index: 1000;
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
ChartBar,
|
||||
ChartColumnBig,
|
||||
Dock,
|
||||
Link2,
|
||||
Logs,
|
||||
PanelBottom,
|
||||
ScrollText,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
|
||||
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
|
||||
SpanDetailVariant,
|
||||
VISIBLE_ACTIONS,
|
||||
} from './constants';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
useLinkedSpans,
|
||||
} from './LinkedSpans/LinkedSpans';
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
|
||||
import './SpanDetailsPanel.styles.scss';
|
||||
|
||||
interface SpanDetailsPanelProps {
|
||||
panelState: DetailsPanelState;
|
||||
selectedSpan: SpanV3 | undefined;
|
||||
variant?: SpanDetailVariant;
|
||||
onVariantChange?: (variant: SpanDetailVariant) => void;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
}
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
}: {
|
||||
selectedSpan: SpanV3;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
// Map span attribute actions to PrettyView actions format.
|
||||
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
|
||||
const prettyViewCustomActions = useMemo(
|
||||
() =>
|
||||
spanAttributeActions.map((action) => ({
|
||||
key: action.value,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
shouldHide: action.shouldHide,
|
||||
onClick: (context: {
|
||||
fieldKey: string;
|
||||
fieldKeyPath: (string | number)[];
|
||||
fieldValue: unknown;
|
||||
}): void => {
|
||||
const leafKey = getLeafKeyFromPath(context.fieldKeyPath, context.fieldKey);
|
||||
action.callback({
|
||||
key: leafKey,
|
||||
value: String(context.fieldValue),
|
||||
});
|
||||
},
|
||||
})),
|
||||
[spanAttributeActions],
|
||||
);
|
||||
|
||||
// const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
// Key attributes section — commented out as pinning in PrettyView covers this use case.
|
||||
// Uncomment when key attributes need to be shown separately.
|
||||
// const buildKeyAttrMenu = useCallback(
|
||||
// (key: string, value: string): ActionMenuItem[] => {
|
||||
// const items: ActionMenuItem[] = [
|
||||
// {
|
||||
// key: 'copy-value',
|
||||
// label: 'Copy Value',
|
||||
// icon: <Copy size={12} />,
|
||||
// onClick: (): void => {
|
||||
// setCopy(value);
|
||||
// toast.success('Copied to clipboard', {
|
||||
// richColors: true,
|
||||
// position: 'top-right',
|
||||
// });
|
||||
// },
|
||||
// },
|
||||
// ];
|
||||
// spanAttributeActions.forEach((action) => {
|
||||
// if (action.shouldHide && action.shouldHide(key)) {
|
||||
// return;
|
||||
// }
|
||||
// items.push({
|
||||
// key: action.value,
|
||||
// label: action.label,
|
||||
// icon: action.icon,
|
||||
// onClick: (): void => {
|
||||
// action.callback({ key, value });
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// return items;
|
||||
// },
|
||||
// [spanAttributeActions, setCopy],
|
||||
// );
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading: isLogsLoading,
|
||||
isError: isLogsError,
|
||||
isFetching: isLogsFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.trace_id,
|
||||
spanId: selectedSpan.span_id,
|
||||
timeRange: {
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: true,
|
||||
});
|
||||
|
||||
const infraMetadata = useMemo(() => {
|
||||
if (!hasInfraMetadata(selectedSpan)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
|
||||
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
|
||||
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
|
||||
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
|
||||
|
||||
const traceIdFilter = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.trace_id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: traceIdFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
|
||||
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyLogsStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// const keyAttributes = useMemo(() => {
|
||||
// const keys = KEY_ATTRIBUTE_KEYS.traces || [];
|
||||
// const allAttrs: Record<string, string> = {};
|
||||
// Object.entries(selectedSpan.resource || {}).forEach(([k, v]) => {
|
||||
// allAttrs[k] = String(v);
|
||||
// });
|
||||
// Object.entries(selectedSpan.attributes || {}).forEach(([k, v]) => {
|
||||
// allAttrs[k] = String(v);
|
||||
// });
|
||||
// const span = (selectedSpan as unknown) as Record<string, unknown>;
|
||||
// keys.forEach((key) => {
|
||||
// if (!(key in allAttrs) && span[key] != null && span[key] !== '') {
|
||||
// allAttrs[key] = String(span[key]);
|
||||
// }
|
||||
// });
|
||||
// return keys
|
||||
// .filter((key) => allAttrs[key])
|
||||
// .map((key) => ({ key, value: allAttrs[key] }));
|
||||
// }, [selectedSpan]);
|
||||
|
||||
return (
|
||||
<div className="span-details-panel__body">
|
||||
<div className="span-details-panel__details-section">
|
||||
<div className="span-details-panel__span-row">
|
||||
<KeyValueLabel
|
||||
badgeKey="Span name"
|
||||
badgeValue={selectedSpan.name}
|
||||
maxCharacters={50}
|
||||
/>
|
||||
<SpanPercentileBadge
|
||||
loading={percentile.loading}
|
||||
percentileValue={percentile.percentileValue}
|
||||
duration={percentile.duration}
|
||||
spanPercentileData={percentile.spanPercentileData}
|
||||
isOpen={percentile.isOpen}
|
||||
toggleOpen={percentile.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
|
||||
|
||||
{/* Span info: exec time + start time */}
|
||||
<div className="span-details-panel__span-info">
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<Timer size={14} />
|
||||
<span>
|
||||
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
|
||||
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
|
||||
<>
|
||||
{' — '}
|
||||
<strong>
|
||||
{(
|
||||
(selectedSpan.duration_nano * 100) /
|
||||
((traceEndTime - traceStartTime) * 1e6)
|
||||
).toFixed(2)}
|
||||
%
|
||||
</strong>
|
||||
{' of total exec time'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<CalendarClock size={14} />
|
||||
<span>
|
||||
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="span-details-panel__span-info-item">
|
||||
<Link2 size={14} />
|
||||
<LinkedSpansToggle
|
||||
count={linkedSpans.count}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
toggleOpen={linkedSpans.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkedSpansPanel
|
||||
linkedSpans={linkedSpans.linkedSpans}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
/>
|
||||
|
||||
{/* Step 6: HighlightedOptions */}
|
||||
<div className="span-details-panel__highlighted-options">
|
||||
{HIGHLIGHTED_OPTIONS.map((option) => {
|
||||
const rendered = option.render(selectedSpan);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<KeyValueLabel
|
||||
key={option.key}
|
||||
badgeKey={option.label}
|
||||
badgeValue={rendered}
|
||||
direction="column"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
|
||||
{keyAttributes.length > 0 && (
|
||||
<div className="span-details-panel__key-attributes">
|
||||
<div className="span-details-panel__key-attributes-label">
|
||||
KEY ATTRIBUTES
|
||||
</div>
|
||||
<div className="span-details-panel__key-attributes-chips">
|
||||
{keyAttributes.map(({ key, value }) => (
|
||||
<ActionMenu
|
||||
key={key}
|
||||
items={buildKeyAttrMenu(key, value)}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div>
|
||||
<KeyValueLabel badgeKey={key} badgeValue={value} />
|
||||
</div>
|
||||
</ActionMenu>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Step 8: MiniTraceContext */}
|
||||
</div>
|
||||
|
||||
<div className="span-details-panel__tabs-section">
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<Logs size={14} /> Logs
|
||||
</TabsTrigger>
|
||||
{infraMetadata && (
|
||||
<TabsTrigger value="metrics" variant="secondary">
|
||||
<ChartColumnBig size={14} /> Metrics
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="span-details-panel__tabs-scroll">
|
||||
<TabsContent value="overview">
|
||||
<DataViewer
|
||||
data={selectedSpan}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
|
||||
<Events
|
||||
span={{ ...selectedSpan, event: selectedSpan.events } as any}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.trace_id}
|
||||
spanId={selectedSpan.span_id}
|
||||
timeRange={{
|
||||
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
|
||||
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLogsLoading}
|
||||
isError={isLogsError}
|
||||
isFetching={isLogsFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{infraMetadata && (
|
||||
<TabsContent value="metrics">
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanDetailsPanel({
|
||||
panelState,
|
||||
selectedSpan,
|
||||
variant = SpanDetailVariant.DIALOG,
|
||||
onVariantChange,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
serviceExecTime,
|
||||
}: SpanDetailsPanelProps): JSX.Element {
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
|
||||
const headerActions = useMemo((): HeaderAction[] => {
|
||||
const actions: HeaderAction[] = [
|
||||
{
|
||||
key: 'analytics',
|
||||
component: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<ChartBar size={14} />}
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
// TODO: Add back when driven through separate config for different pages
|
||||
// {
|
||||
// key: 'view-full-trace',
|
||||
// component: (
|
||||
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
|
||||
// View full trace
|
||||
// </Button>
|
||||
// ),
|
||||
// },
|
||||
// TODO: Add back when used in trace explorer page
|
||||
// {
|
||||
// key: 'nav',
|
||||
// component: (
|
||||
// <div className="span-details-panel__header-nav">
|
||||
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
|
||||
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
if (onVariantChange) {
|
||||
const isDocked = variant === SpanDetailVariant.DOCKED;
|
||||
actions.push({
|
||||
key: 'dock-toggle',
|
||||
component: (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
onVariantChange(
|
||||
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="dock-toggle-tooltip">
|
||||
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [variant, onVariantChange]);
|
||||
|
||||
const PANEL_WIDTH = 500;
|
||||
const PANEL_MARGIN_RIGHT = 20;
|
||||
const PANEL_MARGIN_TOP = 25;
|
||||
const PANEL_MARGIN_BOTTOM = 25;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DetailsHeader
|
||||
title="Span details"
|
||||
onClose={panelState.close}
|
||||
actions={headerActions}
|
||||
className={
|
||||
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
|
||||
}
|
||||
/>
|
||||
{selectedSpan ? (
|
||||
<SpanDetailsContent
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
) : (
|
||||
<div className="span-details-panel__body">
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const analyticsPanel = (
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
serviceExecTime={serviceExecTime}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED) {
|
||||
return (
|
||||
<>
|
||||
<div className="span-details-panel">{content}</div>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === SpanDetailVariant.DRAWER) {
|
||||
return (
|
||||
<>
|
||||
<DetailsPanelDrawer
|
||||
isOpen={panelState.isOpen}
|
||||
onClose={panelState.close}
|
||||
className="span-details-panel"
|
||||
>
|
||||
{content}
|
||||
</DetailsPanelDrawer>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingPanel
|
||||
isOpen={panelState.isOpen}
|
||||
className="span-details-panel"
|
||||
width={PANEL_WIDTH}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
y: PANEL_MARGIN_TOP,
|
||||
}}
|
||||
enableResizing={{
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
left: true,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</FloatingPanel>
|
||||
{analyticsPanel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanDetailsPanel;
|
||||
@@ -0,0 +1,258 @@
|
||||
// Badge — wraps a KeyValueLabel, clickable to toggle panel
|
||||
.span-percentile-badge {
|
||||
cursor: pointer;
|
||||
|
||||
// Override key color for the percentile value (p99)
|
||||
.key-value-label__key {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&__loader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Panel — collapsible, renders below the row
|
||||
.span-percentile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
margin: 8px 16px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
&-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
&-highlight {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&-loader {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
margin: 0 4px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__timerange {
|
||||
width: 100%;
|
||||
|
||||
&-select {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__table {
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
&-text {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&-rows {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-skeleton {
|
||||
.ant-skeleton-title {
|
||||
width: 100% !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.ant-skeleton-paragraph {
|
||||
margin-top: 8px;
|
||||
|
||||
& > li + li {
|
||||
margin-top: 10px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 4px;
|
||||
|
||||
&-key {
|
||||
flex: 0 0 auto;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-value {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&-dash {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
margin: 0 8px;
|
||||
border-top: 1px solid transparent;
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--l1-border) 0,
|
||||
var(--l1-border) 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
&--current {
|
||||
border-radius: 2px;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
|
||||
.span-percentile-panel__table-row-key {
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.span-percentile-panel__table-row-dash {
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#abbdff 0,
|
||||
#abbdff 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
.span-percentile-panel__table-row-value {
|
||||
color: var(--text-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__resource-selector {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
z-index: 1000;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
&-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&-input {
|
||||
border-radius: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&-items {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
|
||||
&-value {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
|
||||
import { UseSpanPercentileReturn } from './useSpanPercentile';
|
||||
|
||||
import './SpanPercentile.styles.scss';
|
||||
|
||||
type SpanPercentileBadgeProps = Pick<
|
||||
UseSpanPercentileReturn,
|
||||
| 'loading'
|
||||
| 'percentileValue'
|
||||
| 'duration'
|
||||
| 'spanPercentileData'
|
||||
| 'isOpen'
|
||||
| 'toggleOpen'
|
||||
>;
|
||||
|
||||
function SpanPercentileBadge({
|
||||
loading,
|
||||
percentileValue,
|
||||
duration,
|
||||
spanPercentileData,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
}: SpanPercentileBadgeProps): JSX.Element | null {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="span-percentile-badge__loader">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!spanPercentileData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="span-percentile-badge"
|
||||
onClick={toggleOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
toggleOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyValueLabel
|
||||
badgeKey={`p${percentileValue}`}
|
||||
badgeValue={
|
||||
<span className="span-percentile-badge__value">
|
||||
{duration}
|
||||
{isOpen ? (
|
||||
<ChevronUp size={14} className="span-percentile-badge__icon" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="span-percentile-badge__icon" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanPercentileBadge;
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Checkbox, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Check, ChevronDown, Loader2, PlusIcon } from 'lucide-react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { UseSpanPercentileReturn } from './useSpanPercentile';
|
||||
|
||||
import './SpanPercentile.styles.scss';
|
||||
|
||||
const DEFAULT_RESOURCE_ATTRIBUTES = {
|
||||
serviceName: 'service.name',
|
||||
name: 'name',
|
||||
};
|
||||
|
||||
const timerangeOptions = [1, 2, 4, 6, 12, 24].map((hours) => ({
|
||||
label: `${hours}h`,
|
||||
value: hours,
|
||||
}));
|
||||
|
||||
interface SpanPercentilePanelProps {
|
||||
selectedSpan: SpanV3;
|
||||
percentile: UseSpanPercentileReturn;
|
||||
}
|
||||
|
||||
function SpanPercentilePanel({
|
||||
selectedSpan,
|
||||
percentile,
|
||||
}: SpanPercentilePanelProps): JSX.Element | null {
|
||||
const {
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
isError,
|
||||
loading,
|
||||
spanPercentileData,
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange,
|
||||
showResourceAttributesSelector,
|
||||
setShowResourceAttributesSelector,
|
||||
resourceAttributesSearchQuery,
|
||||
setResourceAttributesSearchQuery,
|
||||
spanResourceAttributes,
|
||||
handleResourceAttributeChange,
|
||||
resourceAttributesSelectorRef,
|
||||
isLoadingData,
|
||||
isFetchingData,
|
||||
} = percentile;
|
||||
|
||||
if (!isOpen || isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="span-percentile-panel">
|
||||
<div className="span-percentile-panel__header">
|
||||
<Typography.Text
|
||||
className="span-percentile-panel__header-text"
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<ChevronDown size={16} /> Span Percentile
|
||||
</Typography.Text>
|
||||
|
||||
{showResourceAttributesSelector ? (
|
||||
<Check
|
||||
size={16}
|
||||
className="cursor-pointer span-percentile-panel__header-icon"
|
||||
onClick={(): void => setShowResourceAttributesSelector(false)}
|
||||
/>
|
||||
) : (
|
||||
<PlusIcon
|
||||
size={16}
|
||||
className="cursor-pointer span-percentile-panel__header-icon"
|
||||
onClick={(): void => setShowResourceAttributesSelector(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showResourceAttributesSelector && (
|
||||
<div
|
||||
className="span-percentile-panel__resource-selector"
|
||||
ref={resourceAttributesSelectorRef}
|
||||
>
|
||||
<div className="span-percentile-panel__resource-selector-header">
|
||||
<Input
|
||||
placeholder="Search resource attributes"
|
||||
className="span-percentile-panel__resource-selector-input"
|
||||
value={resourceAttributesSearchQuery}
|
||||
onChange={(e): void =>
|
||||
setResourceAttributesSearchQuery(e.target.value as string)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="span-percentile-panel__resource-selector-items">
|
||||
{spanResourceAttributes
|
||||
.filter((attr) =>
|
||||
attr.key
|
||||
.toLowerCase()
|
||||
.includes(resourceAttributesSearchQuery.toLowerCase()),
|
||||
)
|
||||
.map((attr) => (
|
||||
<div
|
||||
className="span-percentile-panel__resource-selector-item"
|
||||
key={attr.key}
|
||||
>
|
||||
<Checkbox
|
||||
checked={attr.isSelected}
|
||||
onChange={(e): void => {
|
||||
handleResourceAttributeChange(
|
||||
attr.key,
|
||||
attr.value,
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
attr.key === DEFAULT_RESOURCE_ATTRIBUTES.name
|
||||
}
|
||||
>
|
||||
<div className="span-percentile-panel__resource-selector-item-value">
|
||||
{attr.key}
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="span-percentile-panel__content">
|
||||
<Typography.Text className="span-percentile-panel__content-title">
|
||||
This span duration is{' '}
|
||||
{!loading && spanPercentileData ? (
|
||||
<span className="span-percentile-panel__content-highlight">
|
||||
p{Math.floor(spanPercentileData.percentile || 0)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="span-percentile-panel__content-loader">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
</span>
|
||||
)}{' '}
|
||||
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
|
||||
hour(s) since the span start time.
|
||||
</Typography.Text>
|
||||
|
||||
<div className="span-percentile-panel__timerange">
|
||||
<Select
|
||||
labelInValue
|
||||
placeholder="Select timerange"
|
||||
className="span-percentile-panel__timerange-select"
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement || document.body
|
||||
}
|
||||
value={{
|
||||
label: `${selectedTimeRange}h : ${dayjs(selectedSpan.timestamp)
|
||||
.subtract(selectedTimeRange, 'hour')
|
||||
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
|
||||
selectedSpan.timestamp,
|
||||
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
|
||||
value: selectedTimeRange,
|
||||
}}
|
||||
onChange={(value): void => {
|
||||
setSelectedTimeRange(Number(value.value));
|
||||
}}
|
||||
options={timerangeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="span-percentile-panel__table">
|
||||
<div className="span-percentile-panel__table-header">
|
||||
<Typography.Text className="span-percentile-panel__table-header-text">
|
||||
Percentile
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-percentile-panel__table-header-text">
|
||||
Duration
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="span-percentile-panel__table-rows">
|
||||
{isLoadingData || isFetchingData ? (
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 3 }}
|
||||
className="span-percentile-panel__table-skeleton"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(spanPercentileData?.percentiles || {}).map(
|
||||
([pKey, pDuration]) => (
|
||||
<div className="span-percentile-panel__table-row" key={pKey}>
|
||||
<Typography.Text className="span-percentile-panel__table-row-key">
|
||||
{pKey}
|
||||
</Typography.Text>
|
||||
<div className="span-percentile-panel__table-row-dash" />
|
||||
<Typography.Text className="span-percentile-panel__table-row-value">
|
||||
{getYAxisFormattedValue(`${pDuration / 1000000}`, 'ms')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
<div className="span-percentile-panel__table-row span-percentile-panel__table-row--current">
|
||||
<Typography.Text className="span-percentile-panel__table-row-key">
|
||||
p{Math.floor(spanPercentileData?.percentile || 0)}
|
||||
</Typography.Text>
|
||||
<div className="span-percentile-panel__table-row-dash" />
|
||||
<Typography.Text className="span-percentile-panel__table-row-value">
|
||||
(this span){' '}
|
||||
{getYAxisFormattedValue(
|
||||
`${selectedSpan.duration_nano / 1000000}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanPercentilePanel;
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import dayjs from 'dayjs';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export interface IResourceAttribute {
|
||||
key: string;
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RESOURCE_ATTRIBUTES = {
|
||||
serviceName: 'service.name',
|
||||
name: 'name',
|
||||
};
|
||||
|
||||
export interface UseSpanPercentileReturn {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
toggleOpen: () => void;
|
||||
loading: boolean;
|
||||
percentileValue: number;
|
||||
duration: string;
|
||||
spanPercentileData: {
|
||||
percentile: number;
|
||||
description: string;
|
||||
percentiles: Record<string, number>;
|
||||
} | null;
|
||||
isError: boolean;
|
||||
selectedTimeRange: number;
|
||||
setSelectedTimeRange: (range: number) => void;
|
||||
showResourceAttributesSelector: boolean;
|
||||
setShowResourceAttributesSelector: (show: boolean) => void;
|
||||
resourceAttributesSearchQuery: string;
|
||||
setResourceAttributesSearchQuery: (query: string) => void;
|
||||
spanResourceAttributes: IResourceAttribute[];
|
||||
handleResourceAttributeChange: (
|
||||
key: string,
|
||||
value: string,
|
||||
isSelected: boolean,
|
||||
) => void;
|
||||
resourceAttributesSelectorRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
isLoadingData: boolean;
|
||||
isFetchingData: boolean;
|
||||
}
|
||||
|
||||
function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState(1);
|
||||
const [resourceAttributesSearchQuery, setResourceAttributesSearchQuery] =
|
||||
useState('');
|
||||
const [spanPercentileData, setSpanPercentileData] = useState<{
|
||||
percentile: number;
|
||||
description: string;
|
||||
percentiles: Record<string, number>;
|
||||
} | null>(null);
|
||||
const [showResourceAttributesSelector, setShowResourceAttributesSelector] =
|
||||
useState(false);
|
||||
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
|
||||
IResourceAttribute[]
|
||||
>([]);
|
||||
const [initialWaitCompleted, setInitialWaitCompleted] = useState(false);
|
||||
const [shouldFetchData, setShouldFetchData] = useState(false);
|
||||
const [shouldUpdateUserPreference, setShouldUpdateUserPreference] =
|
||||
useState(false);
|
||||
|
||||
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside({
|
||||
ref: resourceAttributesSelectorRef,
|
||||
onClickOutside: () => {
|
||||
if (resourceAttributesSelectorRef.current) {
|
||||
setShowResourceAttributesSelector(false);
|
||||
}
|
||||
},
|
||||
eventType: 'mousedown',
|
||||
});
|
||||
|
||||
const endTime = useMemo(
|
||||
() => Math.floor(Number(selectedSpan.timestamp) / 1000) * 1000,
|
||||
[selectedSpan.timestamp],
|
||||
);
|
||||
|
||||
const startTime = useMemo(
|
||||
() =>
|
||||
dayjs(selectedSpan.timestamp)
|
||||
.subtract(Number(selectedTimeRange), 'hour')
|
||||
.unix() * 1000,
|
||||
[selectedSpan.timestamp, selectedTimeRange],
|
||||
);
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } =
|
||||
useMutation(updateUserPreference);
|
||||
|
||||
const {
|
||||
data: userSelectedResourceAttributes,
|
||||
isError: isErrorUserSelectedResourceAttributes,
|
||||
} = useQuery({
|
||||
queryFn: () =>
|
||||
getUserPreference({
|
||||
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
}),
|
||||
queryKey: [
|
||||
'getUserPreferenceByPreferenceName',
|
||||
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
selectedSpan.span_id,
|
||||
],
|
||||
enabled: selectedSpan.attributes !== undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isLoadingData,
|
||||
isFetching: isFetchingData,
|
||||
data,
|
||||
refetch: refetchData,
|
||||
isError: isErrorData,
|
||||
} = useQuery({
|
||||
queryFn: () =>
|
||||
getSpanPercentiles({
|
||||
start: startTime || 0,
|
||||
end: endTime || 0,
|
||||
spanDuration: selectedSpan.duration_nano || 0,
|
||||
serviceName: selectedSpan['service.name'] || '',
|
||||
name: selectedSpan.name || '',
|
||||
resourceAttributes: selectedResourceAttributes,
|
||||
}),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
|
||||
selectedSpan.span_id,
|
||||
startTime,
|
||||
endTime,
|
||||
],
|
||||
enabled:
|
||||
shouldFetchData && !showResourceAttributesSelector && initialWaitCompleted,
|
||||
onSuccess: (response) => {
|
||||
if (response.httpStatusCode !== 200) {
|
||||
return;
|
||||
}
|
||||
if (shouldUpdateUserPreference) {
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
value: [...Object.keys(selectedResourceAttributes)],
|
||||
});
|
||||
setShouldUpdateUserPreference(false);
|
||||
}
|
||||
},
|
||||
keepPreviousData: false,
|
||||
cacheTime: 0,
|
||||
});
|
||||
|
||||
// 2-second delay before initial fetch
|
||||
useEffect(() => {
|
||||
setSpanPercentileData(null);
|
||||
setIsOpen(false);
|
||||
setInitialWaitCompleted(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setInitialWaitCompleted(true);
|
||||
}, 2000);
|
||||
|
||||
return (): void => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [selectedSpan.span_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.httpStatusCode !== 200) {
|
||||
setSpanPercentileData(null);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
setSpanPercentileData({
|
||||
percentile: data.data?.position?.percentile || 0,
|
||||
description: data.data?.position?.description || '',
|
||||
percentiles: data.data?.percentiles || {},
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Merge resource + attributes to get all span attributes (equivalent to V2 tagMap).
|
||||
// Stringify all values since the backend expects map[string]string.
|
||||
const allSpanAttributes = useMemo(() => {
|
||||
const merged: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(selectedSpan.resource || {})) {
|
||||
merged[k] = String(v);
|
||||
}
|
||||
for (const [k, v] of Object.entries(selectedSpan.attributes || {})) {
|
||||
merged[k] = String(v);
|
||||
}
|
||||
return merged;
|
||||
}, [selectedSpan.resource, selectedSpan.attributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSelectedResourceAttributes) {
|
||||
const userList = (
|
||||
userSelectedResourceAttributes?.data?.value as string[]
|
||||
).map((attr: string) => attr);
|
||||
let selectedMap: Record<string, string> = {};
|
||||
userList.forEach((attr: string) => {
|
||||
selectedMap[attr] = allSpanAttributes[attr] || '';
|
||||
});
|
||||
selectedMap = Object.fromEntries(
|
||||
Object.entries(selectedMap).filter(
|
||||
([key]) => allSpanAttributes[key] !== undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const resourceAttrs = Object.entries(allSpanAttributes).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
isSelected:
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
|
||||
(key in selectedMap &&
|
||||
selectedMap[key] !== '' &&
|
||||
selectedMap[key] !== undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
const selected = resourceAttrs.filter((a) => a.isSelected);
|
||||
const unselected = resourceAttrs.filter((a) => !a.isSelected);
|
||||
updateSpanResourceAttributes([...selected, ...unselected]);
|
||||
setSelectedResourceAttributes(selectedMap);
|
||||
setShouldFetchData(true);
|
||||
}
|
||||
|
||||
if (isErrorUserSelectedResourceAttributes) {
|
||||
const resourceAttrs = Object.entries(allSpanAttributes).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
isSelected:
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
|
||||
}),
|
||||
);
|
||||
updateSpanResourceAttributes(resourceAttrs);
|
||||
setShouldFetchData(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
userSelectedResourceAttributes,
|
||||
isErrorUserSelectedResourceAttributes,
|
||||
allSpanAttributes,
|
||||
]);
|
||||
|
||||
const handleResourceAttributeChange = useCallback(
|
||||
(key: string, value: string, isSelected: boolean): void => {
|
||||
updateSpanResourceAttributes((prev) =>
|
||||
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
|
||||
);
|
||||
|
||||
const newSelected = { ...selectedResourceAttributes };
|
||||
if (isSelected) {
|
||||
newSelected[key] = value;
|
||||
} else {
|
||||
delete newSelected[key];
|
||||
}
|
||||
setSelectedResourceAttributes(newSelected);
|
||||
setShouldFetchData(true);
|
||||
setShouldUpdateUserPreference(true);
|
||||
},
|
||||
[selectedResourceAttributes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldFetchData &&
|
||||
!showResourceAttributesSelector &&
|
||||
initialWaitCompleted
|
||||
) {
|
||||
refetchData();
|
||||
setShouldFetchData(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldFetchData, showResourceAttributesSelector, initialWaitCompleted]);
|
||||
|
||||
const loading = isLoadingData || isFetchingData;
|
||||
const percentileValue = Math.floor(spanPercentileData?.percentile || 0);
|
||||
const duration = getYAxisFormattedValue(
|
||||
`${selectedSpan.duration_nano / 1000000}`,
|
||||
'ms',
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||
|
||||
const handleTimeRangeChange = useCallback((range: number): void => {
|
||||
setShouldFetchData(true);
|
||||
setSelectedTimeRange(range);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
toggleOpen,
|
||||
loading,
|
||||
percentileValue,
|
||||
duration,
|
||||
spanPercentileData,
|
||||
isError: isErrorData,
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange: handleTimeRangeChange,
|
||||
showResourceAttributesSelector,
|
||||
setShowResourceAttributesSelector,
|
||||
resourceAttributesSearchQuery,
|
||||
setResourceAttributesSearchQuery,
|
||||
spanResourceAttributes,
|
||||
handleResourceAttributeChange,
|
||||
resourceAttributesSelectorRef,
|
||||
isLoadingData,
|
||||
isFetchingData,
|
||||
};
|
||||
}
|
||||
|
||||
export default useSpanPercentile;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
interface HighlightedOption {
|
||||
key: string;
|
||||
label: string;
|
||||
render: (span: SpanV3) => ReactNode | null;
|
||||
}
|
||||
|
||||
export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
{
|
||||
key: 'service',
|
||||
label: 'SERVICE',
|
||||
render: (span): ReactNode | null =>
|
||||
span['service.name'] ? (
|
||||
<Badge color="vanilla">
|
||||
<span className="span-details-panel__service-dot" />
|
||||
{span['service.name']}
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'statusCodeString',
|
||||
label: 'STATUS CODE STRING',
|
||||
render: (span): ReactNode | null =>
|
||||
span.status_code_string ? (
|
||||
<Badge color="vanilla">{span.status_code_string}</Badge>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'traceId',
|
||||
label: 'TRACE ID',
|
||||
render: (span): ReactNode | null =>
|
||||
span.trace_id ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/trace/${span.trace_id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
className="span-details-panel__trace-id"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'spanKind',
|
||||
label: 'SPAN KIND',
|
||||
render: (span): ReactNode | null =>
|
||||
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SPAN_ACTION } from './hooks/useSpanAttributeActions';
|
||||
|
||||
// Action identifiers for built-in PrettyView actions (copy, pin)
|
||||
export const PRETTY_VIEW_ACTION = {
|
||||
COPY: 'copy',
|
||||
PIN: 'pin',
|
||||
} as const;
|
||||
|
||||
// Which actions are visible per node type — drives the entire menu
|
||||
export const VISIBLE_ACTIONS = {
|
||||
leaf: [
|
||||
PRETTY_VIEW_ACTION.COPY,
|
||||
PRETTY_VIEW_ACTION.PIN,
|
||||
SPAN_ACTION.FILTER_IN,
|
||||
SPAN_ACTION.FILTER_OUT,
|
||||
SPAN_ACTION.GROUP_BY,
|
||||
],
|
||||
nested: [PRETTY_VIEW_ACTION.COPY],
|
||||
} as const;
|
||||
|
||||
export enum SpanDetailVariant {
|
||||
DRAWER = 'drawer',
|
||||
DIALOG = 'dialog',
|
||||
DOCKED = 'docked',
|
||||
}
|
||||
|
||||
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
|
||||
traces: [
|
||||
'service.name',
|
||||
'service.namespace',
|
||||
'deployment.environment',
|
||||
'timestamp',
|
||||
'duration_nano',
|
||||
'kind_string',
|
||||
'status_code_string',
|
||||
'http_method',
|
||||
'http_url',
|
||||
'http_host',
|
||||
'db_name',
|
||||
'db_operation',
|
||||
'external_http_method',
|
||||
'external_http_url',
|
||||
'response_status_code',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { ArrowDownToDot, ArrowUpFromDot } from 'lucide-react';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export interface SpanAttributeAction {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
callback: (args: { key: string; value: string; dataType?: string }) => void;
|
||||
/** Returns true if this action should be hidden for the given field key */
|
||||
shouldHide: (key: string) => boolean;
|
||||
}
|
||||
|
||||
// Keys that should NOT support filter/group-by actions.
|
||||
// These are system/internal/computed fields, not actual queryable attributes.
|
||||
export const NON_FILTERABLE_KEYS = new Set([
|
||||
'datetime',
|
||||
'duration',
|
||||
'parent_span_id',
|
||||
'has_children',
|
||||
'has_sibling',
|
||||
'sub_tree_node_count',
|
||||
'flags',
|
||||
'trace_state',
|
||||
'timestamp',
|
||||
]);
|
||||
|
||||
const shouldHideForKey = (key: string): boolean => NON_FILTERABLE_KEYS.has(key);
|
||||
|
||||
// Action identifiers
|
||||
export const SPAN_ACTION = {
|
||||
FILTER_IN: 'filter-in',
|
||||
FILTER_OUT: 'filter-out',
|
||||
GROUP_BY: 'group-by',
|
||||
} as const;
|
||||
|
||||
export function useSpanAttributeActions(): SpanAttributeAction[] {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const getAutocompleteKey = useCallback(
|
||||
async (fieldKey: string): Promise<BaseAutocompleteData> => {
|
||||
const response = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
|
||||
return chooseAutocompleteFromCustomValue(
|
||||
response.payload?.attributeKeys || [],
|
||||
fieldKey,
|
||||
DataTypes.String,
|
||||
);
|
||||
},
|
||||
[queryClient, currentQuery.builder.queryData],
|
||||
);
|
||||
|
||||
const handleFilter = useCallback(
|
||||
async (
|
||||
{ key, value }: { key: string; value: string },
|
||||
operator: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const autocompleteKey = await getAutocompleteKey(key);
|
||||
const resolvedOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => {
|
||||
const cleanedFilters = (item.filters?.items || []).filter(
|
||||
(f) => f.key?.key !== autocompleteKey.key,
|
||||
);
|
||||
const newFilters = [
|
||||
...cleanedFilters,
|
||||
{
|
||||
id: uuid(),
|
||||
key: autocompleteKey,
|
||||
op: resolvedOperator,
|
||||
value,
|
||||
},
|
||||
];
|
||||
const converted = convertFiltersToExpressionWithExistingQuery(
|
||||
{ items: newFilters, op: item.filters?.op || 'AND' },
|
||||
item.filter?.expression || '',
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
filters: converted.filters,
|
||||
filter: converted.filter,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.LIST },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
} catch {
|
||||
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
|
||||
}
|
||||
},
|
||||
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const handleFilterIn = useCallback(
|
||||
(args: { key: string; value: string }): void => {
|
||||
handleFilter(args, '=');
|
||||
},
|
||||
[handleFilter],
|
||||
);
|
||||
|
||||
const handleFilterOut = useCallback(
|
||||
(args: { key: string; value: string }): void => {
|
||||
handleFilter(args, '!=');
|
||||
},
|
||||
[handleFilter],
|
||||
);
|
||||
|
||||
const handleGroupBy = useCallback(
|
||||
async ({ key }: { key: string }): Promise<void> => {
|
||||
try {
|
||||
const autocompleteKey = await getAutocompleteKey(key);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
dataSource: DataSource.TRACES,
|
||||
groupBy: [...item.groupBy, autocompleteKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(
|
||||
nextQuery,
|
||||
{ panelTypes: PANEL_TYPES.TIME_SERIES },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
} catch {
|
||||
toast.error(SOMETHING_WENT_WRONG, { position: 'top-right' });
|
||||
}
|
||||
},
|
||||
[currentQuery, getAutocompleteKey, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Filter for value',
|
||||
value: SPAN_ACTION.FILTER_IN,
|
||||
icon: React.createElement(ArrowDownToDot, {
|
||||
size: 14,
|
||||
style: { transform: 'rotate(90deg)' },
|
||||
}),
|
||||
callback: handleFilterIn,
|
||||
shouldHide: shouldHideForKey,
|
||||
},
|
||||
{
|
||||
label: 'Filter out value',
|
||||
value: SPAN_ACTION.FILTER_OUT,
|
||||
icon: React.createElement(ArrowUpFromDot, {
|
||||
size: 14,
|
||||
style: { transform: 'rotate(90deg)' },
|
||||
}),
|
||||
callback: handleFilterOut,
|
||||
shouldHide: shouldHideForKey,
|
||||
},
|
||||
{
|
||||
label: 'Group by attribute',
|
||||
value: SPAN_ACTION.GROUP_BY,
|
||||
icon: React.createElement(GroupByIcon),
|
||||
callback: handleGroupBy,
|
||||
shouldHide: shouldHideForKey,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.event-tooltip-content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
max-width: 300px;
|
||||
|
||||
&__header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-robin-400);
|
||||
|
||||
&.error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 11px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
&__attributes {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&__kv {
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__key {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { Diamond } from 'lucide-react';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './EventTooltipContent.styles.scss';
|
||||
|
||||
export interface EventTooltipContentProps {
|
||||
eventName: string;
|
||||
timeOffsetMs: number;
|
||||
isError: boolean;
|
||||
attributeMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export function EventTooltipContent({
|
||||
eventName,
|
||||
timeOffsetMs,
|
||||
isError,
|
||||
attributeMap,
|
||||
}: EventTooltipContentProps): JSX.Element {
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
|
||||
|
||||
return (
|
||||
<div className="event-tooltip-content">
|
||||
<div className="event-tooltip-content__header">
|
||||
<Diamond size={10} />
|
||||
<span>EVENT DETAILS</span>
|
||||
</div>
|
||||
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
|
||||
{eventName}
|
||||
</div>
|
||||
<div className="event-tooltip-content__time">
|
||||
{toFixed(time, 2)} {timeUnitName} from start
|
||||
</div>
|
||||
{Object.keys(attributeMap).length > 0 && (
|
||||
<>
|
||||
<div className="event-tooltip-content__divider" />
|
||||
<div className="event-tooltip-content__attributes">
|
||||
{Object.entries(attributeMap).map(([key, value]) => (
|
||||
<div key={key} className="event-tooltip-content__kv">
|
||||
<span className="event-tooltip-content__key">{key}:</span>{' '}
|
||||
<span className="event-tooltip-content__value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.span-hover-card-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.span-hover-card-popover {
|
||||
// Event-dot tooltip is rendered while the SpanDetailsPanel may be docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index 50.
|
||||
--tooltip-z-index: 1000;
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.
|
||||
// Position is set inline on the element (left/top track the cursor); the
|
||||
// static layout/decoration lives here.
|
||||
.flamegraph-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.span-hover-card-content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SpanTooltipContentProps {
|
||||
spanName: string;
|
||||
color: string;
|
||||
hasError: boolean;
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
spanName,
|
||||
color,
|
||||
hasError,
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className="span-hover-card-content">
|
||||
<div className="span-hover-card-content__name" style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Status: {hasError ? 'error' : 'ok'}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Start: {toFixed(relativeStartMs, 2)} ms
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: SpanV3;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy hover card — only mounts the expensive antd Popover when the user
|
||||
* actually hovers over the element (after a short delay). During fast scrolling,
|
||||
* rows mount and unmount without ever creating a Popover instance, avoiding
|
||||
* expensive DOM/effect overhead from antd Tooltip/Trigger internals.
|
||||
*/
|
||||
const SpanHoverCard = memo(function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowPopover(true);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setShowPopover(false);
|
||||
}, []);
|
||||
|
||||
if (!showPopover) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
|
||||
<span
|
||||
className="span-hover-card-wrapper"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const durationMs = span.duration_nano / 1e6;
|
||||
const relativeStartMs = span.timestamp - traceMetadata.startTime;
|
||||
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
if (span.has_error) {
|
||||
color = 'var(--bg-cherry-500)';
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open
|
||||
content={
|
||||
<SpanTooltipContent
|
||||
spanName={span.name}
|
||||
color={color}
|
||||
hasError={span.has_error}
|
||||
relativeStartMs={relativeStartMs}
|
||||
durationMs={durationMs}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card-popover"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<span className="span-hover-card-wrapper" onMouseLeave={handleMouseLeave}>
|
||||
{children}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -0,0 +1,28 @@
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 8px;
|
||||
|
||||
&__back-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key-value-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__filter {
|
||||
&.trace-v3-filter-row {
|
||||
padding: 0;
|
||||
}
|
||||
max-width: 850px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__old-view-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
noData?: boolean;
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
noData,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID]);
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
const isSpaNavigate =
|
||||
document.referrer &&
|
||||
// oxlint-disable-next-line signoz/no-raw-absolute-path
|
||||
new URL(document.referrer).origin === window.location.origin;
|
||||
const hasBackHistory = window.history.length > 1;
|
||||
|
||||
if (isSpaNavigate && hasBackHistory) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__back-btn"
|
||||
onClick={handlePreviousBtnClick}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<KeyValueLabel
|
||||
badgeKey="Trace ID"
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
{!noData && (
|
||||
<>
|
||||
<div className="trace-details-header__filter">
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__old-view-btn"
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Old View
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsHeader;
|
||||
107
frontend/src/pages/TraceDetailsV3/TraceDetailsV3.styles.scss
Normal file
107
frontend/src/pages/TraceDetailsV3/TraceDetailsV3.styles.scss
Normal file
@@ -0,0 +1,107 @@
|
||||
.trace-details-v3 {
|
||||
height: calc(100vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__flame-collapse,
|
||||
&__waterfall-collapse {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
// Disable collapse animation — virtualizer and canvas flicker during height transitions
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__collapse-count {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__flame-collapse {
|
||||
flex-shrink: 0;
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__waterfall-collapse {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-collapse-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ant-collapse-content.ant-collapse-content-active {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&--docked {
|
||||
flex: none;
|
||||
|
||||
.ant-collapse-item {
|
||||
flex: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
flex: none;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__docked-span-details {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.flamegraph-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.flamegraph-canvas__viewport {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flamegraph-canvas__main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.flamegraph-canvas__overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { useCrosshair } from '../hooks/useCrosshair';
|
||||
import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
|
||||
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
|
||||
import './FlamegraphCanvas.styles.scss';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const {
|
||||
layout,
|
||||
traceMetadata,
|
||||
firstSpanAtFetchLevel,
|
||||
onSpanClick,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
} = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const spanRectsRef = useRef<SpanRect[]>([]);
|
||||
const eventRectsRef = useRef<EventRect[]>([]);
|
||||
|
||||
const [viewStartTs, setViewStartTs] = useState<number>(
|
||||
traceMetadata.startTime,
|
||||
);
|
||||
const [viewEndTs, setViewEndTs] = useState<number>(traceMetadata.endTime);
|
||||
const [scrollTop, setScrollTop] = useState<number>(0);
|
||||
const [rowHeight, setRowHeight] = useState<number>(DEFAULT_ROW_HEIGHT);
|
||||
|
||||
// Mutable refs for zoom and drag hooks to read during rAF / mouse callbacks
|
||||
const viewStartRef = useRef(viewStartTs);
|
||||
const viewEndRef = useRef(viewEndTs);
|
||||
const rowHeightRef = useRef(rowHeight);
|
||||
const scrollTopRef = useRef(scrollTop);
|
||||
|
||||
useEffect(() => {
|
||||
viewStartRef.current = viewStartTs;
|
||||
}, [viewStartTs]);
|
||||
|
||||
useEffect(() => {
|
||||
viewEndRef.current = viewEndTs;
|
||||
}, [viewEndTs]);
|
||||
|
||||
useEffect(() => {
|
||||
rowHeightRef.current = rowHeight;
|
||||
}, [rowHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
//TODO: see if this can be removed as once loaded the view start and end ts will not change
|
||||
setViewStartTs(traceMetadata.startTime);
|
||||
setViewEndTs(traceMetadata.endTime);
|
||||
viewStartRef.current = traceMetadata.startTime;
|
||||
viewEndRef.current = traceMetadata.endTime;
|
||||
}, [traceMetadata.startTime, traceMetadata.endTime]);
|
||||
|
||||
const totalHeight = layout.totalVisualRows * rowHeight;
|
||||
|
||||
const { isOverFlamegraphRef } = useFlamegraphZoom({
|
||||
canvasRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
rowHeightRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setRowHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
handleMouseDown,
|
||||
handleMouseMove: handleDragMouseMove,
|
||||
handleMouseUp,
|
||||
handleDragMouseLeave,
|
||||
isDraggingRef,
|
||||
} = useFlamegraphDrag({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
traceMetadata,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
scrollTopRef,
|
||||
setScrollTop,
|
||||
totalHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
hoveredSpanId,
|
||||
hoveredEventKey,
|
||||
handleHoverMouseMove,
|
||||
handleHoverMouseLeave,
|
||||
handleMouseDownForClick,
|
||||
handleClick,
|
||||
tooltipContent,
|
||||
} = useFlamegraphHover({
|
||||
canvasRef,
|
||||
spanRectsRef,
|
||||
eventRectsRef,
|
||||
traceMetadata,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
isDraggingRef,
|
||||
onSpanClick,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
const { drawFlamegraph } = useFlamegraphDraw({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spans: layout.visualRows,
|
||||
connectors: layout.connectors,
|
||||
viewStartTs,
|
||||
viewEndTs,
|
||||
scrollTop,
|
||||
rowHeight,
|
||||
selectedSpanId: firstSpanAtFetchLevel || undefined,
|
||||
hoveredSpanId: hoveredSpanId ?? '',
|
||||
isDarkMode,
|
||||
spanRectsRef,
|
||||
eventRectsRef,
|
||||
hoveredEventKey,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
});
|
||||
|
||||
useScrollToSpan({
|
||||
firstSpanAtFetchLevel,
|
||||
spans: layout.visualRows,
|
||||
traceMetadata,
|
||||
containerRef,
|
||||
viewStartRef,
|
||||
viewEndRef,
|
||||
scrollTopRef,
|
||||
rowHeight,
|
||||
setViewStartTs,
|
||||
setViewEndTs,
|
||||
setScrollTop,
|
||||
});
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
|
||||
|
||||
const {
|
||||
cursorXPercent,
|
||||
cursorX,
|
||||
onMouseMove: onCrosshairMove,
|
||||
onMouseLeave: onCrosshairLeave,
|
||||
} = useCrosshair({ containerRef });
|
||||
|
||||
useFlamegraphCrosshair({ overlayCanvasRef, cursorX });
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
handleDragMouseMove(e);
|
||||
handleHoverMouseMove(e);
|
||||
if (!isDraggingRef.current) {
|
||||
onCrosshairMove(e);
|
||||
}
|
||||
},
|
||||
[handleDragMouseMove, handleHoverMouseMove, onCrosshairMove, isDraggingRef],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
isOverFlamegraphRef.current = false;
|
||||
onCrosshairLeave();
|
||||
handleDragMouseLeave();
|
||||
handleHoverMouseLeave();
|
||||
}, [
|
||||
isOverFlamegraphRef,
|
||||
onCrosshairLeave,
|
||||
handleDragMouseLeave,
|
||||
handleHoverMouseLeave,
|
||||
]);
|
||||
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className="span-hover-card-popover flamegraph-tooltip"
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
}}
|
||||
>
|
||||
{tooltipContent.event ? (
|
||||
<EventTooltipContent
|
||||
eventName={tooltipContent.event.name}
|
||||
timeOffsetMs={tooltipContent.event.timeOffsetMs}
|
||||
isError={tooltipContent.event.isError}
|
||||
attributeMap={tooltipContent.event.attributeMap}
|
||||
/>
|
||||
) : (
|
||||
<SpanTooltipContent
|
||||
spanName={tooltipContent.spanName}
|
||||
color={tooltipContent.spanColor}
|
||||
hasError={tooltipContent.status === 'error'}
|
||||
relativeStartMs={tooltipContent.startMs}
|
||||
durationMs={tooltipContent.durationMs}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flamegraph-canvas">
|
||||
{tooltipElement}
|
||||
<TimelineV3
|
||||
startTimestamp={viewStartTs}
|
||||
endTimestamp={viewEndTs}
|
||||
offsetTimestamp={viewStartTs - traceMetadata.startTime}
|
||||
timelineHeight={10}
|
||||
cursorXPercent={cursorXPercent}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flamegraph-canvas__viewport"
|
||||
onMouseEnter={(): void => {
|
||||
isOverFlamegraphRef.current = true;
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="flamegraph-canvas__main"
|
||||
onMouseDown={(e): void => {
|
||||
handleMouseDown(e);
|
||||
handleMouseDownForClick(e);
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<canvas ref={overlayCanvasRef} className="flamegraph-canvas__overlay" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphCanvas;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
|
||||
|
||||
interface TraceFlamegraphProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}
|
||||
|
||||
function TraceFlamegraph({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: TraceFlamegraphProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(spanId: string): void => {
|
||||
setFirstSpanAtFetchLevel(spanId);
|
||||
const searchParams = new URLSearchParams(search);
|
||||
//tood: use from query params constants
|
||||
if (searchParams.get('spanId') !== spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
error: fetchError,
|
||||
} = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
// selectedSpanId: firstSpanAtFetchLevel,
|
||||
limit: FLAMEGRAPH_SPAN_LIMIT,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
() => data?.payload?.spans || [],
|
||||
[data?.payload?.spans],
|
||||
);
|
||||
|
||||
const {
|
||||
layout,
|
||||
isComputing,
|
||||
error: workerError,
|
||||
} = useVisualLayoutWorker(spans);
|
||||
|
||||
const content = useMemo(() => {
|
||||
// Loading: fetching data or worker computing layout
|
||||
if (isFetching || isComputing) {
|
||||
if (layout.totalVisualRows > 0) {
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
layout={layout}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', padding: '8px 12px' }}>
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{
|
||||
rows: 8,
|
||||
width: ['100%', '95%', '85%', '70%', '50%', '35%', '20%', '10%'],
|
||||
}}
|
||||
title={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Error: network or worker failure
|
||||
if (fetchError || workerError) {
|
||||
return <Error error={(fetchError || workerError) as any} />;
|
||||
}
|
||||
if (data?.payload?.spans && data.payload.spans.length === 0) {
|
||||
return <div>No data found for trace {traceId}</div>;
|
||||
}
|
||||
return (
|
||||
<FlamegraphCanvas
|
||||
layout={layout}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
onSpanClick={handleSpanClick}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
data?.payload?.spans,
|
||||
fetchError,
|
||||
filteredSpanIds,
|
||||
firstSpanAtFetchLevel,
|
||||
handleSpanClick,
|
||||
isComputing,
|
||||
isFilterActive,
|
||||
isFetching,
|
||||
layout,
|
||||
traceId,
|
||||
workerError,
|
||||
]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -0,0 +1,475 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { computeVisualLayout } from '../computeVisualLayout';
|
||||
|
||||
function makeSpan(
|
||||
overrides: Partial<FlamegraphSpan> & {
|
||||
spanId: string;
|
||||
timestamp: number;
|
||||
durationNano: number;
|
||||
},
|
||||
): FlamegraphSpan {
|
||||
return {
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'svc',
|
||||
name: 'op',
|
||||
level: 0,
|
||||
event: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeVisualLayout', () => {
|
||||
it('should handle empty input', () => {
|
||||
const layout = computeVisualLayout([]);
|
||||
expect(layout.totalVisualRows).toBe(0);
|
||||
expect(layout.visualRows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single root, no children — 1 visual row', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const layout = computeVisualLayout([[root]]);
|
||||
expect(layout.totalVisualRows).toBe(1);
|
||||
expect(layout.visualRows[0]).toEqual([root]);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
});
|
||||
|
||||
it('should keep non-overlapping siblings on the same row (compact)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 400,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
// root on row 0, all children on row 1
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1);
|
||||
});
|
||||
|
||||
it('should pack non-overlapping siblings into shared lanes (greedy packing)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 300e6,
|
||||
});
|
||||
// A and B overlap; C does not overlap with either
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6, // ends at 100ms
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 100e6, // starts at 50ms < 100ms end of A → overlap → lane 1
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 100e6, // 200 >= 100, fits lane 0 with A
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
// root on row 0, C placed first (latest) → row 1, B doesn't overlap C → row 1, A overlaps B → row 2
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['a']).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle full overlap — all siblings get own row', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b]]);
|
||||
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(2);
|
||||
});
|
||||
|
||||
it('should stack children correctly below overlapping parents', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 300e6,
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
// Child of A
|
||||
const childA = makeSpan({
|
||||
spanId: 'childA',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 10,
|
||||
durationNano: 50e6,
|
||||
});
|
||||
// Child of B
|
||||
const childB = makeSpan({
|
||||
spanId: 'childB',
|
||||
parentSpanId: 'b',
|
||||
timestamp: 60,
|
||||
durationNano: 50e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b], [childA, childB]]);
|
||||
|
||||
// DFS processes b's subtree first (latest):
|
||||
// root → row 0
|
||||
// b → row 1 (parentRow 0 + 1)
|
||||
// childB → row 2 (parentRow 1 + 1)
|
||||
// a → try row 1 (parentRow 0 + 1), overlaps b → try row 2, overlaps childB → row 3
|
||||
// childA → row 4 (parentRow 3 + 1)
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['childB']).toBe(2);
|
||||
expect(layout.spanToVisualRow['a']).toBe(3);
|
||||
expect(layout.spanToVisualRow['childA']).toBe(4);
|
||||
expect(layout.totalVisualRows).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle multiple roots as a sibling group', () => {
|
||||
// Two overlapping roots
|
||||
const r1 = makeSpan({
|
||||
spanId: 'r1',
|
||||
timestamp: 0,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
const r2 = makeSpan({
|
||||
spanId: 'r2',
|
||||
timestamp: 50,
|
||||
durationNano: 100e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[r1, r2]]);
|
||||
|
||||
expect(layout.spanToVisualRow['r1']).toBe(0);
|
||||
expect(layout.spanToVisualRow['r2']).toBe(1);
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
});
|
||||
|
||||
it('should produce compact layout for deep nesting without overlap', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 1000e6,
|
||||
});
|
||||
const child = makeSpan({
|
||||
spanId: 'child',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 10,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const grandchild = makeSpan({
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child',
|
||||
timestamp: 20,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [child], [grandchild]]);
|
||||
|
||||
// No overlap at any level → visual rows == tree depth
|
||||
expect(layout.totalVisualRows).toBe(3);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['child']).toBe(1);
|
||||
expect(layout.spanToVisualRow['grandchild']).toBe(2);
|
||||
});
|
||||
|
||||
it('should pack many sequential siblings into 1 row (no diagonal staircase)', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
// 6 sequential children — like checkoutservice/PlaceOrder scenario
|
||||
const spans = [
|
||||
makeSpan({
|
||||
spanId: 's1',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 3,
|
||||
durationNano: 30e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's2',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 35,
|
||||
durationNano: 4e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's3',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 39,
|
||||
durationNano: 1e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's4',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 40,
|
||||
durationNano: 4e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's5',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 44,
|
||||
durationNano: 5e6,
|
||||
}),
|
||||
makeSpan({
|
||||
spanId: 's6',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 49,
|
||||
durationNano: 1e6,
|
||||
}),
|
||||
];
|
||||
|
||||
const layout = computeVisualLayout([[root], spans]);
|
||||
|
||||
// All 6 sequential siblings should share 1 row
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
for (const span of spans) {
|
||||
expect(layout.spanToVisualRow[span.spanId]).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep children below parents even with misparented spans', () => {
|
||||
// Simulates the dd_sig2 bug: /route spans have parentSpanId pointing
|
||||
// to the wrong ancestor, but they are at level 2 in the spans[][] input.
|
||||
// Level-based packing must place them below level 1 regardless.
|
||||
const httpGet = makeSpan({
|
||||
spanId: 'http-get',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const route = makeSpan({
|
||||
spanId: 'route',
|
||||
parentSpanId: 'some-wrong-ancestor', // misparented!
|
||||
timestamp: 10,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[httpGet], [route]]);
|
||||
|
||||
// httpGet at level 0 → row 0, route at level 1 → row 1
|
||||
expect(layout.spanToVisualRow['http-get']).toBe(0);
|
||||
expect(layout.spanToVisualRow['route']).toBe(1);
|
||||
expect(layout.totalVisualRows).toBe(2);
|
||||
});
|
||||
|
||||
it('should keep parent-child pairs adjacent when sibling subtrees overlap', () => {
|
||||
// Multiple overlapping parents each with a child — the subtree-unit
|
||||
// guarantee means every parent→child gap should be exactly 1.
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
// Three overlapping HTTP GET children of root, each with its own /route child
|
||||
const get1 = makeSpan({
|
||||
spanId: 'get1',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route1 = makeSpan({
|
||||
spanId: 'route1',
|
||||
parentSpanId: 'get1',
|
||||
timestamp: 10,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
const get2 = makeSpan({
|
||||
spanId: 'get2',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 50,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route2 = makeSpan({
|
||||
spanId: 'route2',
|
||||
parentSpanId: 'get2',
|
||||
timestamp: 60,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
const get3 = makeSpan({
|
||||
spanId: 'get3',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const route3 = makeSpan({
|
||||
spanId: 'route3',
|
||||
parentSpanId: 'get3',
|
||||
timestamp: 110,
|
||||
durationNano: 180e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([
|
||||
[root],
|
||||
[get1, get2, get3],
|
||||
[route1, route2, route3],
|
||||
]);
|
||||
|
||||
// Each parent-child pair should have a gap of exactly 1
|
||||
const get1Row = layout.spanToVisualRow['get1'];
|
||||
const route1Row = layout.spanToVisualRow['route1'];
|
||||
const get2Row = layout.spanToVisualRow['get2'];
|
||||
const route2Row = layout.spanToVisualRow['route2'];
|
||||
const get3Row = layout.spanToVisualRow['get3'];
|
||||
const route3Row = layout.spanToVisualRow['route3'];
|
||||
|
||||
expect(route1Row - get1Row).toBe(1);
|
||||
expect(route2Row - get2Row).toBe(1);
|
||||
expect(route3Row - get3Row).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle mixed levels — overlap at level 2 but not level 1', () => {
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 1000e6,
|
||||
});
|
||||
// Non-overlapping children
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 400e6,
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 500,
|
||||
durationNano: 400e6,
|
||||
});
|
||||
// Overlapping grandchildren under A
|
||||
const ga1 = makeSpan({
|
||||
spanId: 'ga1',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 0,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
const ga2 = makeSpan({
|
||||
spanId: 'ga2',
|
||||
parentSpanId: 'a',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6,
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b], [ga1, ga2]]);
|
||||
|
||||
// root → row 0
|
||||
// a, b → row 1 (no overlap, share row)
|
||||
// ga1 → row 2, ga2 → row 3 (overlap, expanded)
|
||||
// b has no children, so nothing after ga2
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['a']).toBe(1);
|
||||
expect(layout.spanToVisualRow['b']).toBe(1);
|
||||
expect(layout.spanToVisualRow['ga2']).toBe(2);
|
||||
expect(layout.spanToVisualRow['ga1']).toBe(3);
|
||||
expect(layout.totalVisualRows).toBe(4);
|
||||
});
|
||||
|
||||
it('should not place a span where it covers an existing connector point (Check 2)', () => {
|
||||
// Scenario: root has 3 leaf children. Sorted latest-first: C(200), B(100), A(80).
|
||||
//
|
||||
// C placed at row 1 [200, 400].
|
||||
// B overlaps C → placed at row 2 [100, 300]. Connector from row 0→2 at x=100
|
||||
// passes through row 1, recording connector point at (row 1, x=100).
|
||||
// A [80, 110] does NOT overlap C's span [200, 400] at row 1 (110 < 200),
|
||||
// so without connector reservation A would fit at row 1.
|
||||
// But A's span [80, 110) contains the connector point x=100 at row 1.
|
||||
// Check 2 prevents this placement, pushing A further down.
|
||||
const root = makeSpan({
|
||||
spanId: 'root',
|
||||
timestamp: 0,
|
||||
durationNano: 500e6,
|
||||
});
|
||||
const c = makeSpan({
|
||||
spanId: 'c',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 200,
|
||||
durationNano: 200e6, // [200, 400]
|
||||
});
|
||||
const b = makeSpan({
|
||||
spanId: 'b',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 100,
|
||||
durationNano: 200e6, // [100, 300]
|
||||
});
|
||||
const a = makeSpan({
|
||||
spanId: 'a',
|
||||
parentSpanId: 'root',
|
||||
timestamp: 80,
|
||||
durationNano: 30e6, // [80, 110]
|
||||
});
|
||||
|
||||
const layout = computeVisualLayout([[root], [a, b, c]]);
|
||||
|
||||
expect(layout.spanToVisualRow['root']).toBe(0);
|
||||
expect(layout.spanToVisualRow['c']).toBe(1); // latest, placed first
|
||||
expect(layout.spanToVisualRow['b']).toBe(2); // overlaps C → row 2
|
||||
|
||||
// A would fit at row 1 by span overlap alone, but connector point at
|
||||
// (row 1, x=100) falls within A's span [80, 110). Check 2 pushes A down.
|
||||
const aRow = layout.spanToVisualRow['a']!;
|
||||
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
|
||||
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,536 @@
|
||||
import { DASHED_BORDER_LINE_DASH, MIN_WIDTH_FOR_NAME } from '../constants';
|
||||
import type { FlamegraphRowMetrics } from '../utils';
|
||||
import { getFlamegraphRowMetrics } from '../utils';
|
||||
import { drawEventDot, drawSpanBar, getEventDotColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (): { time: number; timeUnitName: string } => ({
|
||||
time: 50,
|
||||
timeUnitName: 'ms',
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Minimal 2D context for createStripePattern's internal canvas (jsdom getContext often returns null) */
|
||||
const mockPatternCanvasCtx = {
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
globalAlpha: 1,
|
||||
};
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
document.createElement = function (
|
||||
tagName: string,
|
||||
): ReturnType<typeof originalCreateElement> {
|
||||
const el = originalCreateElement(tagName);
|
||||
if (tagName.toLowerCase() === 'canvas') {
|
||||
(el as HTMLCanvasElement).getContext = (() =>
|
||||
mockPatternCanvasCtx as unknown) as HTMLCanvasElement['getContext'];
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
function createMockCtx(): jest.Mocked<CanvasRenderingContext2D> {
|
||||
return {
|
||||
beginPath: jest.fn(),
|
||||
roundRect: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
translate: jest.fn(),
|
||||
rotate: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
measureText: jest.fn(
|
||||
(text: string) => ({ width: text.length * 6 }) as TextMetrics,
|
||||
),
|
||||
createPattern: jest.fn(() => ({}) as CanvasPattern),
|
||||
clip: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
lineWidth: 0,
|
||||
globalAlpha: 1,
|
||||
} as unknown as jest.Mocked<CanvasRenderingContext2D>;
|
||||
}
|
||||
|
||||
const METRICS: FlamegraphRowMetrics = getFlamegraphRowMetrics(24);
|
||||
|
||||
describe('Canvas Draw Utils', () => {
|
||||
describe('drawSpanBar', () => {
|
||||
it('draws rect + fill for normal span (no selected/hovered)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, event: [] },
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#1890ff',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
expect(ctx.roundRect).toHaveBeenCalledWith(10, 1, 100, 22, 2);
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).not.toHaveBeenCalled();
|
||||
expect(spanRectsArray).toHaveLength(1);
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 10,
|
||||
y: 1,
|
||||
width: 100,
|
||||
height: 22,
|
||||
level: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stripe pattern + dashed stroke + 2px when selected', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'sel', event: [] },
|
||||
x: 20,
|
||||
y: 0,
|
||||
width: 80,
|
||||
levelIndex: 1,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'sel',
|
||||
});
|
||||
|
||||
// Selected spans get solid l2-background fill + dashed border
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith(DASHED_BORDER_LINE_DASH);
|
||||
expect(ctx.strokeStyle).toBe('#2F80ED');
|
||||
expect(ctx.lineWidth).toBe(2);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('uses solid l2-background fill + solid stroke + 1px when hovered (not selected)', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'hov', event: [] },
|
||||
x: 30,
|
||||
y: 0,
|
||||
width: 60,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#2F80ED',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'hov',
|
||||
});
|
||||
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.setLineDash).not.toHaveBeenCalled();
|
||||
expect(ctx.lineWidth).toBe(1);
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes spanRectsArray with correct dimensions', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'rect-test', event: [] },
|
||||
x: 5,
|
||||
y: 24,
|
||||
width: 200,
|
||||
levelIndex: 2,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(spanRectsArray[0]).toMatchObject({
|
||||
x: 5,
|
||||
y: 25,
|
||||
width: 200,
|
||||
height: 22,
|
||||
level: 2,
|
||||
});
|
||||
expect(spanRectsArray[0].span.spanId).toBe('rect-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanLabel (via drawSpanBar)', () => {
|
||||
it('skips label when width < MIN_WIDTH_FOR_NAME', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanRectsArray: {
|
||||
span: typeof MOCK_SPAN;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
}[] = [];
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray,
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).not.toHaveBeenCalled();
|
||||
expect(ctx.fillText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws name only when width >= MIN_WIDTH_FOR_NAME but < MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 }) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'foo', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.clip).toHaveBeenCalled();
|
||||
expect(ctx.fillText).toHaveBeenCalled();
|
||||
expect(ctx.textAlign).toBe('left');
|
||||
});
|
||||
|
||||
it('draws name + duration when width >= MIN_WIDTH_FOR_NAME_AND_DURATION', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 6 }) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'my-span', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'50ms',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'my-span',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText (via drawSpanBar)', () => {
|
||||
it('uses full text when it fits', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) => ({ width: t.length * 4 }) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'short', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'short',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates text when it exceeds available width', () => {
|
||||
const ctx = createMockCtx();
|
||||
ctx.measureText = jest.fn(
|
||||
(t: string) =>
|
||||
({
|
||||
width: t.includes('...') ? 24 : t.length * 10,
|
||||
}) as TextMetrics,
|
||||
);
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, name: 'very-long-span-name', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
const fillTextCalls = (ctx.fillText as jest.Mock).mock.calls;
|
||||
const nameArg = fillTextCalls.find((c) => c[0] !== '50ms')?.[0];
|
||||
expect(nameArg).toBeDefined();
|
||||
expect(nameArg).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawEventDot', () => {
|
||||
it('uses error styling when isError is true', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('#000', true, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 50,
|
||||
y: 11,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(50, 11);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.fillStyle).toBe('rgb(220, 38, 38)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(153, 27, 27)');
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(-3, -3, 6, 6);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives color from span color when isError is false', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('rgb(100, 200, 150)', false, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
// Darkened by 20% for fill
|
||||
expect(ctx.fillStyle).toBe('rgb(80, 160, 120)');
|
||||
// Darkened by 40% for stroke
|
||||
expect(ctx.strokeStyle).toBe('rgb(60, 120, 90)');
|
||||
});
|
||||
|
||||
it('uses dark mode colors for error', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('#000', true, true);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(239, 68, 68)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(185, 28, 28)');
|
||||
});
|
||||
|
||||
it('falls back to cyan/blue for unparseable span colors', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('hsl(200, 50%, 50%)', false, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 0,
|
||||
y: 0,
|
||||
color,
|
||||
eventDotSize: 6,
|
||||
});
|
||||
|
||||
expect(ctx.fillStyle).toBe('rgb(6, 182, 212)');
|
||||
expect(ctx.strokeStyle).toBe('rgb(8, 145, 178)');
|
||||
});
|
||||
|
||||
it('calls save, translate, rotate, restore', () => {
|
||||
const ctx = createMockCtx();
|
||||
const color = getEventDotColor('#000', false, false);
|
||||
|
||||
drawEventDot({
|
||||
ctx,
|
||||
x: 10,
|
||||
y: 20,
|
||||
color,
|
||||
eventDotSize: 4,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(ctx.translate).toHaveBeenCalledWith(10, 20);
|
||||
expect(ctx.rotate).toHaveBeenCalledWith(Math.PI / 4);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('solid l2-background fill for selected/hovered spans', () => {
|
||||
it('uses solid fill for hovered span', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
hoveredSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses solid fill + dashed border for selected span', () => {
|
||||
const ctx = createMockCtx();
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: { ...MOCK_SPAN, spanId: 'p', event: [] },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: MIN_WIDTH_FOR_NAME - 1,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
selectedSpanId: 'p',
|
||||
});
|
||||
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawSpanBar with events', () => {
|
||||
it('draws event dots for each span event', () => {
|
||||
const ctx = createMockCtx();
|
||||
const spanWithEvents = {
|
||||
...MOCK_SPAN,
|
||||
event: [
|
||||
{
|
||||
name: 'e1',
|
||||
timeUnixNano: 1_010_000_000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'e2',
|
||||
timeUnixNano: 1_025_000_000,
|
||||
attributeMap: {},
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
drawSpanBar({
|
||||
ctx,
|
||||
span: spanWithEvents,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
levelIndex: 0,
|
||||
spanRectsArray: [],
|
||||
eventRectsArray: [],
|
||||
color: '#000',
|
||||
isDarkMode: false,
|
||||
metrics: METRICS,
|
||||
});
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.translate).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.fillRect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
/** Minimal FlamegraphSpan for unit tests */
|
||||
export const MOCK_SPAN: FlamegraphSpan = {
|
||||
timestamp: 1000,
|
||||
durationNano: 50_000_000, // 50ms
|
||||
spanId: 'span-1',
|
||||
parentSpanId: '',
|
||||
traceId: 'trace-1',
|
||||
hasError: false,
|
||||
serviceName: 'test-service',
|
||||
name: 'test-span',
|
||||
level: 0,
|
||||
event: [],
|
||||
};
|
||||
|
||||
/** Nested spans structure for findSpanById tests */
|
||||
export const MOCK_SPANS: FlamegraphSpan[][] = [
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'root',
|
||||
parentSpanId: '',
|
||||
level: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-a',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'child-b',
|
||||
parentSpanId: 'root',
|
||||
level: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...MOCK_SPAN,
|
||||
spanId: 'grandchild',
|
||||
parentSpanId: 'child-a',
|
||||
level: 2,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const MOCK_TRACE_METADATA = {
|
||||
startTime: 0,
|
||||
endTime: 1000,
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useFlamegraphDrag } from '../hooks/useFlamegraphDrag';
|
||||
import { MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
toJSON: (): Record<string, unknown> => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function createMockContainer(): HTMLDivElement {
|
||||
const div = document.createElement('div');
|
||||
Object.defineProperty(div, 'clientHeight', { value: 400 });
|
||||
return div;
|
||||
}
|
||||
|
||||
const defaultArgs = {
|
||||
canvasRef: { current: createMockCanvas() },
|
||||
containerRef: { current: createMockContainer() },
|
||||
traceMetadata: MOCK_TRACE_METADATA,
|
||||
viewStartRef: { current: 0 },
|
||||
viewEndRef: { current: 1000 },
|
||||
setViewStartTs: jest.fn(),
|
||||
setViewEndTs: jest.fn(),
|
||||
scrollTopRef: { current: 0 },
|
||||
setScrollTop: jest.fn(),
|
||||
totalHeight: 1000,
|
||||
};
|
||||
|
||||
describe('useFlamegraphDrag', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
defaultArgs.viewStartRef.current = 0;
|
||||
defaultArgs.viewEndRef.current = 1000;
|
||||
defaultArgs.scrollTopRef.current = 0;
|
||||
});
|
||||
|
||||
it('starts drag state on mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-left button mousedown', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 1,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('updates pan/scroll on mousemove', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(defaultArgs.setViewStartTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setViewEndTs).toHaveBeenCalled();
|
||||
expect(defaultArgs.setScrollTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets drag state on mouseup', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseUp();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels drag on mouseleave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphDrag(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDown({
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 50,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragMouseLeave();
|
||||
});
|
||||
|
||||
expect(result.current.isDraggingRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user