Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
c58f76f998 Merge branch 'main' into nv/exec-stats 2026-11-03 11:34:10 +05:30
Naman Verma
e39ad9e221 test: check how exec stats fail 2026-11-02 22:24:22 +05:30
1101 changed files with 10547 additions and 56142 deletions

View File

@@ -6,12 +6,12 @@ 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) and [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md). **Read both first.** Adhere to them.
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/search.spec.ts`, not `dashboards/list/dashboards-list-search.spec.ts`.
- **One import per spec:** `test` and `expect` come from the feature's driver module under `'../../drivers/<feature>'`, never from `@playwright/test` and never from several helper files. Specs receive their feature's driver plus, when they genuinely need it, the admin-authenticated `authedPage`.
- **Drivers first.** New specs import `test`, `expect` and constants from one driver module in [tests/e2e/drivers/](../../tests/e2e/drivers/) and receive the feature's driver as a fixture. A spec never passes `page` around.
```ts
import { expect, PATH, test } from '../../drivers/routing';
@@ -22,24 +22,42 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
```
- A driver is a function closing over the page that returns `Locator` getters and async actions. Actions wait inside themselves (e.g. `run()` pairs the click with `waitForResponse(/query_range/)`), so specs don't.
- `driver(name, build, dispose?)` in `drivers/kit.ts` makes it a test-scoped fixture on `authedPage`; `dispose` deletes what it seeded. `seeded(name, open, close)` makes a worker-scoped seed.
- Layers compose: `withNewPanel(withEditor(panelsTest))`. Extend the closest existing driver instead of starting a parallel one.
- When no driver covers the feature, add `drivers/<feature>.ts` with the spec. Locators live in the driver, not the spec. `drivers/` is AI-owned: keep the banner, and change a driver only alongside a spec that needs it.
- Read [tests/e2e/tests/dashboards/editor/query.spec.ts](../../tests/e2e/tests/dashboards/editor/query.spec.ts) and [drivers/panel-editor.ts](../../tests/e2e/drivers/panel-editor.ts) for the canonical shape.
- **Seeds are layers on the feature's `test`.** A spec destructures the seed it needs (`alertList`, `ownedRules`, `resolvedHistory` for alerts) instead of importing a fixtures module. A new seed goes in the feature's driver directory: `seeded()` for read-only data shared by a worker, a test-scoped layer for anything a test mutates.
- **Plain auth fixture** only for a spec no driver fits: import `test` and `expect` from `'../../fixtures/auth'` (never `@playwright/test`) and use the `authedPage` fixture (the admin, the only user the bootstrap seeds).
- **Extended fixtures:** older suites import domain fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md).
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
```ts
// Alert list tests - need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// Alert history tests - need evaluated history rows
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
- **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, and scope every assertion to names the spec seeded. Pick the scope per the Seeding table in [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md): test scope (a test-scoped layer, or `try / finally` through the driver) for mutations, worker scope (`seeded()`) for read-only data, suite `beforeAll` for a dataset only one file uses.
- **Drive everything through the feature's driver** in `tests/e2e/drivers/`. A spec that reaches for `page.locator(...)` or `page.getByTestId(...)` is missing a driver method — add the method instead. `onAdminPage(browser, …)` from `drivers/kit.ts` is what suite hooks use, because `authedPage` is test-scoped and not visible to them.
- **Seed through the kit when the UI flow is not under test.** Control-plane objects go through the feature driver (`dashboards.seed`, `alerts.seed.threshold`), which uses `apiDriver(page)` from `drivers/api.ts`; telemetry goes to the seeder. `page.request.*` does **not** carry the token, so never seed with it.
- **Golden telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads logs, traces and metrics over the last 6h (constants in `GOLDEN`, `drivers/dashboard-spec.ts`). Query within ≤ 6h and assert structure (series, rows, request payload), never exact counts.
- **`query_range` mocks** in [`drivers/query-range-mock.ts`](../../tests/e2e/drivers/query-range-mock.ts) are for error / no-data / warning / loading states only; install before `goto`.
- **Reusable payloads live in [tests/e2e/testdata/](../../tests/e2e/testdata/)** (traces and logs POSTed to the seeder, `dashboards/panels.ts`). Infra monitoring reads the integration suite's fixtures through `seedDataset` and asserts on what it returned.
- **Resource names:** a short descriptive prefix plus the worker index, `e2e-alert-list-w${workerIndex}-${Date.now()}`. Six workers share one stack, so a name without the stamp collides.
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing resources. Cleanup shapes, in order of preference:
- **A driver's `dispose`** — the driver tracks what it seeded and deletes it after the test (`dashboards.seed(...)` in `drivers/dashboards.ts`). The spec has no cleanup code.
- **A worker-scoped `seeded(...)` layer** — read-only data shared by a worker's tests.
- **Per-test `try / finally`** or **suite-level `afterAll` with an id registry** — specs without a driver. Suite hooks can't see `authedPage`; use `newAdminContext(browser)` from `helpers/auth.ts`.
- **Golden telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads logs, traces and metrics over the last 6h (constants in `GOLDEN`, `helpers/dashboard-spec.ts`). Query within ≤ 6h and assert structure (series, rows, request payload), never exact counts: per-test seeds from parallel workers land in the same tenant. Seed missing shapes through the seeder with unique service names (`helpers/alerts/seeding.ts`).
- **Reuse helpers from `tests/e2e/helpers/`** inside drivers. Don't reinvent:
- [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) — `authToken`, `watchConsole`, `collectRequests`, `requestUrl`, `seederUrl`.
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)`.
- [`helpers/query-range-mock.ts`](../../tests/e2e/helpers/query-range-mock.ts) — `query_range` mocks for error / no-data / warning / loading states only; install before `goto`.
- **Seed via API when the UI flow is multi-step or brittle**, through the driver (`dashboards.seed`, `seedAndOpen`, `seedAndEdit`). `page.request.*` does **not** auto-attach `Authorization`; drivers add `authToken(page)`.
- **Static payloads live in [tests/e2e/testdata/](../../tests/e2e/testdata/)** (`dashboards/panels.ts`, `traces/large-trace.json`).
- **Resource names:** short and descriptive, no timestamps. Where parallel workers share a list, add a random suffix as `uniqueName` in `drivers/dashboard-list.ts` does.
- **Serial mode** when tests in a file mutate the same list page:
```ts
test.describe.configure({ mode: 'serial' });
```
- **Locate by testid, in the driver.** A missing testid is added to the product source. Role, label and CSS lookups are a stopgap; antd internals (`.ant-select-dropdown`, in `antd.ts`) are the exception.
- **A driver method is navigation or a locator, never a scenario.** A method that exists for one spec belongs in that spec.
- **No `test.slow()` in drivers.** It compounds when called in a loop; use `allowFor(floor)` from `drivers/kit.ts`.
- **Keep driver changes separate from spec changes**, so they land in different commits (see Commits in [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md)).
- **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…()`.
@@ -60,8 +78,8 @@ For each scenario in the plan:
- **`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 `../../drivers/<feature>`. **Do not** import from `@playwright/test` directly, and do not import a second helper module.
- **Cleanup** comes from the seed layer or the driver's `dispose`. Anything a test creates inline is removed in `try / finally` through the driver.
- **Imports** from the feature's driver module (or `../../fixtures/auth` when none fits). **Do not** import from `@playwright/test` directly.
- **Cleanup** through the driver's `dispose`; `try / finally` only without a driver.
- **Move recorded locators and waits into the driver.** The log gives raw `page.*` calls; the spec should read as driver actions.
# Quality bar — what to write, what to skip
@@ -122,7 +140,7 @@ test.describe('Dashboards — list search', () => {
});
```
Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locators (they live in `drivers/dashboard-list.ts`), and no `// 1. …` step narration. If a step needs an element the driver does not expose, add a method to the driver; do not reach for `page.getByTestId(...)` in the spec. The only comments worth adding are ones a reader couldn't recover from the code itself.
Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locators (they live in `drivers/dashboard-list.ts`), and no `// 1. …` step narration. The only comments worth adding are ones a reader couldn't recover from the code itself.
# Known UI gotchas (apply when relevant)

View File

@@ -6,7 +6,7 @@ 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) and [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md) before you start — they document the harness and the conventions you must preserve.
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
@@ -27,11 +27,11 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
- `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 through its seed layer or driver (a driver's `dispose` cleans up), scopes its assertions to the names it seeded (with the worker index), and cleans up. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug. Assert structure instead.
- **Selector drift** — the app changed `data-testid` or text. Fix the locator where it lives: in the driver under `tests/e2e/drivers/` when the spec uses one, so every spec sharing it heals at once. 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 — inside the driver action when there is one.
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Seed through the driver (its `dispose` cleans up) or via API with `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug — assert structure instead.
- **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 driver first, the spec second. A driver change must keep every other spec importing it green, so run them too. Preserve TC-NN titles, the feature's `test` import, the spec's 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.
5. **Fix.** Edit the spec or its driver. Preserve TC-NN titles, the driver / `authedPage` fixtures, cleanup, and serial mode if present. A driver change must keep every other spec importing it green — run them too. 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
@@ -41,20 +41,17 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
# Repo-specific signals
- **Fix it in the driver, not in the spec.** Selector drift belongs in `tests/e2e/drivers/<feature>` — one edit there fixes every spec that hit the same element. A `page.locator(...)` added to a spec body is a regression, not a fix.
- **Reuse drivers and helpers before adding new code.** [`tests/e2e/drivers/`](../../tests/e2e/drivers/) holds the per-feature seed, cleanup, navigation and action methods (`kit.ts` explains `driver` / `seeded`); [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) and [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) the plain plumbing. Prefer them over re-inlining auth/login/POST code 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. Add `scrollIntoViewIfNeeded` before the click, in the driver action (`dashboardList.openRowMenu(name)`).
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. Add `scrollIntoViewIfNeeded` before the click, in the driver action.
- **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 `dashboards.seed(...)`.
- **Auth.** `tests/e2e/drivers/auth.ts` logs in once per worker and caches `storageState` (localStorage with `AUTH_TOKEN`). For API-driven seeding and cleanup use `apiDriver(page)` from `drivers/api.ts`, which attaches the header for you. Never re-implement login.
- **Seeds.** Feature seeds are layers on the feature's `test`, e.g. `alertList`, `ownedRules` and `resolvedHistory` in `drivers/alerts/seeds.ts`. When a test fails on missing data, check which seed it destructures and whether it scopes to that seed's names.
- **Timeouts.** Never add `test.slow()` to a driver: it triples the remaining budget per call and compounds in loops. Use `allowFor(floor)` from `drivers/kit.ts`. Budgets and their measurements are in `tests/e2e/docs/flake-notes.md`.
- **CI-only failures.** Reproduce with `STRESS=1 CI=1 pnpm test <path>` from `tests/e2e/` before calling it a product bug.
- **Commits.** Driver fixes and spec fixes go in separate commits (see Commits in `tests/e2e/AGENTS.md`).
- **The list pages render zero-state when the workspace is empty.** List controls and the `new-dashboard-cta` testid are absent in zero-state. A timeout on those usually means the workspace was empty — seed first via `dashboards.seed(...)`.
- **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/common.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
- **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 `pnpm typecheck` and `pnpm lint` from `tests/e2e/`.
- **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

View File

@@ -6,7 +6,7 @@ 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) and [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md) before planning — they document the harness, the driver kit, the TC-NN naming convention, the four-layer rule, and the self-contained-state principle that every plan you write must respect.
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:
@@ -16,62 +16,28 @@ You will:
- 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 tell the generator which seed and cleanup calls the driver needs
- Seeding goes through the feature driver (`apiDriver` underneath) for control-plane objects and through the seeder for telemetry, never through `page.request.*`. Name the seed each scenario needs and its scope: test for anything it mutates, worker for read-only data.
- 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. **Work out which layer each scenario belongs to — before planning it.**
2. **Check what's already wired up.**
- [tests/e2e/drivers/](../../tests/e2e/drivers/) holds one driver per feature (e.g. `dashboards.ts`, `dashboard-list.ts`, `panel-editor.ts`, `routing.ts`, `channels.ts`). A driver is a fixture exposing the feature's locators and actions, including API seeding that cleans itself up (`dashboards.seed(...)`). When one covers the feature, write steps in terms of its actions by name so the generator reuses them. When none does, say so: the generator adds one.
- [tests/e2e/helpers/](../../tests/e2e/helpers/) holds plain helpers: `common.ts` (`authToken`, `watchConsole`, `collectRequests`, `seederUrl`), `auth.ts` (`newAdminContext`), `query-range-mock.ts` (mock `query_range` for error, no-data, warning and loading states only).
- **Telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads a golden OTel-demo dataset (logs, traces, metrics over the last 6h; constants in `GOLDEN` in `helpers/dashboard-spec.ts`). Plans that query data use a window ≤ 6h and assert structure, not exact counts. Data the golden set lacks is seeded per test through the seeder (`helpers/alerts/seeding.ts` shows how).
- [tests/e2e/testdata/](../../tests/e2e/testdata/) holds static payloads (`dashboards/panels.ts`, `traces/large-trace.json`) — note in the plan if a scenario benefits from one.
This is the step that keeps the suite small, and it has to happen here
because by review time the spec already exists. Read
[tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md) for the full rule. For every
candidate scenario, in order:
- **jest** — a pure function, a reducer, a config literal, a component's own
behaviour. Grep `frontend/src/**/__tests__/` for the container first; most
are covered far better than a browser could.
- **Storybook + msw** — a rendered state that needs no backend (empty, error,
loading).
- **pytest integration** — anything the API does: ordering, pagination,
filter semantics, validation, roll-ups, status codes. **Grep
`tests/integration/` and name the test you found.** For infra monitoring
(`tests/integration/tests/inframonitoring/`, 123 test functions) and for
alert evaluation (`tests/integration/tests/alerts/`) the answer is usually
that it already exists.
- **E2E** — only when a real response has to reach a real DOM: URL and
history, a write path that must persist, cross-page navigation, one seeded
proof per surface.
The plan must say, per scenario, why it is not one of the cheaper three. A
scenario that cannot answer that does not go in the plan.
3. **Check what's already wired up.**
- [tests/e2e/drivers/](../../tests/e2e/drivers/) holds one driver module per
feature plus the shared ones (`tanstack`, `querysearch`, `antd`, `api`,
`storage`, `console`). Reference driver methods by name in the steps so the
generator reuses them rather than reinventing a locator.
- If a step needs something the driver does not expose, say so — the
generator adds a driver method, not a `page.locator` in the spec.
- **Telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads a golden
OTel-demo dataset (logs, traces, metrics over the last 6h; constants in
`GOLDEN` in `drivers/dashboard-spec.ts`). Plans that query data use a
window ≤ 6h and assert structure, not exact counts.
- [tests/e2e/testdata/](../../tests/e2e/testdata/) holds static payloads
(`dashboards/panels.ts`, `traces/large-trace.json`); note in the plan if a
scenario benefits from one.
4. **Navigate and explore**
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.
5. **Design comprehensive scenarios**
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)
6. **Structure the test plan**
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).
@@ -80,7 +46,7 @@ You will:
- Expected outcomes per step.
- Cleanup notes (what gets created and how to remove it — usually via API).
7. **Save the plan and the checklist**
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.
@@ -131,8 +97,8 @@ The dashboards list page (`/dashboard`) lists all dashboards in the workspace. F
**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, and assert only on names it seeded. **Never depend on data another spec file created.** Files run in parallel. Pick the seed scope from the Seeding table in [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md); a driver's self-cleaning seed (`dispose`) or a worker-scoped `seeded(...)` layer covers most cases.
- Note the `data-testid` for every element a step touches. Where none exists, say so: the generator adds it to the product rather than falling back to roles or CSS (antd internals aside).
- Each scenario must own its preconditions and cleanup. **Do not invent cross-file global fixtures** — they break parallel-by-file execution. Prefer a driver's self-cleaning seed (`dispose`) or worker-scoped `seeded(...)` layer; without a driver, suite-level `beforeAll` / `afterAll` within one spec file or per-test `try / finally` both work.
- 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.

6
.gitattributes vendored
View File

@@ -2,9 +2,3 @@
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true
tests/e2e/drivers/** linguist-generated=true
# Transcribed E2E data: seeded telemetry, golden payloads and the entity
# datasets. Regenerated from the seeder rather than hand-edited, and re-derived
# by the specs that read them, so they are collapsed in review.
tests/e2e/testdata/** linguist-generated=true
tests/e2e/**/__generated__/** linguist-generated=true

5
.github/CODEOWNERS vendored
View File

@@ -302,8 +302,3 @@ go.mod @therealpandey
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad
## AI Assistant
/frontend/src/container/AIAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/hooks/aiAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/pages/AIAssistantPage/ @SigNoz/nerve @SigNoz/frontend-maintainers

View File

@@ -2,9 +2,6 @@ name: e2eci
on:
pull_request:
merge_group:
types:
- checks_requested
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -16,12 +13,8 @@ jobs:
outputs:
e2e: ${{ steps.changes.outputs.e2e }}
steps:
- name: merge-queue-pass
if: github.event_name == 'merge_group'
run: echo "::notice::Merge queue run. E2E already gated the PR, so the required e2e check auto-passes."
- name: changes
id: changes
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@@ -106,8 +106,8 @@ jobs:
- name: webdriver
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/google-chrome.gpg
echo "deb [signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update -qqy
sudo apt-get -qqy install google-chrome-stable
CHROME_VERSION=$(google-chrome-stable --version)

View File

@@ -132,27 +132,3 @@ jobs:
run: |
cd frontend && pnpm generate:config:web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)
geo-outlines:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: install-frontend
run: cd frontend && pnpm install
- name: generate-geo-outlines
run: |
cd frontend && pnpm build:geo-outlines
git status --short
test -z "$(git status --porcelain)" || (echo; echo "Unexpected difference in geo map outlines. Run pnpm build:geo-outlines in frontend/ locally and commit."; exit 1)

View File

@@ -16,14 +16,6 @@ packages:
filename: "maintenance.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/types/telemetrytypes:
interfaces:
SchemaMetadataStore:
config:
dir: '{{.InterfaceDir}}/telemetrytypestest'
filename: "schema_metadata_store.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/tokenizer:
config:
all: true

View File

@@ -178,9 +178,6 @@ querier:
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
schemametadatastore:
# How long found schema metadata stays cached; 0 caches it for the life of the process.
cache_ttl: 0s
##################### TelemetryStore #####################
telemetrystore:

View File

@@ -2205,10 +2205,10 @@ components:
type: object
CloudintegrationtypesAWSConnectionArtifact:
properties:
cliCommand:
connectionUrl:
type: string
required:
- cliCommand
- connectionUrl
type: object
CloudintegrationtypesAWSIntegrationConfig:
properties:
@@ -2364,8 +2364,11 @@ components:
properties:
cliCommand:
type: string
cloudPowerShellCommand:
type: string
required:
- cliCommand
- cloudPowerShellCommand
type: object
CloudintegrationtypesAzureIntegrationConfig:
properties:
@@ -4280,11 +4283,6 @@ components:
last_6_hr, last_1_day, last_3_days, last_1_week or last_1_month pin a
fixed window.'
type: object
DashboardtypesBubbleRadius:
maximum: 64
minimum: 1
nullable: true
type: integer
DashboardtypesBuilderQuerySpec:
discriminator:
mapping:
@@ -4517,231 +4515,6 @@ components:
minimum: 0
nullable: true
type: number
DashboardtypesGeoMapBubbles:
properties:
maxRadius:
allOf:
- $ref: '#/components/schemas/DashboardtypesBubbleRadius'
description: Radius of the largest circle, in CSS pixels from 1 to 64. Null
means 8 on the points layer, 28 otherwise.
minRadius:
allOf:
- $ref: '#/components/schemas/DashboardtypesBubbleRadius'
description: Radius of the smallest circle, in CSS pixels from 1 to 64.
Null means 2 on the points layer, 3 otherwise.
type: object
DashboardtypesGeoMapChartAppearance:
properties:
bubbles:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapBubbles'
description: The circle radius range of the bubbles and points layers.
colors:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapColors'
description: The color scale applied to the colorBy values.
labels:
description: Shows country and state names once each fits inside its shape.
Null shows them.
nullable: true
type: boolean
type: object
DashboardtypesGeoMapColors:
properties:
fill:
description: The hex color varied in opacity mode. Empty uses the default
fill.
type: string
max:
description: The value mapped to the highest color. Null uses the highest
value in the data.
nullable: true
type: number
min:
description: The value mapped to the lowest color. Null uses the lowest
value in the data. Must not exceed max.
nullable: true
type: number
mode:
allOf:
- $ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
description: 'How values are colored: palette (default) picks a hue from
palette, opacity varies the opacity of fill.'
palette:
allOf:
- $ref: '#/components/schemas/DashboardtypesHeatmapPalette'
description: The named color ramp used in palette mode, such as lava (default),
ice or ember.
scale:
allOf:
- $ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
description: 'How values map onto the color range: log (default), sqrt or
linear.'
steps:
description: The number of distinct color steps, 2 to 128. 0 uses the chart
default.
type: integer
type: object
DashboardtypesGeoMapCountryFormat:
enum:
- auto
- alpha2
- alpha3
- name
type: string
DashboardtypesGeoMapLayer:
enum:
- regions
- bubbles
- points
type: string
DashboardtypesGeoMapLocation:
properties:
country:
description: Group-by key holding the country, for the country and subdivision
modes. Empty detects it by name, such as geo.country.iso_code.
type: string
countryFormat:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapCountryFormat'
description: 'How country values are written: auto (default) detects it,
alpha2, alpha3 or name.'
lat:
description: Group-by key holding the latitude, for the coordinates mode.
Empty detects it by name.
type: string
lon:
description: Group-by key holding the longitude, for the coordinates mode.
Empty detects it by name.
type: string
mode:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapLocationMode'
description: 'How rows are placed: auto (default) detects it from the group-by
keys, country, subdivision or coordinates.'
region:
description: Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code),
for the subdivision mode. Empty detects it by name.
type: string
required:
- mode
- countryFormat
type: object
DashboardtypesGeoMapLocationMode:
enum:
- auto
- country
- subdivision
- coordinates
type: string
DashboardtypesGeoMapPanelSpec:
properties:
chartAppearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapChartAppearance'
description: Color scale, bubble size and labels.
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesTableFormatting'
description: Per-column units and decimal precision.
legend:
allOf:
- $ref: '#/components/schemas/DashboardtypesLegend'
description: Legend placement.
location:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapLocation'
description: The group-by columns that place each row on the map.
map:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapSettings'
description: Layer, view, projection and the value columns drawn.
thresholds:
description: Rules that recolor a shape, bubble or point when its value
compares to a threshold as specified.
items:
$ref: '#/components/schemas/DashboardtypesGeoMapThreshold'
nullable: true
type: array
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the values are computed over.
type: object
DashboardtypesGeoMapProjection:
enum:
- mercator
- equal_earth
- natural_earth
type: string
DashboardtypesGeoMapSettings:
properties:
colorBy:
description: Value column key, as in a Table panel, that sets the color.
Empty uses the first value column.
type: string
layer:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapLayer'
description: 'What is drawn: regions (default) fills each shape, bubbles
draws a circle per region, points a circle per coordinate.'
projection:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapProjection'
description: 'The map projection: mercator (default), equal_earth or natural_earth.'
sizeBy:
description: Value column key, as in a Table panel, that sets the bubble
or point size. Empty uses colorBy.
type: string
view:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapView'
description: 'The area shown: world (default), fit to the data, a continent,
or country with viewCountry.'
viewCountry:
description: Uppercase ISO 3166-1 alpha-2 code of the country the country
view shows.
type: string
required:
- layer
- view
- projection
type: object
DashboardtypesGeoMapThreshold:
properties:
color:
description: 'The CSS color of the shape, bubble or point while the threshold
is active, for example #F1575F; CSS named colors such as red also work.'
type: string
operator:
allOf:
- $ref: '#/components/schemas/DashboardtypesComparisonOperator'
description: 'The comparison that activates the threshold: above (default),
below, above_or_equal, below_or_equal, equal or not_equal.'
unit:
description: The unit id of value, such as ms or percent. Empty means the
colorBy column's unit.
type: string
value:
description: The value compared against, in unit.
format: double
type: number
required:
- value
- color
type: object
DashboardtypesGeoMapView:
enum:
- world
- fit
- africa
- asia
- europe
- north_america
- south_america
- oceania
- country
type: string
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -5479,7 +5252,6 @@ components:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/GeoMapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -5489,7 +5261,6 @@ components:
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
signoz/TopListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -5503,8 +5274,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5519,8 +5288,6 @@ components:
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/TopListPanel
- signoz/GeoMapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5552,21 +5319,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/GeoMapPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesGeoMapPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
@@ -5702,21 +5454,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/TopListPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesTopListPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelSpec:
properties:
display:
@@ -6479,43 +6216,6 @@ components:
last_6_hr, last_1_day, last_3_days, last_1_week or last_1_month pin a
fixed window.'
type: object
DashboardtypesTopListAppearance:
properties:
barColor:
description: Hex color of every bar; a matching background threshold overrides
it.
type: string
showRank:
description: Numbers each row by its position in the list.
type: boolean
showShare:
description: Shows each row's share of the listed rows' total.
type: boolean
required:
- barColor
type: object
DashboardtypesTopListPanelSpec:
properties:
appearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesTopListAppearance'
description: Rank, share and bar color options.
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
description: Unit and decimal precision of the values.
thresholds:
description: Rules that recolor a row when its value compares to a threshold
as specified.
items:
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
nullable: true
type: array
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the rows are computed over.
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:
@@ -25453,9 +25153,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: List rule saved views
tags:
- rules
@@ -25511,9 +25211,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: Create rule saved view
tags:
- rules
@@ -25565,9 +25265,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: Delete rule saved view
tags:
- rules
@@ -25634,9 +25334,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: Update rule saved view
tags:
- rules

View File

@@ -28,7 +28,7 @@ func ConvertConfig(src ExternalConfig) (InternalConfig, error)
// Over this:
type ConfigAdapter struct { ... }
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
func (adapter *ConfigAdapter) ToInternal() (InternalConfig, error)
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
```
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
@@ -66,10 +66,10 @@ A method on a structured type should not strip meaning from the structure it bel
```go
// Problematic: callers cannot distinguish how items were related.
func (order *Order) AllLineItems() []LineItem { ... }
func (o *Order) AllLineItems() []LineItem { ... }
// Better: the validation logic operates on the full structure.
func ValidateOrder(order *Order) error { ... }
func ValidateOrder(o *Order) error { ... }
```
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.

View File

@@ -74,7 +74,7 @@ The `*Visitor` passed in provides everything needed to build predicates. Use the
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
```go
func (resolver sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
switch key {
case "created_by":
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)

View File

@@ -82,7 +82,7 @@ In the module implementation, implement the new method. A typical implementation
- Uses the `render` package to write responses or errors
```go
func (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
// Extract authentication and organization context from req.Context()
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -98,7 +98,7 @@ func (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
}
// Call module functions
out, err := handler.module.CreateThing(req.Context(), claims.OrgID, &in)
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
if err != nil {
render.Error(rw, err)
return

View File

@@ -59,8 +59,7 @@ Follow these rules:
### Unexported symbols
- Struct receivers: the lowercase name of the role the type plays, the same on every method of the type (`provider`, `module`, `handler`, `store`, `middleware`, `migration`). Never one or two characters: `c`, `h`, `m` say nothing at the call site and collide with local variables.
- When the receiver name would shadow an imported package, alias the import, not the receiver (`pkghandler "github.com/SigNoz/signoz/pkg/http/handler"` with a `handler` receiver).
- Struct receivers: one or two characters (`c`, `f`, `br`).
- Helper functions: descriptive lowercase names (`parseToken`, `buildQuery`).
### Constants

View File

@@ -70,16 +70,16 @@ The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but
```go
// pkg/instrumentation/sdk.go
func (sdk *SDK) Start(ctx context.Context) error {
<-sdk.startCh
func (i *SDK) Start(ctx context.Context) error {
<-i.startCh
return nil
}
func (sdk *SDK) Stop(ctx context.Context) error {
close(sdk.startCh)
func (i *SDK) Stop(ctx context.Context) error {
close(i.startCh)
return errors.Join(
sdk.sdk.Shutdown(ctx),
sdk.meterProviderShutdownFunc(ctx),
i.sdk.Shutdown(ctx),
i.meterProviderShutdownFunc(ctx),
)
}
```
@@ -197,8 +197,8 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
}
}
func (service *service) Start(ctx context.Context) error { ... }
func (service *service) Stop(ctx context.Context) error { ... }
func (s *service) Start(ctx context.Context) error { ... }
func (s *service) Stop(ctx context.Context) error { ... }
```
### Provider that is also a service

View File

@@ -17,7 +17,7 @@ Two rules shape how the core type behaves:
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
// Receiver form
func (maintenance *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
```
- **`X` can double as the storage row** when the DB shape would be identical. `Channel` embeds `bun.BaseModel` directly, and there is no `StorableChannel`. This is the preferred shape when it works.
@@ -175,10 +175,10 @@ The rules that make the envelope work:
```go
// Domain invariants: every write path re-runs these.
func (entity *X) Validate() error { ... }
func (x *X) Validate() error { ... }
// Request-shape-only: checks that do not apply once the value is persisted.
func (postable *PostableX) Validate() error { ... }
func (p *PostableX) Validate() error { ... }
```
- **Type aliases, not wrappers**, when two shapes are identical. `type GettableChannels = []*Channel` is correct because it adds no semantics beyond the underlying type.
- **Serialization tags** follow [handler.md](handler.md): `required:"true"` means the JSON key must be present, `nullable:"true"` is required on any slice or map that may serialize as `null`, and types with a fixed value set must implement `Enum() []any`.

View File

@@ -11,7 +11,7 @@ Before running E2E tests, ensure you have the following installed:
- Python 3.13+
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Docker (for containerized services)
- Node 18+ and pnpm
- Node 18+ and Yarn
### Initial Setup
@@ -24,8 +24,8 @@ uv sync
2. Install Node deps and Playwright browsers:
```bash
cd e2e
pnpm install
pnpm install:browsers # one-time Playwright browser install
yarn install
yarn install:browsers # one-time Playwright browser install
```
### Starting the Test Environment
@@ -84,118 +84,127 @@ tests/
├── playwright.config.ts # loads .env + .env.local via dotenv
├── .env.example # staging-mode template
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── AGENTS.md # read this before writing a spec
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── drivers/ # the driver kit: one module per feature
│ ├── kit.ts # layer(), seeded(), the root `test`
│ ├── auth.ts # login + per-worker storageState
│ ├── api.ts # authed HTTP client + seeder URL
│ ├── tanstack.ts # the shared table driver
│ ├── alerts/ # feature driver, seeds, payloads
│ └── infra/ # feature driver, seeds, __generated__/datasets.ts
├── docs/flake-notes.md # measurements and timeout budgets
├── testdata/ # static data files (marked generated)
├── tests/ # Playwright .spec.ts files, one dir per feature
├── drivers/ # the driver kit and one driver per feature
│ ├── kit.ts
│ └── routing.ts
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
│ ├── auth.ts
│ └── logs-explorer.ts
├── testdata/ # static data files (JSON) used by helpers and tests
│ └── traces/large-trace.json # (example)
├── tests/ # Playwright .spec.ts files, one dir per feature area
│ └── alerts/
│ └── list.spec.ts # (example)
│ └── 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
```
### The driver kit
### `fixtures/` vs `helpers/` — what goes where
A spec imports **one** module: its feature's `test` constant. That constant is
built by composing layers, and each layer adds one named driver to the test
arguments — so a spec body never handles a `Page`:
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. `gotoLogsExplorer(page)`) and the constants those helpers and the tests both refer to (e.g. `LINKED_ROW_CLASS`). 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, `traces/large-trace.json`, a real trace the trace-details helpers seed.
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/`.
### Drivers
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
```ts
import { expect, test } from '../../drivers/alerts';
import { expect, PATH, test } from '../../drivers/routing';
test('TC-01 seeded rules render', async ({ alerts, alertList }) => {
await alerts.list.goto({ search: alertList.namePrefix });
await expect(alerts.list.rows()).toHaveCount(10);
test('TC-09 RouteTab switches the url', async ({ routing }) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
});
```
- **`drivers/kit.ts`** holds the composition primitives. `driver(name, build, dispose?)`
binds a factory to the authenticated page, and `dispose` runs after the test
on the same page so a driver can delete what it seeded; `seeded(name, open, close)` seeds
data once per worker on a throwaway admin page and tears it down after;
`allowFor(floor)` raises the test timeout to a floor and never lowers it.
`test.slow()` is banned in drivers, because it compounds when called in a loop.
- **`drivers/<feature>.ts`** (or `drivers/<feature>/`) exports the feature's
driver, its layers, and the composed `test`. A driver is a plain function
returning an object of closures — no classes, and no `page` parameter on
anything a spec calls.
- **Shared drivers** — `tanstack.ts` (the table), `querysearch.ts` (the
expression editor), `antd.ts` (select primitives), `console.ts`, `api.ts`,
`storage.ts` — are composed into feature drivers by direct call, not by
reaching for another fixture.
- **`drivers/` is AI-owned**: coding agents generate and maintain it with the
specs, and `.gitattributes` collapses it in diffs.
- **`testdata/`** and any `__generated__/` directory hold transcribed data. Both
are marked `linguist-generated`; something in the suite re-derives them at
runtime, so a stale value fails loudly rather than silently.
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
**Fixture scopes:**
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
- **worker scope** — shared across a worker. Use for read-only data; it pays the
setup cost once per worker instead of once per test. Playwright builds them
lazily, so a spec that never destructures a seed never pays for it.
- **suite scope**: a dataset only one file uses. Seed it in `test.beforeAll`
through `onAdminPage(browser, …)`, since `authedPage` is test-scoped.
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
### Which layer does this test belong to?
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
Four layers run against this codebase, in cost order: **jest → Storybook + msw →
pytest integration → Playwright**. Before writing an E2E, check the cheaper
three. [`tests/e2e/AGENTS.md`](../../../tests/e2e/AGENTS.md) has the full rule;
the short version:
```
fixtures/alerts/
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
```
- A pure function, a reducer, a config literal or a component's own behaviour is
a **jest** test. Most containers under `frontend/src/container/` already have
one.
- A rendered state that needs no backend (empty, error, loading) is a
**Storybook** story or a jest test with a fixed mock.
- Anything the **API** does — ordering, pagination, filter semantics, validation,
roll-ups — is a pytest integration test, and for infra monitoring and alert
evaluation it very likely already exists. Grep `tests/integration/` first.
- An **E2E** is for a real response reaching a real DOM: URL and history, write
paths that must persist, cross-page navigation, and one seeded proof per
surface.
Specs import from the fixture they need:
```ts
// List tests — just need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// History tests — need history rows from ruler evaluation
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
**When creating new fixtures:**
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
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. To assert a write, attach `page.waitForResponse` before the click, then reload and re-read from the server.
4. **Self-contained state**: each spec seeds its own data, scopes its assertions to the names it seeded, and cleans up. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. Names carry the worker index, so parallel workers never collide. Pick the scope from the list above.
5. **Seed through the kit when the UI flow is not under test.** `apiDriver(page)` (`drivers/api.ts`) creates control-plane objects with the session's token, and the seeder takes telemetry. The JWT lives in `localStorage`, so a spec never improvises with `page.request.*`. Feature drivers wrap both (`dashboards.seed`, `alerts.seed.threshold`).
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `traces/large-trace.json` is POSTed to the seeder by `drivers/traces.ts`. Infra monitoring reads the integration suite's fixtures through `seedDataset` (`drivers/infra/seed.ts`) instead of keeping a copy, and asserts on what it returned.
7. **Locate by testid, in the driver.** A missing testid is added to the product in the same change. Role, label and CSS lookups are a stopgap, converted as that code is touched; antd internals (`.ant-select-dropdown`) are the exception, since the markup is not ours.
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 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. Patterns, in order of preference:
- **Driver `dispose`** — the driver tracks what it seeded and deletes it after the test, so the spec has no cleanup code. See `dashboards.seed(...)` in `tests/e2e/drivers/dashboards.ts`.
- **Per-test seed + cleanup in `try / finally`** — small specs without a driver, where each test owns its data.
- **Suite-level seed + `afterAll` teardown** — larger specs without a driver. One `test.afterAll(...)` deletes everything the suite seeded. See `tests/e2e/tests/llm-o11y/llm-pricing.spec.ts`. `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, `traces/large-trace.json` is a real trace that `loadLargeTrace` (in `helpers/trace-details.ts`) seeds for the trace-details tests.
## How to write an E2E test?
Create a new file `tests/e2e/tests/alerts/smoke.spec.ts`:
```typescript
import { ALERT_LIST_PAGE_SIZE, expect, test } from '../../drivers/alerts';
import { test, expect } from '../../fixtures/auth';
test('TC-01 seeded rules render and paginate', async ({ alerts, alertList }) => {
await alerts.list.goto({ search: alertList.namePrefix });
await expect(alerts.list.table.totalCount()).toContainText(`of ${alertList.count}`);
await expect(alerts.list.rows()).toHaveCount(ALERT_LIST_PAGE_SIZE);
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
});
```
`alertList` is a worker-scoped seed: the rules exist before the test starts, are
shared by every test in the worker that asks for them, and are deleted when the
worker ends.
The `authedPage` fixture (from `tests/e2e/drivers/kit.ts`, re-exported by every feature's `test`) gives you a `Page` whose browser context is already authenticated as the admin user. First use per worker triggers one login; the resulting `storageState` is held in memory and reused for later requests. Take it only when the driver genuinely does not cover what you need — navigation, `goBack`, `waitForResponse`, a second tab.
The `authedPage` fixture (from `tests/e2e/fixtures/auth.ts`) gives you a `Page` whose browser context is already authenticated as the admin user. First use per worker triggers one login; the resulting `storageState` is held in memory and reused for later requests.
To run just this test (assuming the stack is up via `test_setup`):
@@ -204,56 +213,52 @@ cd tests/e2e
npx playwright test tests/alerts/smoke.spec.ts --project=chromium
```
Here's a more comprehensive example, driven entirely through the feature's
driver:
Here's a more comprehensive example that exercises a CRUD flow via the UI:
```typescript
import { expect, test } from '../../drivers/alerts';
import { test, expect } from '../../fixtures/auth';
test('TC-02 row actions persist: disable, then enable', async ({
authedPage: page,
alerts,
ownedRules,
}) => {
const name = `smoke-rule-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
test.describe.configure({ mode: 'serial' });
await alerts.list.goto({ search: name });
await expect(alerts.list.stateCell(ruleId)).toHaveText('OK');
test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }) => {
await page.goto('/alerts?tab=AlertRules');
const name = 'smoke-rule';
await alerts.list.openFirstRowActions();
await Promise.all([
// waitForResponse captures the call the UI triggers — no parallel fetch.
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
alerts.list.menuItem('Disable').click(),
]);
// Seed via UI — click "New Alert", fill form, save.
await page.getByRole('button', { name: /new alert/i }).click();
await page.getByTestId('alert-name-input').fill(name);
// ... fill metric / threshold / save ...
// Re-read from the server rather than trusting the optimistic update.
await page.reload();
await expect(alerts.list.stateCell(ruleId)).toHaveText('Disabled');
// Find the row and exercise the action menu.
const row = page.locator('tr', { hasText: name });
await expect(row).toBeVisible();
await row.locator('[data-testid="alert-actions"] button').first().click();
// waitForResponse captures the network call the UI triggers — no parallel fetch needed.
const patchWait = page.waitForResponse(
(r) => r.url().includes('/rules/') && r.request().method() === 'PATCH',
);
await page.getByRole('menuitem').filter({ hasText: /^disable$/i }).click();
await patchWait;
await expect(row).toContainText(/disabled/i);
});
```
`ownedRules` is a test-scoped seed: every rule it creates is deleted when the
test finishes, so the spec owns its data and nothing leaks into the next one.
### Locator priority
### Commits
Commit driver changes apart from specs, product testids and tests moved to
another layer, so each commit can be reviewed on its own.
[`tests/e2e/AGENTS.md`](../../../tests/e2e/AGENTS.md#commits) has the order.
`pnpm typecheck`, `pnpm lint` and `pnpm fmt:check` gate the suite.
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 on the feature's driver, adding the driver methods and seeds the plan needs.
- **`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).
@@ -265,15 +270,15 @@ The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` too
With the stack already up, from `tests/e2e/`:
```bash
pnpm test # headless, all projects
yarn test # headless, all projects
```
### Running Specific Projects
```bash
pnpm test:chromium # chromium only
pnpm test:firefox
pnpm test:webkit
yarn test:chromium # chromium only
yarn test:firefox
yarn test:webkit
```
### Running Specific Tests
@@ -291,17 +296,17 @@ npx playwright test tests/alerts/history/ --project=chromium
npx playwright test tests/alerts/page.spec.ts --project=chromium
# Single test by title grep
npx playwright test --project=chromium -g "TC-01"
npx playwright test --project=chromium -g "AL-01"
```
### Iterative modes
```bash
pnpm test:ui # Playwright UI mode — watch + step through
pnpm test:headed # headed browser
pnpm test:debug # Playwright inspector, pause-on-breakpoint
pnpm codegen # record-and-replay locator generation
pnpm report # open the last HTML report (artifacts/html)
yarn test:ui # Playwright UI mode — watch + step through
yarn test:headed # headed browser
yarn test:debug # Playwright inspector, pause-on-breakpoint
yarn codegen # record-and-replay locator generation
yarn report # open the last HTML report (artifacts/html)
```
### Staging fallback
@@ -311,7 +316,7 @@ Point `SIGNOZ_E2E_BASE_URL` at a remote env via `.env` — no local backend brin
```bash
cd tests/e2e
cp .env.example .env # fill SIGNOZ_E2E_USERNAME / PASSWORD
pnpm test:staging
yarn test:staging
```
## How to configure different options for E2E tests?
@@ -359,14 +364,12 @@ The same pytest flags integration tests expose work here, since E2E reuses the s
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build on a cold start; later builds are incremental.
- **Changed backend or frontend code? Re-run the setup command** — it passes `--rebuild`, swapping the SigNoz container for one built from your current sources while the rest of the stack stays up.
- **Don't teardown before setup.** `--reuse` correctly handles partially-set-up state, so chaining teardown → setup wastes time.
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Seed through the kit, never with `page.request.*`.
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Drop to `page.request.*` only when the UI can't reach what you need.
- **Use `page.waitForResponse` on UI clicks** to assert BE contracts — it still exercises the UI trigger path.
- **Title every test `TC-NN <short description>`** — keeps the suite navigable and reportable.
- **Split by resource, not by regression suite.** One spec per feature resource; cross-resource junction concerns (cascade-delete, linked-edit) get their own file.
- **Name resources by what they are, plus the worker index** (`e2e-alert-list-w${workerIndex}-${Date.now()}`). Six workers share one stack, so assert only on names you seeded.
- **Use short descriptive resource names** (`alerts-list-rule`, `labels-rule`, `downtime-once`) — no timestamp disambiguation. Each test owns its resources and cleans up in `try/finally`.
- **Never commit `test.only`** — a pre-commit check or CI runs with `forbidOnly: true`.
- **Prefer explicit waits over `page.waitForTimeout(ms)`.** `await expect(locator).toBeVisible()` is always better than `waitForTimeout(5000)`.
- **Unique test names won't save you from shared-tenant state.** When two tests hit the same list page, either serialize (`describe.configure({ mode: 'serial' })`) or isolate cleanup religiously.
- **Green locally, red on CI?** Run `STRESS=1 CI=1 pnpm test <path>` (4x CPU throttle, 2 workers, 2 retries) before filing a product bug.
- **Commit drivers apart from specs.** One concern per commit; see [Commits](#commits).
- **Artifacts go to `tests/e2e/artifacts/`** — HTML report at `artifacts/html`, traces at `artifacts/results/<test>/`. All gitignored; archive the dir in CI.

View File

@@ -2,6 +2,8 @@ package implcloudprovider
import (
"context"
"fmt"
"net/url"
"sort"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -16,14 +18,29 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
return &awscloudprovider{serviceDefinitions: defStore}, nil
}
// TODO: move URL construction logic to cloudintegrationtypes and add unit tests for it.
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
connectionArtifact, err := cloudintegrationtypes.NewAWSConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.AWS.DeploymentRegion)
if err != nil {
return nil, err
}
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Config.AWS.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
q = u.Query()
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
q.Set("param_SigNozAccountId", account.ID.StringValue())
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
return &cloudintegrationtypes.ConnectionArtifact{
AWS: connectionArtifact,
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
}, nil
}

View File

@@ -143,12 +143,7 @@ const preview: Preview = {
'Logs',
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
'Traces',
[
'Explorer',
['Overview', 'Span Drawer'],
'Trace Details',
'Funnel Details',
],
['Explorer', 'Trace Details', 'Funnel Details'],
'Metrics',
['Explorer'],
'Infrastructure',

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|d3-geo|d3-array|internmap|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|d3-geo|d3-array|internmap|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -30,8 +30,7 @@
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'",
"build:geo-outlines": "node scripts/geo-outlines/build.mjs"
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
},
"engines": {
"node": ">=22.0.0",
@@ -79,7 +78,6 @@
"classnames": "2.3.2",
"color": "^4.2.1",
"crypto-js": "4.2.0",
"d3-geo": "3.1.1",
"d3-hierarchy": "3.1.2",
"dayjs": "^1.10.7",
"dompurify": "3.4.15",
@@ -98,7 +96,6 @@
"monaco-editor": "0.55.1",
"motion": "12.4.13",
"nuqs": "2.8.8",
"ol": "10.11.0",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
@@ -133,7 +130,6 @@
"rrule": "2.8.1",
"styled-components": "^5.3.11",
"timestamp-nano": "^1.0.0",
"topojson-client": "3.1.0",
"translation-resilience": "^0.2.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
@@ -174,10 +170,8 @@
"@testing-library/user-event": "14.4.3",
"@types/color": "^3.0.3",
"@types/crypto-js": "4.2.2",
"@types/d3-geo": "3.1.1",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/geojson": "7946.0.16",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
@@ -194,8 +188,6 @@
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/topojson-client": "3.1.5",
"@types/topojson-specification": "1.0.5",
"@typescript/native-preview": "7.0.0-dev.20260430.1",
"eslint-plugin-sonarjs": "4.0.2",
"husky": "^9.1.7",
@@ -217,8 +209,6 @@
"storybook": "10.6.3",
"stylelint": "17.15.0",
"svgo": "4.1.0",
"topojson-server": "3.0.1",
"topojson-simplify": "3.0.3",
"ts-jest": "29.4.15",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",

201
frontend/pnpm-lock.yaml generated
View File

@@ -150,9 +150,6 @@ importers:
crypto-js:
specifier: 4.2.0
version: 4.2.0
d3-geo:
specifier: 3.1.1
version: 3.1.1
d3-hierarchy:
specifier: 3.1.2
version: 3.1.2
@@ -207,9 +204,6 @@ importers:
nuqs:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
ol:
specifier: 10.11.0
version: 10.11.0
overlayscrollbars:
specifier: ^2.16.0
version: 2.16.0
@@ -312,9 +306,6 @@ importers:
timestamp-nano:
specifier: ^1.0.0
version: 1.0.1
topojson-client:
specifier: 3.1.0
version: 3.1.0
translation-resilience:
specifier: ^0.2.0
version: 0.2.0
@@ -394,18 +385,12 @@ importers:
'@types/crypto-js':
specifier: 4.2.2
version: 4.2.2
'@types/d3-geo':
specifier: 3.1.1
version: 3.1.1
'@types/d3-hierarchy':
specifier: 1.1.11
version: 1.1.11
'@types/event-source-polyfill':
specifier: ^1.0.0
version: 1.0.1
'@types/geojson':
specifier: 7946.0.16
version: 7946.0.16
'@types/history':
specifier: 4.7.11
version: 4.7.11
@@ -454,12 +439,6 @@ importers:
'@types/testing-library__jest-dom':
specifier: ^5.14.5
version: 5.14.5
'@types/topojson-client':
specifier: 3.1.5
version: 3.1.5
'@types/topojson-specification':
specifier: 1.0.5
version: 1.0.5
'@typescript/native-preview':
specifier: 7.0.0-dev.20260430.1
version: 7.0.0-dev.20260430.1
@@ -523,12 +502,6 @@ importers:
svgo:
specifier: 4.1.0
version: 4.1.0
topojson-server:
specifier: 3.0.1
version: 3.0.1
topojson-simplify:
specifier: 3.0.3
version: 3.0.3
ts-jest:
specifier: 29.4.15
version: 29.4.15(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@22.20.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@22.20.5)(typescript@5.9.3)))(typescript@5.9.3)
@@ -2930,9 +2903,6 @@ packages:
'@petamoriken/float16@3.8.0':
resolution: {integrity: sha512-AhVAm6SQ+zgxIiOzwVdUcDmKlu/qU39FiYD2UD6kQQaVenrn0dGZewIghWAENGQsvC+1avLCuT+T2/3Gsp/W3w==}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -3986,9 +3956,6 @@ packages:
'@types/d3-geo@3.1.0':
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
'@types/d3-geo@3.1.1':
resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
'@types/d3-hierarchy@1.1.11':
resolution: {integrity: sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==}
@@ -4034,8 +4001,8 @@ packages:
'@types/file-saver@2.0.5':
resolution: {integrity: sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/geojson@7946.0.14':
resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==}
'@types/graceful-fs@4.1.6':
resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==}
@@ -4115,9 +4082,6 @@ packages:
'@types/prop-types@15.7.5':
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
'@types/rbush@4.0.0':
resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==}
'@types/react-addons-update@0.14.21':
resolution: {integrity: sha512-HOxr0Hd8C1L4uw8DHyv2etqMVIj78oLEpe567/HgjoE+1Lc+PUsTGXTrkr1BDvFqsu5r49mSlgI5evwrk9eutA==}
@@ -4169,12 +4133,6 @@ packages:
'@types/testing-library__jest-dom@5.14.5':
resolution: {integrity: sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==}
'@types/topojson-client@3.1.5':
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
'@types/topojson-specification@1.0.5':
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -4460,9 +4418,6 @@ packages:
'@xobotyi/scrollbar-width@1.9.5':
resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
'@zarrita/storage@0.2.0':
resolution: {integrity: sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==}
'@zxing/text-encoding@0.9.0':
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
@@ -5262,10 +5217,6 @@ packages:
resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==}
engines: {node: '>=12'}
d3-geo@3.1.1:
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
engines: {node: '>=12'}
d3-hierarchy@1.1.9:
resolution: {integrity: sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==}
@@ -5541,9 +5492,6 @@ packages:
earcut@2.2.4:
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
earcut@3.2.4:
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -5811,9 +5759,6 @@ packages:
fflate@0.4.9:
resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@@ -5991,10 +5936,6 @@ packages:
resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==}
engines: {node: '>=10.19'}
geotiff@3.1.0-beta.0:
resolution: {integrity: sha512-wbVwOaQYuN+ywly5NoMXn37nHslwkQg5EFHqMeUTtjDACrSag0EEVlFsc+2DK3Pzmmp6XzJW2RTZOamPsBM6ng==}
engines: {node: '>=10.19'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -7567,9 +7508,6 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
numcodecs@0.3.2:
resolution: {integrity: sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==}
nuqs@2.8.8:
resolution: {integrity: sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==}
peerDependencies:
@@ -7646,9 +7584,6 @@ packages:
ol-mapbox-style@10.7.0:
resolution: {integrity: sha512-S/UdYBuOjrotcR95Iq9AejGYbifKeZE85D9VtH11ryJLQPTZXZSW1J5bIXcr4AlAH6tyjPPHTK34AdkwB32Myw==}
ol@10.11.0:
resolution: {integrity: sha512-OJ+lrx7kx/1O7Xx18Ff9QkVnwZIEL0OuhJQSy2sYgzzDvyZ2qHvuZEGAajbSstUyBwaS1OtQ4RWnY84tRMDdqg==}
ol@7.4.0:
resolution: {integrity: sha512-bgBbiah694HhC0jt8ptEFNRXwgO8d6xWH3G97PCg4bmn9Li5nLLbi59oSrvqUI6VPVwonPQF1YcqJymxxyMC6A==}
@@ -7873,10 +7808,6 @@ packages:
resolution: {integrity: sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==}
hasBin: true
pbf@5.1.2:
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -8082,15 +8013,9 @@ packages:
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
rbush@3.0.1:
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
rbush@4.0.1:
resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==}
rc-cascader@3.20.0:
resolution: {integrity: sha512-lkT9EEwOcYdjZ/jvhLoXGzprK1sijT3/Tp4BLxQQcHDZkkOzzwYQC9HgmKoJz0K7CukMfgvO9KqHeBdgE+pELw==}
peerDependencies:
@@ -8651,9 +8576,6 @@ packages:
resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
reference-spec-reader@0.2.0:
resolution: {integrity: sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==}
refractor@3.6.0:
resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
@@ -9340,18 +9262,6 @@ packages:
toggle-selection@1.0.6:
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
topojson-client@3.1.0:
resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
hasBin: true
topojson-server@3.0.1:
resolution: {integrity: sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==}
hasBin: true
topojson-simplify@3.0.3:
resolution: {integrity: sha512-V+pBjLVzSQ3+hSOxBiV01OVXgFiCmMO8ia3huxKEyIMTC1ApQHBcdXdOqcQ6U2JJJD31TZduwY6KyF15R8sUgg==}
hasBin: true
tough-cookie@4.1.4:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
@@ -9600,10 +9510,6 @@ packages:
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
unzipit@2.0.0:
resolution: {integrity: sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==}
engines: {node: '>=18'}
update-browserslist-db@1.3.3:
resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
hasBin: true
@@ -9922,9 +9828,6 @@ packages:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
xml-utils@1.10.2:
resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==}
xml-utils@1.7.0:
resolution: {integrity: sha512-bWB489+RQQclC7A9OW8e5BzbT8Tu//jtAOvkYwewFr+Q9T9KDGvfzC1lp0pYPEQPEoPQLDkmxkepSC/2gIAZGw==}
@@ -10007,18 +9910,12 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zarrita@0.7.5:
resolution: {integrity: sha512-ZriOSEne+Wf2iuM0yjDZvUKpHNlug3dO7qJ6mAxXoOgsYV18DPOkAkwB9e0rf21bHcKcOffFuwoZwn5g6pvUeA==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zstddec@0.1.0:
resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==}
zstddec@0.2.0:
resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==}
zustand@5.0.11:
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
engines: {node: '>=12.20.0'}
@@ -12600,8 +12497,6 @@ snapshots:
'@petamoriken/float16@3.8.0': {}
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -13689,11 +13584,7 @@ snapshots:
'@types/d3-geo@3.1.0':
dependencies:
'@types/geojson': 7946.0.16
'@types/d3-geo@3.1.1':
dependencies:
'@types/geojson': 7946.0.16
'@types/geojson': 7946.0.14
'@types/d3-hierarchy@1.1.11': {}
@@ -13733,7 +13624,7 @@ snapshots:
'@types/file-saver@2.0.5': {}
'@types/geojson@7946.0.16': {}
'@types/geojson@7946.0.14': {}
'@types/graceful-fs@4.1.6':
dependencies:
@@ -13822,8 +13713,6 @@ snapshots:
'@types/prop-types@15.7.5': {}
'@types/rbush@4.0.0': {}
'@types/react-addons-update@0.14.21':
dependencies:
'@types/react': 18.0.26
@@ -13894,15 +13783,6 @@ snapshots:
dependencies:
'@types/jest': 30.0.0
'@types/topojson-client@3.1.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/topojson-specification': 1.0.5
'@types/topojson-specification@1.0.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7':
@@ -14198,11 +14078,6 @@ snapshots:
'@xobotyi/scrollbar-width@1.9.5': {}
'@zarrita/storage@0.2.0':
dependencies:
reference-spec-reader: 0.2.0
unzipit: 2.0.0
'@zxing/text-encoding@0.9.0':
optional: true
@@ -15091,10 +14966,6 @@ snapshots:
dependencies:
d3-array: 3.2.4
d3-geo@3.1.1:
dependencies:
d3-array: 3.2.4
d3-hierarchy@1.1.9: {}
d3-hierarchy@3.1.2: {}
@@ -15361,8 +15232,6 @@ snapshots:
earcut@2.2.4: {}
earcut@3.2.4: {}
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.427: {}
@@ -15679,8 +15548,6 @@ snapshots:
fflate@0.4.9: {}
fflate@0.8.3: {}
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -15869,17 +15736,6 @@ snapshots:
xml-utils: 1.7.0
zstddec: 0.1.0
geotiff@3.1.0-beta.0:
dependencies:
'@petamoriken/float16': 3.9.3
lerc: 3.0.0
pako: 2.1.0
parse-headers: 2.0.5
quick-lru: 6.1.1
web-worker: 1.5.0
xml-utils: 1.10.2
zstddec: 0.2.0
get-caller-file@2.0.5: {}
get-east-asian-width@1.5.0: {}
@@ -17937,10 +17793,6 @@ snapshots:
dependencies:
boolbase: 1.0.0
numcodecs@0.3.2:
dependencies:
fflate: 0.8.3
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.0.0
@@ -18019,15 +17871,6 @@ snapshots:
mapbox-to-css-font: 2.4.5
ol: 7.4.0
ol@10.11.0:
dependencies:
'@types/rbush': 4.0.0
earcut: 3.2.4
geotiff: 3.1.0-beta.0
pbf: 5.1.2
rbush: 4.0.1
zarrita: 0.7.5
ol@7.4.0:
dependencies:
earcut: 2.2.4
@@ -18354,10 +18197,6 @@ snapshots:
ieee754: 1.2.1
resolve-protobuf-schema: 2.1.0
pbf@5.1.2:
dependencies:
resolve-protobuf-schema: 2.1.0
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -18541,16 +18380,10 @@ snapshots:
quickselect@2.0.0: {}
quickselect@3.0.0: {}
rbush@3.0.1:
dependencies:
quickselect: 2.0.0
rbush@4.0.1:
dependencies:
quickselect: 3.0.0
rc-cascader@3.20.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@babel/runtime': 7.28.2
@@ -19284,8 +19117,6 @@ snapshots:
dependencies:
'@eslint-community/regexpp': 4.12.2
reference-spec-reader@0.2.0: {}
refractor@3.6.0:
dependencies:
hastscript: 6.0.0
@@ -20056,19 +19887,6 @@ snapshots:
toggle-selection@1.0.6: {}
topojson-client@3.1.0:
dependencies:
commander: 2.20.3
topojson-server@3.0.1:
dependencies:
commander: 2.20.3
topojson-simplify@3.0.3:
dependencies:
commander: 2.20.3
topojson-client: 3.1.0
tough-cookie@4.1.4:
dependencies:
psl: 1.15.0
@@ -20337,8 +20155,6 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
unzipit@2.0.0: {}
update-browserslist-db@1.3.3(browserslist@4.28.9):
dependencies:
browserslist: 4.28.9
@@ -20660,8 +20476,6 @@ snapshots:
xml-name-validator@4.0.0: {}
xml-utils@1.10.2: {}
xml-utils@1.7.0: {}
xml@1.0.1: {}
@@ -20738,17 +20552,10 @@ snapshots:
yoctocolors@2.1.2: {}
zarrita@0.7.5:
dependencies:
'@zarrita/storage': 0.2.0
numcodecs: 0.3.2
zod@4.3.6: {}
zstddec@0.1.0: {}
zstddec@0.2.0: {}
zustand@5.0.11(@types/react@18.0.26)(immer@11.1.3)(react@18.2.0)(use-sync-external-store@1.6.0(react@18.2.0)):
optionalDependencies:
'@types/react': 18.0.26

View File

@@ -103,7 +103,7 @@
"placeholder_slack_description": "Description",
"placeholder_pager_description": "Description",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",

View File

@@ -169,7 +169,7 @@
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",

View File

@@ -1,210 +0,0 @@
// Builds the Geo Map outlines assets: Natural Earth 50m admin-0 countries, and
// 10m admin-1 states/provinces for every country with at least two of them.
// Run by hand when bumping Natural Earth or editing regions/aliases; the output is committed.
// Usage: pnpm build:geo-outlines
//
// Nothing is written until every invariant in validate.mjs holds. The assets
// are built in a staging folder and swapped in whole, so a failed run leaves
// the committed ones as they were.
import {
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { gzipSync } from 'node:zlib';
import {
ASSETS_DIR,
COUNTRIES_URL,
SUBDIVISION_LIST_FILE,
SUBDIVISIONS_URL,
} from './config.mjs';
import { buildCountries } from './countries.mjs';
import { buildSubdivisions } from './subdivisions.mjs';
import { validate } from './validate.mjs';
const DOWNLOAD_TIMEOUT_MS = 120_000;
const DOWNLOAD_ATTEMPTS = 3;
const STAGING_DIR = `${ASSETS_DIR}.staging`;
const PREVIOUS_DIR = `${ASSETS_DIR}.previous`;
// Everything in the assets folder is generated; anything else would be lost
// in the swap, so its presence stops the build.
const GENERATED_ENTRIES = new Set([
'countries-50m.json',
'lookups.json',
'subdivisions',
]);
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
const sizeOf = (json) =>
`${kb(Buffer.byteLength(json))} raw / ${kb(gzipSync(json).length)} gzip`;
async function downloadFeatures(url) {
for (let attempt = 1; ; attempt += 1) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = await response.json();
if (
body?.type !== 'FeatureCollection' ||
!Array.isArray(body.features) ||
body.features.length === 0
) {
throw new Error('response is not a non-empty GeoJSON FeatureCollection');
}
return body;
} catch (error) {
if (attempt === DOWNLOAD_ATTEMPTS) {
throw new Error(
`download failed after ${attempt} attempts: ${url}\n ${error.message}`,
);
}
await sleep(attempt * 2_000);
}
}
}
function subdivisionListSource(countries) {
return `// Generated by scripts/geo-outlines/build.mjs; do not edit.
export const SUBDIVISION_COUNTRY_CODES = [
${countries.map((country) => `\t'${country}',`).join('\n')}
] as const;
`;
}
// A run that died mid-swap leaves the assets in the previous folder.
function recoverInterruptedSwap() {
if (!existsSync(ASSETS_DIR) && existsSync(PREVIOUS_DIR)) {
renameSync(PREVIOUS_DIR, ASSETS_DIR);
}
rmSync(STAGING_DIR, { recursive: true, force: true });
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
}
function assertOnlyGeneratedEntries() {
if (!existsSync(ASSETS_DIR)) {
return;
}
const foreign = readdirSync(ASSETS_DIR).filter(
(entry) => !GENERATED_ENTRIES.has(entry),
);
if (foreign.length > 0) {
throw new Error(
`${ASSETS_DIR} holds files this build doesn't generate: ${foreign.join(', ')}`,
);
}
}
/** Writes every output under the staging folder; returns the sizes report. */
function stage(outputs) {
const subdivisionDir = join(STAGING_DIR, 'subdivisions');
mkdirSync(subdivisionDir, { recursive: true });
writeFileSync(join(STAGING_DIR, 'countries-50m.json'), outputs.countriesJson);
writeFileSync(join(STAGING_DIR, 'lookups.json'), outputs.lookupsJson);
const sizes = [];
for (const [country, file] of outputs.subdivisionFiles) {
const json = JSON.stringify(file);
writeFileSync(join(subdivisionDir, `${country}.json`), json);
sizes.push({
country,
shapes: file.objects.subdivisions.geometries.length,
gzip: gzipSync(json).length,
});
}
writeFileSync(`${SUBDIVISION_LIST_FILE}.staging`, outputs.subdivisionList);
return sizes;
}
/** Swaps the staged assets in; puts the previous ones back if that fails. */
function commitStaged() {
const hadAssets = existsSync(ASSETS_DIR);
if (hadAssets) {
renameSync(ASSETS_DIR, PREVIOUS_DIR);
}
try {
renameSync(STAGING_DIR, ASSETS_DIR);
// A failed rename leaves the previous list in place.
renameSync(`${SUBDIVISION_LIST_FILE}.staging`, SUBDIVISION_LIST_FILE);
} catch (error) {
rmSync(ASSETS_DIR, { recursive: true, force: true });
if (hadAssets) {
renameSync(PREVIOUS_DIR, ASSETS_DIR);
}
throw error;
}
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
}
function cleanUpStaging() {
rmSync(STAGING_DIR, { recursive: true, force: true });
rmSync(`${SUBDIVISION_LIST_FILE}.staging`, { force: true });
}
function report(countries, subdivisions, outputs, sizes) {
const totalGzip = sizes.reduce((sum, { gzip }) => sum + gzip, 0);
return {
countries: {
...countries.report,
sizes: {
'countries-50m.json': sizeOf(outputs.countriesJson),
'lookups.json': sizeOf(outputs.lookupsJson),
},
},
subdivisions: {
...subdivisions.report,
countries: sizes.length,
totalGzip: kb(totalGzip),
largest: [...sizes]
.sort((a, b) => b.gzip - a.gzip)
.slice(0, 10)
.map(
({ country, shapes, gzip }) =>
`${country}: ${shapes} shapes, ${kb(gzip)} gzip`,
),
},
};
}
async function main() {
recoverInterruptedSwap();
assertOnlyGeneratedEntries();
const [countrySource, subdivisionSource] = await Promise.all([
downloadFeatures(COUNTRIES_URL),
downloadFeatures(SUBDIVISIONS_URL),
]);
const countries = buildCountries(countrySource);
const subdivisions = buildSubdivisions(subdivisionSource);
validate(countries, subdivisions);
const outputs = {
countriesJson: JSON.stringify(countries.topology),
lookupsJson: JSON.stringify(countries.lookups),
subdivisionFiles: subdivisions.files,
subdivisionList: subdivisionListSource([...subdivisions.files.keys()]),
};
try {
const sizes = stage(outputs);
commitStaged();
process.stdout.write(
`${JSON.stringify(report(countries, subdivisions, outputs, sizes), null, 2)}\n`,
);
} finally {
cleanUpStaging();
}
}
main().catch((error) => {
process.stderr.write(`Geo outlines build failed: ${error.message}\n`);
process.exitCode = 1;
});

View File

@@ -1,181 +0,0 @@
// Inputs and tuning for the Geo Map outlines build. Tables only; no logic.
// Read by every other module here; not run on its own.
//
// Edit this file to bump Natural Earth (the pinned version below), retune
// simplification, or fix a region, alias or merge; then run
// pnpm build:geo-outlines and commit the rebuilt assets.
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const NATURAL_EARTH_BASE =
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson';
export const COUNTRIES_URL = `${NATURAL_EARTH_BASE}/ne_50m_admin_0_countries.geojson`;
export const SUBDIVISIONS_URL = `${NATURAL_EARTH_BASE}/ne_10m_admin_1_states_provinces.geojson`;
const GEO_MAP_DIR = join(
dirname(fileURLToPath(import.meta.url)),
'../../src/lib/visualization/charts/GeoMap',
);
export const ASSETS_DIR = join(GEO_MAP_DIR, 'assets');
export const SUBDIVISION_LIST_FILE = join(
GEO_MAP_DIR,
'geo/subdivisionCountries.ts',
);
export const COUNTRY_QUANTIZATION = 1e5;
// Simplification drops every point whose triangle with its neighbours is
// smaller than this, in square degrees. A fixed size, unlike a share of all
// points, still leaves a small island a few points of its own.
export const COUNTRY_MIN_WEIGHT = 3e-3;
// 10m is street-level detail; a country view needs far less.
export const SUBDIVISION_QUANTIZATION = 1e4;
export const SUBDIVISION_MIN_WEIGHT = 1e-3;
// A shape this many times the minimum weight or smaller could lose every polygon.
export const PROTECTED_SHAPE_FACTOR = 10;
// A country view needs at least this many coded states to be worth drawing.
export const MIN_SUBDIVISIONS = 2;
// ISO 3166-2 codes that changed after Natural Earth v5.1.2, or older spellings
// still in GeoIP data, mapped to the code the shape carries.
export const SUBDIVISION_ALIASES = {
IN: {
'IN-CG': 'IN-CT',
'IN-UK': 'IN-UT',
'IN-TS': 'IN-TG',
'IN-OD': 'IN-OR',
'IN-DD': 'IN-DH',
'IN-DN': 'IN-DH',
},
// Natural Earth carries Metro Manila under its retired code.
PH: { 'PH-00': 'PH-MNL' },
};
// Shapes Natural Earth still splits along retired lines, merged into the codes
// GeoIP reports now; the retired codes become aliases. Northern Ireland's 26
// districts became 11 councils in 2015, each a union of old districts.
// Current code → [current name, retired codes].
export const SUBDIVISION_MERGES = {
GB: {
'GB-ANN': ['Antrim and Newtownabbey', ['GB-ANT', 'GB-NTA']],
'GB-AND': ['Ards and North Down', ['GB-ARD', 'GB-NDN']],
'GB-ABC': [
'Armagh City, Banbridge and Craigavon',
['GB-ARM', 'GB-BNB', 'GB-CGV'],
],
'GB-BFS': ['Belfast', ['GB-BFS']],
'GB-CCG': [
'Causeway Coast and Glens',
['GB-BLY', 'GB-CLR', 'GB-LMV', 'GB-MYL'],
],
'GB-DRS': ['Derry City and Strabane', ['GB-DRY', 'GB-STB']],
'GB-FMO': ['Fermanagh and Omagh', ['GB-FER', 'GB-OMH']],
'GB-LBC': ['Lisburn and Castlereagh', ['GB-LSB', 'GB-CSR']],
'GB-MEA': ['Mid and East Antrim', ['GB-BLA', 'GB-CKF', 'GB-LRN']],
'GB-MUL': ['Mid Ulster', ['GB-CKT', 'GB-DGN', 'GB-MFT']],
'GB-NMD': ['Newry, Mourne and Down', ['GB-NYM', 'GB-DOW']],
},
};
// Natural Earth puts these islands in "Seven seas (open ocean)".
export const OPEN_OCEAN_REGION = {
Africa: 'africa',
Asia: 'asia',
Americas: 'south_america',
};
export const CONTINENT_REGION = {
Africa: 'africa',
Asia: 'asia',
Europe: 'europe',
'North America': 'north_america',
'South America': 'south_america',
Oceania: 'oceania',
};
// Transcontinental countries belong to every region they span.
export const EXTRA_REGIONS = {
RU: ['asia'],
TR: ['europe'],
KZ: ['europe'],
GE: ['europe'],
AM: ['europe'],
AZ: ['europe'],
CY: ['europe'],
EG: ['asia'],
};
// ISO codes GeoIP emits for places Natural Earth draws inside another country.
export const COUNTRY_ALIASES = {
GF: 'FR',
GP: 'FR',
MQ: 'FR',
RE: 'FR',
YT: 'FR',
BQ: 'NL',
SJ: 'NO',
BV: 'NO',
CX: 'AU',
CC: 'AU',
HM: 'AU',
UM: 'US',
TK: 'NZ',
AC: 'SH',
TA: 'SH',
DG: 'IO',
CP: 'FR',
FX: 'FR',
EA: 'ES',
IC: 'ES',
CQ: 'GB',
GI: 'GB',
// Retired or reserved codes still seen in older data.
UK: 'GB',
SU: 'RU',
YU: 'RS',
CS: 'RS',
TP: 'TL',
BU: 'MM',
ZR: 'CD',
DD: 'DE',
VD: 'VN',
YD: 'YE',
RH: 'ZW',
NH: 'VU',
HV: 'BF',
DY: 'BJ',
AN: 'CW',
};
export const COUNTRY_NAME_ALIASES = {
usa: 'US',
'united states of america': 'US',
america: 'US',
uk: 'GB',
'great britain': 'GB',
britain: 'GB',
england: 'GB',
russia: 'RU',
'south korea': 'KR',
'north korea': 'KP',
czechia: 'CZ',
'czech republic': 'CZ',
'ivory coast': 'CI',
turkiye: 'TR',
türkiye: 'TR',
burma: 'MM',
'cape verde': 'CV',
swaziland: 'SZ',
macedonia: 'MK',
'east timor': 'TL',
vatican: 'VA',
};
// Alpha-2 codes Intl names that aren't countries, so none needs a shape.
export const NON_COUNTRY_CODES = new Set([
'EU',
'EZ',
'UN',
'ZZ',
'QO',
'XA',
'XB',
'AQ',
]);

View File

@@ -1,190 +0,0 @@
// Natural Earth admin-0 → the countries topology and the lookup tables.
// Called by build.mjs; not run on its own.
//
// Merges each country's parts into one shape by ISO alpha-2, drops Antarctica,
// assigns view regions and builds the alpha-3/name/alias lookups
// normalizeCountry reads. Returns the topology, the lookups and a report.
import { geoArea, geoBounds } from 'd3-geo';
import { feature } from 'topojson-client';
import {
CONTINENT_REGION,
COUNTRY_ALIASES,
COUNTRY_MIN_WEIGHT,
COUNTRY_NAME_ALIASES,
COUNTRY_QUANTIZATION,
EXTRA_REGIONS,
NON_COUNTRY_CODES,
OPEN_OCEAN_REGION,
} from './config.mjs';
import {
polygonsOf,
round,
roundBounds,
simplifyAndQuantize,
} from './geometry.mjs';
export const isCountryCode = (value) => /^[A-Z]{2}$/.test(value ?? '');
function regionsOf(props, iso2) {
const base = props.CONTINENT.startsWith('Seven seas')
? OPEN_OCEAN_REGION[props.REGION_UN]
: CONTINENT_REGION[props.CONTINENT];
return [...new Set([base, ...(EXTRA_REGIONS[iso2] ?? [])].filter(Boolean))];
}
// Group features by code so a country's overseas parts (Australia's Indian
// Ocean territories) become one shape that one data row colours.
function groupByCode(sourceFeatures, report) {
const groups = new Map();
for (const f of sourceFeatures) {
const p = f.properties;
if (p.CONTINENT === 'Antarctica') {
report.dropped.push(p.NAME);
continue;
}
const iso2 = isCountryCode(p.ISO_A2_EH) ? p.ISO_A2_EH : null;
const key = iso2 ?? `uncoded:${p.NAME}`;
if (!iso2) {
report.uncoded.push(p.NAME);
}
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(f);
}
return groups;
}
function toCountryFeature(key, members, report) {
// The sovereign part carries the name, label point and region.
const primary =
members.find((f) => f.properties.ISO_A2 === f.properties.ISO_A2_EH) ??
members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
const p = primary.properties;
const iso2 = key.startsWith('uncoded:') ? null : key;
if (members.length > 1) {
report.merged.push(
`${iso2}: ${members.map((f) => f.properties.NAME).join(' + ')}`,
);
}
const polygons = members.flatMap((f) => polygonsOf(f.geometry));
const geometry = { type: 'MultiPolygon', coordinates: polygons };
const mainland = polygons
.map((coordinates) => ({ type: 'Polygon', coordinates }))
.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
return {
type: 'Feature',
id: iso2 ?? p.NAME,
properties: {
code: iso2,
iso3: /^[A-Z]{3}$/.test(p.ISO_A3_EH) ? p.ISO_A3_EH : null,
name: p.NAME,
label: [round(p.LABEL_X), round(p.LABEL_Y)],
mainland: roundBounds(geoBounds(mainland)),
bounds: roundBounds(geoBounds(geometry)),
regions: regionsOf(p, iso2 ?? ''),
},
geometry,
};
}
/** Alpha-3 codes and English names → alpha-2, for normalising data. */
function buildLookups(sourceFeatures) {
const alpha3 = {};
const names = { ...COUNTRY_NAME_ALIASES };
for (const { properties: p } of sourceFeatures) {
if (!isCountryCode(p.ISO_A2_EH) || p.CONTINENT === 'Antarctica') {
continue;
}
if (/^[A-Z]{3}$/.test(p.ISO_A3_EH)) {
alpha3[p.ISO_A3_EH] = p.ISO_A2_EH;
}
for (const name of [
p.NAME,
p.NAME_LONG,
p.ADMIN,
p.NAME_EN,
p.BRK_NAME,
p.NAME_SORT,
p.NAME_CIAWF,
]) {
if (name) {
names[name.toLowerCase()] ??= p.ISO_A2_EH;
}
}
}
return { alpha3, names, aliases: COUNTRY_ALIASES };
}
/** ISO alpha-2 codes Intl knows that neither a shape nor an alias covers. */
function findUncoveredCodes(drawn) {
const display = new Intl.DisplayNames(['en'], {
type: 'region',
fallback: 'none',
});
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const uncovered = [];
for (const a of letters) {
for (const b of letters) {
const code = a + b;
const name = display.of(code);
if (
name &&
!NON_COUNTRY_CODES.has(code) &&
!drawn.has(code) &&
!COUNTRY_ALIASES[code]
) {
uncovered.push(`${code} ${name}`);
}
}
}
return uncovered;
}
/** Natural Earth admin-0 GeoJSON → `{ topology, lookups, report }`. */
export function buildCountries(source) {
const report = { dropped: [], uncoded: [], merged: [] };
const features = [...groupByCode(source.features, report)]
.map(([key, members]) => toCountryFeature(key, members, report))
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
'countries',
features,
COUNTRY_MIN_WEIGHT,
COUNTRY_QUANTIZATION,
);
const drawn = new Set(features.map((f) => f.properties.code).filter(Boolean));
// Lets data be checked against the drawn codes without decoding the outlines.
const lookups = { codes: [...drawn].sort(), ...buildLookups(source.features) };
const regionCounts = {};
for (const f of features) {
for (const region of f.properties.regions) {
regionCounts[region] = (regionCounts[region] ?? 0) + 1;
}
}
return {
topology: topo,
lookups,
report: {
features: features.length,
decodedFeatures: feature(topo, topo.objects.countries).features.length,
droppedFlippedIslands: flipped,
droppedCollapsedPolygons: collapsed,
emptied: emptied.join(' '),
regionCounts,
...report,
lookupCounts: {
alpha3: Object.keys(lookups.alpha3).length,
names: Object.keys(lookups.names).length,
aliases: Object.keys(lookups.aliases).length,
},
uncoveredIsoCodes: findUncoveredCodes(drawn),
},
};
}

View File

@@ -1,118 +0,0 @@
// Geometry helpers and the simplification shared by countries and states.
// Used by countries.mjs and subdivisions.mjs; not run on its own.
//
// Simplification protects small shapes so islands keep an outline, and drops
// polygons that collapse to slivers or flip inside out after quantizing.
import { geoArea } from 'd3-geo';
import { topology } from 'topojson-server';
import { presimplify, simplify } from 'topojson-simplify';
import { feature, quantize } from 'topojson-client';
import { PROTECTED_SHAPE_FACTOR } from './config.mjs';
export const round = (n) => Math.round(n * 1e3) / 1e3;
export const roundBounds = (bounds) =>
bounds.map(([x, y]) => [round(x), round(y)]);
export function polygonsOf(geometry) {
if (geometry.type === 'Polygon') {
return [geometry.coordinates];
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates;
}
return [];
}
/** Area of a lon/lat ring in square degrees, the unit of the simplify weights. */
function planarRingArea(ring) {
let twice = 0;
ring.forEach(([x, y], i) => {
const [nx, ny] = ring[(i + 1) % ring.length];
twice += x * ny - nx * y;
});
return Math.abs(twice) / 2;
}
// Weights every point of a small shape's largest outline as Infinity, which
// simplify never removes, so a city-state or city-sized province (Monaco, a
// London borough) still has an outline to colour. Neighbours share those arcs,
// so their common border stays in step.
function protectSmallShapes(topo, objectName, features, minWeight) {
topo.objects[objectName].geometries.forEach((geometry, i) => {
const polygons = polygonsOf(features[i].geometry);
const areas = polygons.map(([outer]) => planarRingArea(outer));
const total = areas.reduce((sum, area) => sum + area, 0);
if (polygons.length === 0 || total > minWeight * PROTECTED_SHAPE_FACTOR) {
return;
}
const largest = areas.indexOf(Math.max(...areas));
const rings =
geometry.type === 'Polygon' ? geometry.arcs : geometry.arcs[largest];
rings[0].forEach((index) => {
topo.arcs[index < 0 ? ~index : index].forEach((point) => {
point[2] = Infinity;
});
});
});
}
const isCollapsed = ([outer]) =>
new Set(outer.map((point) => point.join())).size < 3;
// Simplifying can collapse a tiny island until its ring runs the other way; d3
// then reads it as "the whole globe except this island" and floods the map.
const isInverted = (coordinates) =>
geoArea({ type: 'Polygon', coordinates }) > 2 * Math.PI;
/**
* One feature collection to a simplified, quantized topology under
* `objectName`. Polygons that collapse to a sliver or flip inside out are
* dropped and the topology rebuilt without them.
*/
export function simplifyAndQuantize(
objectName,
features,
minWeight,
quantization,
) {
let topo = presimplify(
topology({ [objectName]: { type: 'FeatureCollection', features } }),
);
protectSmallShapes(topo, objectName, features, minWeight);
topo = simplify(topo, minWeight);
// presimplify stores each point's weight as a third coordinate and simplify
// keeps it, so it is dropped before quantizing (which would carry it through).
topo = { ...topo, arcs: topo.arcs.map((arc) => arc.map(([x, y]) => [x, y])) };
topo = quantize(topo, quantization);
let flipped = 0;
let collapsed = 0;
const cleaned = feature(topo, topo.objects[objectName]).features.map((f) => {
const kept = polygonsOf(f.geometry).filter((coordinates) => {
if (isCollapsed(coordinates)) {
collapsed += 1;
return false;
}
if (isInverted(coordinates)) {
flipped += 1;
return false;
}
return true;
});
return { ...f, geometry: { type: 'MultiPolygon', coordinates: kept } };
});
const emptied = cleaned
.filter((f) => f.geometry.coordinates.length === 0)
.map((f) => f.properties.code ?? f.properties.name);
if (flipped > 0 || collapsed > 0) {
topo = quantize(
topology({
[objectName]: { type: 'FeatureCollection', features: cleaned },
}),
quantization,
);
}
return { topo, flipped, collapsed, emptied };
}

View File

@@ -1,202 +0,0 @@
// Natural Earth admin-1 → one states/provinces topology per country.
// Called by build.mjs; not run on its own.
//
// Skips countries with fewer than MIN_SUBDIVISIONS coded states, merges shapes
// split along retired lines, and records aliases for renamed codes. Returns one
// file per country and a report of what was skipped, merged or dropped.
import { geoArea } from 'd3-geo';
import { topology } from 'topojson-server';
import { merge } from 'topojson-client';
import {
MIN_SUBDIVISIONS,
SUBDIVISION_ALIASES,
SUBDIVISION_MERGES,
SUBDIVISION_MIN_WEIGHT,
SUBDIVISION_QUANTIZATION,
} from './config.mjs';
import { isCountryCode } from './countries.mjs';
import { round, simplifyAndQuantize } from './geometry.mjs';
const SUBDIVISION_CODE = /^[A-Z]{2}-[A-Z0-9]{1,3}$/;
/** Retired code → `[current code, current name]`, from `SUBDIVISION_MERGES`. */
const RECODES = new Map(
Object.values(SUBDIVISION_MERGES).flatMap((merges) =>
Object.entries(merges).flatMap(([code, [name, retired]]) =>
retired.map((old) => [old, [code, name]]),
),
),
);
/** A country's aliases: the hand-written ones, then each merge's retired codes. */
function aliasesOf(country) {
const aliases = { ...SUBDIVISION_ALIASES[country] };
for (const [code, [, retired]] of Object.entries(
SUBDIVISION_MERGES[country] ?? {},
)) {
for (const old of retired) {
if (old !== code) {
aliases[old] = code;
}
}
}
return aliases;
}
// Shapes without an ISO 3166-2 code can't be matched to data, and a code under
// another country's prefix (Puerto Rico's `US-PR`) belongs to that country's
// file. Retired codes are rewritten to the current ones here, so the merge
// below dissolves them.
function groupByCountry(sourceFeatures) {
const byCountry = new Map();
const uncoded = {};
const recoded = new Set();
for (const f of sourceFeatures) {
const country = f.properties.iso_a2;
if (!isCountryCode(country)) {
continue;
}
const code = f.properties.iso_3166_2;
if (!SUBDIVISION_CODE.test(code ?? '') || !code.startsWith(`${country}-`)) {
uncoded[country] = (uncoded[country] ?? 0) + 1;
continue;
}
if (!byCountry.has(country)) {
byCountry.set(country, []);
}
const recode = RECODES.get(code);
if (recode) {
recoded.add(code);
}
byCountry.get(country).push(
recode
? {
...f,
properties: {
...f.properties,
iso_3166_2: recode[0],
name: recode[1],
},
}
: f,
);
}
return { byCountry, uncoded, recoded };
}
// Natural Earth splits some codes across shapes (Dublin's four councils are all
// `IE-D`). Merging them through a topology drops the borders between the parts,
// so one code is one shape with one outline.
function mergeByCode(features) {
const byCode = new Map();
for (const f of features) {
const code = f.properties.iso_3166_2;
if (!byCode.has(code)) {
byCode.set(code, []);
}
byCode.get(code).push(f);
}
const topo = topology({ parts: { type: 'FeatureCollection', features } });
const { geometries } = topo.objects.parts;
let merged = 0;
const shapes = [...byCode].map(([code, members]) => {
// The largest part carries the name and label point.
const primary = members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
let geometry = primary.geometry;
if (members.length > 1) {
merged += members.length - 1;
geometry = merge(
topo,
geometries.filter((_, i) => features[i].properties.iso_3166_2 === code),
);
}
return {
type: 'Feature',
id: code,
properties: {
code,
name: primary.properties.name,
label: [
round(primary.properties.longitude),
round(primary.properties.latitude),
],
},
geometry,
};
});
return { shapes: shapes.sort((a, b) => a.id.localeCompare(b.id)), merged };
}
/**
* Lower-cased English names → code, for data that names a state. A main name
* outranks a neighbour's alternate spelling of it.
*/
function namesOf(members) {
const names = {};
const mainNames = new Set();
for (const { properties: p } of members) {
const main = p.name?.toLowerCase();
if (main && !mainNames.has(main)) {
mainNames.add(main);
names[main] = p.iso_3166_2;
}
for (const name of [p.name_en, p.gn_name, p.woe_name]) {
if (name) {
names[name.toLowerCase()] ??= p.iso_3166_2;
}
}
}
return names;
}
/**
* Natural Earth admin-1 GeoJSON → `{ files, report }`, where `files` maps each
* country with enough coded states to its outlines file, in code order.
*/
export function buildSubdivisions(source) {
const { byCountry, uncoded, recoded } = groupByCountry(source.features);
const files = new Map();
const report = {
skippedUnderMinimum: [],
mergedParts: {},
uncodedShapes: uncoded,
droppedFlippedIslands: {},
droppedCollapsedPolygons: 0,
emptiedShapes: [],
// Merge entries whose retired code Natural Earth no longer has.
unmatchedRetiredCodes: [...RECODES.keys()].filter(
(code) => !recoded.has(code),
),
};
const countries = [...byCountry.keys()].sort((a, b) => a.localeCompare(b));
for (const country of countries) {
const members = byCountry.get(country);
const { shapes, merged } = mergeByCode(members);
if (shapes.length < MIN_SUBDIVISIONS) {
report.skippedUnderMinimum.push(country);
continue;
}
if (merged) {
report.mergedParts[country] = merged;
}
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
'subdivisions',
shapes,
SUBDIVISION_MIN_WEIGHT,
SUBDIVISION_QUANTIZATION,
);
if (flipped) {
report.droppedFlippedIslands[country] = flipped;
}
report.droppedCollapsedPolygons += collapsed;
report.emptiedShapes.push(...emptied);
files.set(country, {
...topo,
aliases: aliasesOf(country),
names: namesOf(members),
});
}
return { files, report };
}

View File

@@ -1,114 +0,0 @@
// Invariants the chart relies on, checked before anything is written. A
// failure names every problem; the committed assets are left untouched.
// Called by build.mjs; not run on its own.
//
// Checks shape counts, that no shape is emptied or drawn twice, and that every
// code, alias and name resolves to a drawn shape.
import { feature } from 'topojson-client';
import { MIN_SUBDIVISIONS } from './config.mjs';
// Natural Earth v5.1.2 yields 239 countries and 198 countries with states. A
// drop below these means a changed or truncated download, not real geography.
const MIN_COUNTRIES = 230;
const MIN_SUBDIVISION_COUNTRIES = 190;
function duplicatesOf(values) {
const seen = new Set();
return values.filter((value) => seen.has(value) || !seen.add(value));
}
function checkCountries({ topology, lookups, report }, problems) {
const shapes = feature(topology, topology.objects.countries).features;
const codes = shapes.map((shape) => shape.properties.code).filter(Boolean);
const drawn = new Set(codes);
if (shapes.length < MIN_COUNTRIES) {
problems.push(
`only ${shapes.length} countries (expected ≥ ${MIN_COUNTRIES})`,
);
}
if (report.emptied) {
problems.push(`countries left with no outline: ${report.emptied}`);
}
for (const code of duplicatesOf(codes)) {
problems.push(`country code ${code} is drawn twice`);
}
if (lookups.codes.join() !== [...drawn].sort().join()) {
problems.push('lookups.codes differs from the drawn country codes');
}
for (const [alias, target] of Object.entries(lookups.aliases)) {
if (!drawn.has(target)) {
problems.push(`country alias ${alias} → ${target}, which has no shape`);
}
}
for (const [alpha3, target] of Object.entries(lookups.alpha3)) {
if (!drawn.has(target)) {
problems.push(`alpha-3 ${alpha3} → ${target}, which has no shape`);
}
}
}
function checkSubdivisionFile(country, file, problems) {
const codes = file.objects.subdivisions.geometries.map(
(geometry) => geometry.properties.code,
);
const known = new Set(codes);
if (codes.length < MIN_SUBDIVISIONS) {
problems.push(
`${country}: ${codes.length} states (expected ≥ ${MIN_SUBDIVISIONS})`,
);
}
for (const code of duplicatesOf(codes)) {
problems.push(`${country}: state code ${code} is drawn twice`);
}
for (const code of codes) {
if (!code?.startsWith(`${country}-`)) {
problems.push(`${country}: state code ${code} is outside the country`);
}
}
for (const [alias, target] of Object.entries(file.aliases)) {
if (!known.has(target)) {
problems.push(`${country}: alias ${alias} → ${target}, which has no shape`);
}
}
for (const [name, code] of Object.entries(file.names)) {
if (!known.has(code)) {
problems.push(`${country}: name "${name}" → ${code}, which has no shape`);
}
}
}
function checkSubdivisions({ files, report }, problems) {
if (files.size < MIN_SUBDIVISION_COUNTRIES) {
problems.push(
`only ${files.size} countries with states (expected ≥ ${MIN_SUBDIVISION_COUNTRIES})`,
);
}
if (report.emptiedShapes.length > 0) {
problems.push(
`states left with no outline: ${report.emptiedShapes.join(' ')}`,
);
}
if (report.unmatchedRetiredCodes.length > 0) {
problems.push(
`merge codes not found in Natural Earth: ${report.unmatchedRetiredCodes.join(' ')}`,
);
}
for (const [country, file] of files) {
checkSubdivisionFile(country, file, problems);
}
}
/** Throws one error listing every broken invariant. */
export function validate(countries, subdivisions) {
const problems = [];
checkCountries(countries, problems);
checkSubdivisions(subdivisions, problems);
if (problems.length > 0) {
throw new Error(
`${problems.length} problem(s) in the generated outlines:\n- ${problems.join('\n- ')}`,
);
}
}

View File

@@ -3936,7 +3936,7 @@ export interface CloudintegrationtypesAWSConnectionArtifactDTO {
/**
* @type string
*/
cliCommand: string;
connectionUrl: string;
}
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
@@ -4146,6 +4146,10 @@ export interface CloudintegrationtypesAzureConnectionArtifactDTO {
* @type string
*/
cliCommand: string;
/**
* @type string
*/
cloudPowerShellCommand: string;
}
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
@@ -5634,13 +5638,6 @@ export interface DashboardtypesBasicVisualizationDTO {
timePreference?: DashboardtypesTimePreferenceDTO;
}
/**
* @minimum 1
* @maximum 64
* @nullable
*/
export type DashboardtypesBubbleRadiusDTO = number | null;
export interface Querybuildertypesv5LogAggregationDTO {
/**
* @type string
@@ -6924,303 +6921,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind {
'signoz/TopListPanel' = 'signoz/TopListPanel',
}
export interface DashboardtypesTopListAppearanceDTO {
/**
* @type string
* @description Hex color of every bar; a matching background threshold overrides it.
*/
barColor: string;
/**
* @type boolean
* @description Numbers each row by its position in the list.
*/
showRank?: boolean;
/**
* @type boolean
* @description Shows each row's share of the listed rows' total.
*/
showShare?: boolean;
}
export interface DashboardtypesTopListPanelSpecDTO {
/**
* @description Rank, share and bar color options.
*/
appearance?: DashboardtypesTopListAppearanceDTO;
/**
* @description Unit and decimal precision of the values.
*/
formatting?: DashboardtypesPanelFormattingDTO;
/**
* @type array,null
* @description Rules that recolor a row when its value compares to a threshold as specified.
*/
thresholds?: DashboardtypesComparisonThresholdDTO[] | null;
/**
* @description The time range the rows are computed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO {
/**
* @enum signoz/TopListPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesTopListPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTOKind {
'signoz/GeoMapPanel' = 'signoz/GeoMapPanel',
}
/**
* Radius of the largest circle, in CSS pixels from 1 to 64. Null means 8 on the points layer, 28 otherwise.
*/
export type DashboardtypesGeoMapBubblesDTOMaxRadius = DashboardtypesBubbleRadiusDTO | null;
/**
* Radius of the smallest circle, in CSS pixels from 1 to 64. Null means 2 on the points layer, 3 otherwise.
*/
export type DashboardtypesGeoMapBubblesDTOMinRadius = DashboardtypesBubbleRadiusDTO | null;
export interface DashboardtypesGeoMapBubblesDTO {
/**
* @description Radius of the largest circle, in CSS pixels from 1 to 64. Null means 8 on the points layer, 28 otherwise.
*/
maxRadius?: DashboardtypesGeoMapBubblesDTOMaxRadius;
/**
* @description Radius of the smallest circle, in CSS pixels from 1 to 64. Null means 2 on the points layer, 3 otherwise.
*/
minRadius?: DashboardtypesGeoMapBubblesDTOMinRadius;
}
export interface DashboardtypesGeoMapColorsDTO {
/**
* @type string
* @description The hex color varied in opacity mode. Empty uses the default fill.
*/
fill?: string;
/**
* @type number,null
* @description The value mapped to the highest color. Null uses the highest value in the data.
*/
max?: number | null;
/**
* @type number,null
* @description The value mapped to the lowest color. Null uses the lowest value in the data. Must not exceed max.
*/
min?: number | null;
/**
* @description How values are colored: palette (default) picks a hue from palette, opacity varies the opacity of fill.
*/
mode?: DashboardtypesHeatmapColorModeDTO;
/**
* @description The named color ramp used in palette mode, such as lava (default), ice or ember.
*/
palette?: DashboardtypesHeatmapPaletteDTO;
/**
* @description How values map onto the color range: log (default), sqrt or linear.
*/
scale?: DashboardtypesHeatmapColorScaleDTO;
/**
* @type integer
* @description The number of distinct color steps, 2 to 128. 0 uses the chart default.
*/
steps?: number;
}
export interface DashboardtypesGeoMapChartAppearanceDTO {
/**
* @description The circle radius range of the bubbles and points layers.
*/
bubbles?: DashboardtypesGeoMapBubblesDTO;
/**
* @description The color scale applied to the colorBy values.
*/
colors?: DashboardtypesGeoMapColorsDTO;
/**
* @type boolean,null
* @description Shows country and state names once each fits inside its shape. Null shows them.
*/
labels?: boolean | null;
}
export enum DashboardtypesGeoMapCountryFormatDTO {
auto = 'auto',
alpha2 = 'alpha2',
alpha3 = 'alpha3',
name = 'name',
}
export enum DashboardtypesGeoMapLocationModeDTO {
auto = 'auto',
country = 'country',
subdivision = 'subdivision',
coordinates = 'coordinates',
}
export interface DashboardtypesGeoMapLocationDTO {
/**
* @type string
* @description Group-by key holding the country, for the country and subdivision modes. Empty detects it by name, such as geo.country.iso_code.
*/
country?: string;
/**
* @description How country values are written: auto (default) detects it, alpha2, alpha3 or name.
*/
countryFormat: DashboardtypesGeoMapCountryFormatDTO;
/**
* @type string
* @description Group-by key holding the latitude, for the coordinates mode. Empty detects it by name.
*/
lat?: string;
/**
* @type string
* @description Group-by key holding the longitude, for the coordinates mode. Empty detects it by name.
*/
lon?: string;
/**
* @description How rows are placed: auto (default) detects it from the group-by keys, country, subdivision or coordinates.
*/
mode: DashboardtypesGeoMapLocationModeDTO;
/**
* @type string
* @description Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code), for the subdivision mode. Empty detects it by name.
*/
region?: string;
}
export enum DashboardtypesGeoMapLayerDTO {
regions = 'regions',
bubbles = 'bubbles',
points = 'points',
}
export enum DashboardtypesGeoMapProjectionDTO {
mercator = 'mercator',
equal_earth = 'equal_earth',
natural_earth = 'natural_earth',
}
export enum DashboardtypesGeoMapViewDTO {
world = 'world',
fit = 'fit',
africa = 'africa',
asia = 'asia',
europe = 'europe',
north_america = 'north_america',
south_america = 'south_america',
oceania = 'oceania',
country = 'country',
}
export interface DashboardtypesGeoMapSettingsDTO {
/**
* @type string
* @description Value column key, as in a Table panel, that sets the color. Empty uses the first value column.
*/
colorBy?: string;
/**
* @description What is drawn: regions (default) fills each shape, bubbles draws a circle per region, points a circle per coordinate.
*/
layer: DashboardtypesGeoMapLayerDTO;
/**
* @description The map projection: mercator (default), equal_earth or natural_earth.
*/
projection: DashboardtypesGeoMapProjectionDTO;
/**
* @type string
* @description Value column key, as in a Table panel, that sets the bubble or point size. Empty uses colorBy.
*/
sizeBy?: string;
/**
* @description The area shown: world (default), fit to the data, a continent, or country with viewCountry.
*/
view: DashboardtypesGeoMapViewDTO;
/**
* @type string
* @description Uppercase ISO 3166-1 alpha-2 code of the country the country view shows.
*/
viewCountry?: string;
}
export interface DashboardtypesGeoMapThresholdDTO {
/**
* @type string
* @description The CSS color of the shape, bubble or point while the threshold is active, for example #F1575F; CSS named colors such as red also work.
*/
color: string;
/**
* @description The comparison that activates the threshold: above (default), below, above_or_equal, below_or_equal, equal or not_equal.
*/
operator?: DashboardtypesComparisonOperatorDTO;
/**
* @type string
* @description The unit id of value, such as ms or percent. Empty means the colorBy column's unit.
*/
unit?: string;
/**
* @type number
* @format double
* @description The value compared against, in unit.
*/
value: number;
}
export interface DashboardtypesGeoMapPanelSpecDTO {
/**
* @description Color scale, bubble size and labels.
*/
chartAppearance?: DashboardtypesGeoMapChartAppearanceDTO;
/**
* @description Per-column units and decimal precision.
*/
formatting?: DashboardtypesTableFormattingDTO;
/**
* @description Legend placement.
*/
legend?: DashboardtypesLegendDTO;
/**
* @description The group-by columns that place each row on the map.
*/
location?: DashboardtypesGeoMapLocationDTO;
/**
* @description Layer, view, projection and the value columns drawn.
*/
map?: DashboardtypesGeoMapSettingsDTO;
/**
* @type array,null
* @description Rules that recolor a shape, bubble or point when its value compares to a threshold as specified.
*/
thresholds?: DashboardtypesGeoMapThresholdDTO[] | null;
/**
* @description The time range the values are computed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTO {
/**
* @enum signoz/GeoMapPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesGeoMapPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -7232,9 +6932,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -8515,8 +8213,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
'signoz/TopListPanel' = 'signoz/TopListPanel',
'signoz/GeoMapPanel' = 'signoz/GeoMapPanel',
}
/**
* The RFC 6902 operations, applied in order against the dashboard's postable shape.

View File

@@ -54,8 +54,6 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
case PANEL_TYPES.TOP_LIST:
case PANEL_TYPES.GEO_MAP:
return 'scalar';
case PANEL_TYPES.TRACE:
return 'trace';
@@ -279,9 +277,7 @@ export function createAggregation(
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.TOP_LIST ||
panelType === PANEL_TYPES.GEO_MAP);
panelType === PANEL_TYPES.VALUE);
if (queryData.dataSource === DataSource.METRICS) {
return [

View File

@@ -2,13 +2,6 @@ import { act, render, screen } from '@testing-library/react';
import LabelColumn from './LabelColumn';
// The global CSS mock maps every class to undefined; keep the names so overflow is assertable.
jest.mock('./LabelColumn.module.scss', () => ({
labelColumn: 'labelColumn',
overflowed: 'overflowed',
shrinkable: 'shrinkable',
}));
const BADGE_WIDTH = 60;
let resizeCallbacks: ResizeObserverCallback[] = [];
@@ -84,20 +77,6 @@ describe('LabelColumn', () => {
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should hide the labels past the visible count', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
// 3 badges (180px) + overflow reserve (40px)
mockLayout(220);
renderWithProviders(<LabelColumn labels={labels} />);
const isHidden = (label: string): boolean =>
screen.getByTestId(`label-tag-${label}`).closest('.overflowed') !== null;
expect(labels.filter(isHidden)).toStrictEqual(['team', 'owner', 'version']);
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should render label with value when value prop provided', () => {
const labels = ['env'];
const value = { env: 'production' };

View File

@@ -10,7 +10,7 @@ interface AuthErrorProps {
function AuthError({ error }: AuthErrorProps): JSX.Element {
return (
<div className="auth-error-container" data-testid="auth-error">
<div className="auth-error-container">
<ErrorContent
error={error}
icon={<CircleAlert size={12} className="auth-error-icon" />}

View File

@@ -512,7 +512,6 @@ export default function CeleryOverviewTable({
allowClear
/>
<ResizeTable
data-testid="mq-overview-table"
className="celery-overview-table"
pagination={paginationConfig}
size="middle"

View File

@@ -28,7 +28,6 @@ function CeleryTaskConfigOptions(): JSX.Element {
Task Name
</Typography.Text>
<Select
data-testid="celery-task-name-select"
placeholder="Task Name"
showSearch
mode="multiple"

View File

@@ -105,10 +105,7 @@ export default function CeleryTaskGraphGrid({
};
return (
<div
className="celery-task-graph-grid-container"
data-testid="celery-graph-grid"
>
<div className="celery-task-graph-grid-container">
<div className="metric-based-graphs">
<CardContainer className="row-card" isDarkMode={isDarkMode}>
<div className="row-panel">

View File

@@ -80,7 +80,6 @@ function CeleryTaskStateGraphConfig({
{tabs.map((tab, index) => (
<Col
key={tab.key}
data-testid={`celery-state-tab-${tab.key}`}
onClick={(): void => handleTabClick(tab.key as CeleryTaskState)}
className={`celery-task-states__tab ${
tab.key === barState ? 'celery-task-states__tab--selected' : ''

View File

@@ -251,7 +251,6 @@ function CustomTimePickerPopoverContent({
<Button
className={cx('data-time-live', isLiveLogsEnabled ? 'active' : '')}
type="text"
data-testid="time-picker-live"
onClick={handleGoLive}
>
Live

View File

@@ -88,7 +88,6 @@ function TimezoneItem({
return (
<button
type="button"
data-testid={`timezone-item-${timezone.value}`}
className={cx('timezone-picker__item', {
selected: isSelected,
'has-divider': timezone.hasDivider,

View File

@@ -1,17 +0,0 @@
.drawer {
overflow: hidden;
border-color: var(--l2-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px var(--l1-shadow);
&:focus,
&:focus-visible {
outline: none;
}
}
.body {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -0,0 +1,7 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,15 +1,12 @@
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
import cx from 'classnames';
import { AnimatePresence } from 'motion/react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import styles from './DetailsPanelDrawer.module.scss';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
testId?: string;
}
function DetailsPanelDrawer({
@@ -17,41 +14,22 @@ function DetailsPanelDrawer({
onClose,
children,
className,
testId,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<Drawer
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
modal={false}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
>
<AnimatePresence>
{isOpen && (
<DrawerContent
forceMount
direction="right"
width="extra-wide"
showOverlay={false}
// Outside presses are left to the page, which knows what counts as inside.
onInteractOutside={(e): void => e.preventDefault()}
className={cx(styles.drawer, className)}
testId={testId}
>
<div className={styles.body}>{children}</div>
</DrawerContent>
)}
</AnimatePresence>
</Drawer>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
);
}
DetailsPanelDrawer.defaultProps = {
className: undefined,
testId: undefined,
};
export default DetailsPanelDrawer;

View File

@@ -1,7 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { message } from 'antd';
import configureStore from 'redux-mock-store';
import store from 'store';
@@ -195,23 +194,6 @@ describe.each([
});
});
it('sends the chosen jsonl format and 30k row limit', async () => {
renderWithStore(dataSource);
await userEvent.click(screen.getByTestId(testId));
await userEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
await userEvent.click(screen.getByRole('radio', { name: '30k' }));
await userEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
});
const callArgs = mockDownloadExportData.mock.calls[0][0];
expect(callArgs.format).toBe(DownloadFormats.JSONL);
expect(callArgs.body.compositeQuery.queries[0].spec.limit).toBe(
DownloadRowCounts.THIRTY_K,
);
});
it('clears groupBy and having in the export payload', async () => {
const mockQuery = createMockStagedQuery(dataSource);
mockQuery.builder.queryData[0].groupBy = [

View File

@@ -52,7 +52,6 @@ function DeleteMemberDialog({
variant="solid"
color="danger"
onClick={onConfirm}
testId="member-delete-confirm-btn"
loading={isDeleting}
prefix={<Trash2 size={12} />}
>

View File

@@ -624,7 +624,6 @@ function EditMemberDrawer({
disabledTooltip={getDeleteTooltip(isRootUser, isSelf)}
size="md"
onClick={(): void => setShowDeleteConfirm(true)}
testId="member-delete-btn"
disabled={isRootUser || isSelf}
variant="link"
color="danger"
@@ -638,7 +637,6 @@ function EditMemberDrawer({
disabledTooltip={ROOT_USER_TOOLTIP}
size="md"
onClick={handleGenerateResetLink}
testId="member-reset-link-btn"
disabled={isRootUser}
loading={isGeneratingLink || isLoadingTokenStatus}
variant="link"
@@ -676,7 +674,6 @@ function EditMemberDrawer({
color="primary"
disabled={!isDirty || isRootUser}
onClick={handleSave}
testId="member-save-btn"
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}

View File

@@ -42,12 +42,7 @@ function ResetLinkDialog({
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span
className="reset-link-dialog__link-text"
data-testid="reset-link-text"
>
{resetLink}
</span>
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
size="md"

View File

@@ -1,5 +1,5 @@
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import { fireEvent, render, screen } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import ExportMenu from '../ExportMenu';
@@ -76,17 +76,6 @@ describe('ExportMenu', () => {
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('exports the picked jsonl format and closes the popover', async () => {
renderMenu();
await userEvent.click(screen.getByTestId(TEST_ID));
await userEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
expect(screen.queryByText('FORMAT')).not.toBeInTheDocument();
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();

View File

@@ -18,7 +18,6 @@ function InputWithLabel({
className,
closeIcon,
disabled,
sanitize,
}: {
label: string;
initialValue?: string | number | null;
@@ -30,17 +29,14 @@ function InputWithLabel({
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
/** Rewrites the typed value before it is shown and reported. */
sanitize?: (value: string) => string;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const value = sanitize ? sanitize(e.target.value) : e.target.value;
setInputValue(value);
onChange?.(value);
setInputValue(e.target.value);
onChange?.(e.target.value);
};
return (
@@ -82,7 +78,6 @@ InputWithLabel.defaultProps = {
initialValue: undefined,
className: undefined,
closeIcon: undefined,
sanitize: undefined,
};
export default InputWithLabel;

View File

@@ -308,11 +308,7 @@ function LogDetailInner({
placement="left"
mouseLeaveDelay={0}
>
<div
className="log-body"
data-testid="log-detail-body"
dangerouslySetInnerHTML={htmlBody}
/>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>

View File

@@ -182,7 +182,6 @@ function ListLogView({
return (
<>
<Container
data-testid={`logs-list-row-${logData.id}`}
$isActiveLog={isHighlighted || activeLog?.id === logData.id}
$isDarkMode={isDarkMode}
$logType={logType}

View File

@@ -23,7 +23,6 @@ function LogLinesActionButtons({
icon={<TextSelect size={14} />}
className="show-context-btn"
onClick={handleShowContext}
data-testid="log-line-show-context"
/>
</Tooltip>
<Tooltip title="Copy Link">
@@ -32,7 +31,6 @@ function LogLinesActionButtons({
icon={<Link size={14} />}
onClick={onLogCopy}
className="copy-log-btn"
data-testid="log-line-copy-link"
/>
</Tooltip>
</div>

View File

@@ -156,7 +156,6 @@ function RawLogView({
return (
<RawLogViewContainer
data-testid={`logs-raw-row-${data.id}`}
onClick={handleClickExpand}
wrap={false}
align="middle"

View File

@@ -182,7 +182,6 @@ function OptionsMenu({
<div
className="item"
key={item.label}
data-testid={`format-option-${item.key}`}
onClick={(): void => handleMenuItemClick(item.key)}
>
<div className={cx('item-label')}>

View File

@@ -1,72 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { READ_ONLY_TOOLTIP } from '../constants';
import InsertVariableMenu from '../InsertVariableMenu';
import type { EditorVariable } from '../types';
const VARIABLES: EditorVariable[] = [
{ name: 'environment', badge: 'QUERY' },
{ name: 'service', badge: 'CUSTOM' },
{ name: 'service_version' },
];
function renderMenu(disabled = false): jest.Mock {
const onSelect = jest.fn();
render(
<InsertVariableMenu
variables={VARIABLES}
onSelect={onSelect}
disabled={disabled}
/>,
);
return onSelect;
}
function rowNames(): string[] {
return screen
.queryAllByTestId(/^markdown-variable-/)
.map((row) => row.getAttribute('data-testid') ?? '');
}
describe('InsertVariableMenu', () => {
it('narrows the rows to names matching the search', async () => {
renderMenu();
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
await screen.findByTestId('markdown-variable-environment');
expect(rowNames()).toHaveLength(3);
await userEvent.type(screen.getByPlaceholderText('Search variables'), 'SERV');
await waitFor(() =>
expect(rowNames()).toStrictEqual([
'markdown-variable-service',
'markdown-variable-service_version',
]),
);
});
it('hands the clicked variable name to onSelect', async () => {
const onSelect = renderMenu();
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
await userEvent.click(await screen.findByTestId('markdown-variable-service'));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith('service');
});
it('stays closed and explains why when read-only', async () => {
const onSelect = renderMenu(true);
const trigger = screen.getByTestId('markdown-insert-variable');
expect(trigger).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(trigger);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(rowNames()).toHaveLength(0);
await userEvent.hover(trigger);
await expect(screen.findByRole('tooltip')).resolves.toHaveTextContent(
READ_ONLY_TOOLTIP,
);
expect(onSelect).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomMultiSelect from '../CustomMultiSelect';
@@ -678,64 +678,6 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['backend']);
});
it('UI-03c: clicking an option checkbox toggles it exactly once', async () => {
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
onChange={mockOnChange}
value={['frontend']}
/>,
);
await user.click(screen.getByRole('combobox'));
const backendRow = (await screen.findByText('Backend')).closest(
'[role="option"]',
) as HTMLElement;
mockOnChange.mockClear();
await user.click(within(backendRow).getByRole('checkbox'));
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['frontend', 'backend']);
});
it('UI-03d: clicking an option label toggles it exactly once', async () => {
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
onChange={mockOnChange}
value={['frontend']}
/>,
);
await user.click(screen.getByRole('combobox'));
const backendLabel = await screen.findByText('Backend');
mockOnChange.mockClear();
await user.click(backendLabel);
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['frontend', 'backend']);
});
it('UI-03e: clicking "Toggle" does not also toggle the wrapping checkbox', async () => {
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
onChange={mockOnChange}
value={['frontend', 'backend']}
/>,
);
await user.click(screen.getByRole('combobox'));
const backendRow = (await screen.findByText('Backend')).closest(
'[role="option"]',
) as HTMLElement;
mockOnChange.mockClear();
await user.click(within(backendRow).getByText('Toggle'));
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['frontend']);
});
it('UI-04: Should display values with loading info at bottom', async () => {
renderWithVirtuoso(
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,

View File

@@ -112,7 +112,6 @@ function ListViewOrderBy({
placeholder="Select a field"
style={{ width: 200 }}
options={selectOptions}
data-testid="list-view-order-by"
filterOption={(input, option): boolean =>
(option?.value ?? '').toLowerCase().includes(input.trim().toLowerCase())
}

View File

@@ -1,17 +0,0 @@
import type { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
interface ItemBadgeProps {
children: ReactNode;
testId?: string;
}
function ItemBadge({ children, testId }: ItemBadgeProps): JSX.Element {
return (
<Badge textTransform="none" variant="solid" color="secondary" testId={testId}>
{children}
</Badge>
);
}
export default ItemBadge;

View File

@@ -1,32 +0,0 @@
.badges {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.overflow {
flex-shrink: 0;
--badge-max-width: none;
}
/* Lays every badge out at its natural width, off-screen, to size the visible row. */
.measure {
position: absolute;
top: 0;
left: 0;
display: flex;
width: max-content;
visibility: hidden;
pointer-events: none;
}
.extraBadges {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
max-width: 320px;
}

View File

@@ -1,75 +0,0 @@
import { type CSSProperties, useRef } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import ItemBadge from './ItemBadge';
import type { OverflowBadgeItem } from './types';
import { useOverflowBadgesLayout } from './useOverflowBadgesLayout';
import styles from './OverflowBadges.module.scss';
interface OverflowBadgesProps {
items: OverflowBadgeItem[];
}
/**
* A row of badges: as many as fit the width inline, the rest revealed on hover
* of a "+N" badge. A lone badge too wide to fit truncates instead.
*/
function OverflowBadges({ items }: OverflowBadgesProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const { visibleCount, firstMaxWidth, isMeasuring } = useOverflowBadgesLayout(
items,
containerRef,
measureRef,
);
const visible = items.slice(0, visibleCount);
const extra = items.slice(visibleCount);
// The app sets `--badge-max-width` globally, which outranks Badge's `maxWidth` prop.
const containerStyle =
firstMaxWidth === undefined
? undefined
: ({ '--badge-max-width': `${firstMaxWidth}px` } as CSSProperties);
return (
<div ref={containerRef} className={styles.badges} style={containerStyle}>
{visible.map(({ key, label }) => (
<ItemBadge key={key} testId="overflow-badges-item">
{label}
</ItemBadge>
))}
{extra.length > 0 && (
<Tooltip
side="bottom"
title={
<div className={styles.extraBadges}>
{extra.map(({ key, label }) => (
<ItemBadge key={key}>{label}</ItemBadge>
))}
</div>
}
>
{/* Stop the click so the "+N" badge doesn't trigger the row's click. */}
<span
role="presentation"
className={styles.overflow}
onClick={(e): void => e.stopPropagation()}
>
<ItemBadge testId="overflow-badges-more">+{extra.length}</ItemBadge>
</span>
</Tooltip>
)}
{isMeasuring && (
<div ref={measureRef} className={styles.measure} aria-hidden>
{items.map(({ key, label }) => (
<ItemBadge key={key}>{label}</ItemBadge>
))}
<ItemBadge>+{items.length}</ItemBadge>
</div>
)}
</div>
);
}
export default OverflowBadges;

View File

@@ -1,74 +0,0 @@
import { render, screen } from 'tests/test-utils';
import OverflowBadges from '../OverflowBadges';
import { computeOverflowBadgesLayout } from '../utils';
const items = ['alpha', 'beta', 'gamma', 'delta'].map((label) => ({
key: label,
label,
}));
describe('computeOverflowBadgesLayout', () => {
it('fits as many badges as the width allows, keeping room for +N', () => {
// 50 + 4 + 50 + 4 + 30 (+N) = 138 fits; a third badge would not.
expect(
computeOverflowBadgesLayout(140, 4, [50, 50, 50, 50], 30),
).toStrictEqual({ visibleCount: 2 });
});
it('shows every badge without +N when all fit', () => {
expect(computeOverflowBadgesLayout(200, 4, [50, 50, 50], 30)).toStrictEqual({
visibleCount: 3,
});
});
it('truncates the first badge when even it does not fit', () => {
expect(computeOverflowBadgesLayout(100, 4, [300, 50], 30)).toStrictEqual({
visibleCount: 1,
firstMaxWidth: 66,
});
});
it('gives a lone too-wide badge the full width', () => {
expect(computeOverflowBadgesLayout(100, 4, [300], 30)).toStrictEqual({
visibleCount: 1,
firstMaxWidth: 100,
});
});
it('returns visibleCount 0 when badgeWidths is empty', () => {
expect(computeOverflowBadgesLayout(100, 4, [], 30)).toStrictEqual({
visibleCount: 0,
});
});
});
describe('OverflowBadges', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('collapses the badges that do not fit into +N', () => {
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(160);
jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 50 } as DOMRect);
render(<OverflowBadges items={items} />);
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(2);
expect(screen.getByTestId('overflow-badges-more')).toHaveTextContent('+2');
});
it('omits +N when every badge fits', () => {
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 50 } as DOMRect);
render(<OverflowBadges items={items} />);
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(4);
expect(screen.queryByTestId('overflow-badges-more')).not.toBeInTheDocument();
});
});

View File

@@ -1,20 +0,0 @@
import type { Key, ReactNode } from 'react';
export interface OverflowBadgeItem {
key: Key;
label: ReactNode;
}
export interface OverflowBadgesLayout {
visibleCount: number;
/** Set when even the first badge does not fit and must truncate. */
firstMaxWidth?: number;
}
export interface OverflowBadgesMeasurement {
/** The items these widths were measured for. */
itemsKey: string;
badgeWidths: number[];
overflowWidth: number;
gap: number;
}

View File

@@ -1,71 +0,0 @@
import { type RefObject, useLayoutEffect, useMemo, useState } from 'react';
import type {
OverflowBadgeItem,
OverflowBadgesLayout,
OverflowBadgesMeasurement,
} from './types';
import { computeOverflowBadgesLayout } from './utils';
/**
* Fits the badges to the container's width, re-fitting on resize. Badge widths
* are measured once per set of items, from a measuring row holding every badge
* at its natural width followed by "+N", rendered only while `isMeasuring`.
*/
export function useOverflowBadgesLayout(
items: OverflowBadgeItem[],
containerRef: RefObject<HTMLElement>,
measureRef: RefObject<HTMLElement>,
): OverflowBadgesLayout & { isMeasuring: boolean } {
const itemsKey = items.map((item) => String(item.key)).join('\u0000');
const [measurement, setMeasurement] =
useState<OverflowBadgesMeasurement | null>(null);
const [available, setAvailable] = useState(0);
const isMeasuring = items.length > 0 && measurement?.itemsKey !== itemsKey;
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!isMeasuring || !container || !measure) {
return;
}
// Rounded up so sub-pixel widths never add up past the container.
const badgeWidths = Array.from(measure.children, (el) =>
Math.ceil(el.getBoundingClientRect().width),
);
const overflowWidth = badgeWidths.pop() ?? 0;
setMeasurement({
itemsKey,
badgeWidths,
overflowWidth,
gap: parseFloat(getComputedStyle(container).columnGap) || 0,
});
}, [isMeasuring, itemsKey, containerRef, measureRef]);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const update = (): void => setAvailable(container.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return (): void => observer.disconnect();
}, [containerRef]);
const layout = useMemo(
(): OverflowBadgesLayout =>
isMeasuring || !measurement || available === 0
? { visibleCount: items.length }
: computeOverflowBadgesLayout(
available,
measurement.gap,
measurement.badgeWidths,
measurement.overflowWidth,
),
[isMeasuring, measurement, available, items.length],
);
return { ...layout, isMeasuring };
}

View File

@@ -1,33 +0,0 @@
import type { OverflowBadgesLayout } from './types';
/**
* How many badges fit `available` px, keeping room for the "+N" badge while any
* are left out. Always shows at least one, capped to truncate when too wide.
*/
export const computeOverflowBadgesLayout = (
available: number,
gap: number,
badgeWidths: number[],
overflowWidth: number,
): OverflowBadgesLayout => {
if (badgeWidths.length === 0) {
return { visibleCount: 0 };
}
let used = 0;
let visibleCount = 0;
for (let i = 0; i < badgeWidths.length; i++) {
const next = used + (i > 0 ? gap : 0) + badgeWidths[i];
const reserve = i < badgeWidths.length - 1 ? gap + overflowWidth : 0;
if (next + reserve > available) {
break;
}
used = next;
visibleCount++;
}
if (visibleCount > 0) {
return { visibleCount };
}
const reserve = badgeWidths.length > 1 ? gap + overflowWidth : 0;
return { visibleCount: 1, firstMaxWidth: Math.max(available - reserve, 0) };
};

View File

@@ -12,13 +12,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { ChevronUp } from '@signozhq/icons';
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeFormulaDTOType,
Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { isLoneBuilderQuery } from 'lib/query/builderEnvelope';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -32,7 +26,6 @@ import {
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import { parseLimit, sanitizeLimit } from '../../utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
@@ -153,34 +146,10 @@ function QueryAddOns({
[resolvedFields],
);
// A panel's default limit applies only to a lone query: per-query limits can keep
// different groups and break a formula's join.
const isLoneQuery = useMemo(
() =>
isLoneBuilderQuery([
...(currentQuery.builder?.queryData ?? [query]).map((data) => ({
type:
data.builderQueryType ??
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
})),
...(currentQuery.builder?.queryFormulas ?? []).map(() => ({
type: Querybuildertypesv5QueryEnvelopeFormulaDTOType.builder_formula,
})),
...(currentQuery.builder?.queryTraceOperator ?? []).map(() => ({
type:
Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType.builder_trace_operator,
})),
]),
[currentQuery.builder, query],
);
const placeholderOf = useCallback(
(key: InputAddOnField): string =>
(key === QueryBuilderField.Limit && !isLoneQuery
? undefined
: resolvedFields.get(key)?.placeholder) ??
ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields, isLoneQuery],
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields],
);
const isDisabled = useCallback(
@@ -309,7 +278,7 @@ function QueryAddOns({
const handleChangeLimit = useCallback(
(value: string) => {
handleChangeQueryData('limit', parseLimit(value));
handleChangeQueryData('limit', Number(value) || null);
},
[handleChangeQueryData],
);
@@ -405,7 +374,6 @@ function QueryAddOns({
label="Limit"
type="number"
onChange={handleChangeLimit}
sanitize={sanitizeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}

View File

@@ -67,9 +67,7 @@ function QueryAggregationOptions({
onChange={onChange}
queryData={queryData}
maxAggregations={
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.TOP_LIST
panelType === PANEL_TYPES.VALUE || panelType === PANEL_TYPES.PIE
? 1
: undefined
}

View File

@@ -1,12 +1,9 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { jest } from '@jest/globals';
import { fireEvent, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import {
Having,
@@ -25,8 +22,6 @@ import {
import '@testing-library/jest-dom';
import { QueryBuilderV2 } from '../../QueryBuilderV2';
import { QueryBuilderV2Provider } from '../../QueryBuilderV2Context';
import { QueryV2 } from '../QueryV2';
// Local mocks for domain-specific heavy child components
jest.mock(
@@ -58,15 +53,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
let baseQBContext: QueryBuilderContextType;
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
@@ -249,70 +235,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
).not.toBeInTheDocument();
});
describe('query actions menu', () => {
const openQueryActions = async (): Promise<void> => {
await userEvent.click(screen.getAllByTestId('query-actions')[0]);
};
it('Clone calls cloneQuery with the query', async () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await userEvent.click(
await screen.findByTestId('query-actions-item-clone-query'),
);
expect(baseQBContext.cloneQuery).toHaveBeenCalledTimes(1);
expect(baseQBContext.cloneQuery).toHaveBeenCalledWith(
'query',
expect.objectContaining({ queryName: 'A' }),
);
});
it('hides Delete when there is a single query', async () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await screen.findByTestId('query-actions-item-clone-query');
expect(
screen.queryByTestId('query-actions-item-delete-query'),
).not.toBeInTheDocument();
});
it('shows Delete with more than one query and deletes on click', async () => {
const [queryA] = (baseQBContext.currentQuery as Query).builder.queryData;
const handleDeleteQuery = jest.fn();
mockedUseQueryOperations.mockReturnValue({
...mockedUseQueryOperations({} as Parameters<UseQueryOperations>[0]),
handleDeleteQuery,
});
render(
<QueryBuilderV2Provider>
<QueryV2
index={0}
query={queryA}
version="v4"
isAvailableToDisable={false}
isMultiQueryAllowed
queriesCount={2}
onSignalSourceChange={jest.fn()}
signalSourceChangeEnabled={false}
savePreviousQuery={false}
/>
</QueryBuilderV2Provider>,
);
await openQueryActions();
await userEvent.click(
await screen.findByTestId('query-actions-item-delete-query'),
);
expect(handleDeleteQuery).toHaveBeenCalledTimes(1);
}, 15000);
});
it('fx button is disabled when functions already exist', () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const supersetQueryBase = baseQBContext.supersetQuery as Query;

View File

@@ -6,8 +6,6 @@ const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
PANEL_TYPES.SCATTER,
PANEL_TYPES.PIE,
PANEL_TYPES.VALUE,
PANEL_TYPES.TOP_LIST,
PANEL_TYPES.GEO_MAP,
]);
export function isScalarPanelType(panelType?: string | null): boolean {

View File

@@ -15,9 +15,6 @@ import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
const mockHandleChangeQueryData = jest.fn();
const mockHandleSetQueryData = jest.fn();
let mockCurrentQuery: { unit: string | undefined; builder?: unknown } = {
unit: undefined,
};
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): {
@@ -30,10 +27,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: typeof mockCurrentQuery;
currentQuery: { unit: string | undefined };
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: mockCurrentQuery,
currentQuery: { unit: undefined },
}),
}));
@@ -172,15 +169,8 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '10');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
await user.clear(input);
await user.type(input, '-5');
expect(input.value).toBe('5');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it.each([
@@ -232,77 +222,6 @@ describe('QueryAddOns', () => {
);
});
describe('a pinned limit with a default', () => {
const renderPinnedLimit = (): void => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TOP_LIST}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'pinned',
placeholder: 'Default 10',
},
}}
isForTraceOperator={false}
/>,
);
};
afterEach(() => {
mockCurrentQuery = { unit: undefined };
});
it('is open with its default as the placeholder', () => {
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10',
);
});
it('drops the default for an AI query, which the request leaves unlimited', () => {
mockCurrentQuery = {
unit: undefined,
builder: {
queryData: [{ ...baseQuery(), builderQueryType: 'builder_ai_query' }],
queryFormulas: [],
queryTraceOperator: [],
},
};
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
it('drops the default once a formula joins the query', () => {
mockCurrentQuery = {
unit: undefined,
builder: {
queryData: [baseQuery()],
queryFormulas: [{ expression: 'A * 2' }],
queryTraceOperator: [],
},
};
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -12,62 +12,10 @@ import {
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
formatValueForExpression,
parseLimit,
removeKeysFromExpression,
sanitizeLimit,
} from '../utils';
describe('convertHavingToExpression', () => {
it.each([
['COUNT()', 'count() > 5'],
['COUNT_DISTINCT(service.name)', 'count_distinct(service.name) > 5'],
['P99(duration_nano)', 'p99(duration_nano) > 5'],
['SUM(http.Request.Size)', 'sum(http.Request.Size) > 5'],
['count()', 'count() > 5'],
])('references %s by its lower-case function', (columnName, expected) => {
expect(
convertHavingToExpression([{ columnName, op: '>', value: 5 }]),
).toStrictEqual({ expression: expected });
});
it('joins several conditions and formats list values', () => {
expect(
convertHavingToExpression([
{ columnName: 'COUNT()', op: '>', value: 5 },
{ columnName: 'COUNT()', op: 'NOT IN', value: [1, 2] },
]),
).toStrictEqual({ expression: 'count() > 5 AND count() NOT IN [1, 2]' });
});
});
describe('sanitizeLimit', () => {
it.each([
['10', '10'],
['-10', '10'],
['2.5', '25'],
['1e3', '13'],
['', ''],
])('sanitizes %j to %j', (value, expected) => {
expect(sanitizeLimit(value)).toBe(expected);
});
});
describe('parseLimit', () => {
it.each([
['10', 10],
['1', 1],
['', null],
['0', null],
['-5', null],
['2.5', null],
['abc', null],
])('parses %j as %j', (value, expected) => {
expect(parseLimit(value)).toBe(expected);
});
});
describe('convertFiltersToExpression', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -840,16 +840,6 @@ export const appendAndClause = (
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
};
/** Strips everything but digits, so a limit input can't hold a sign or decimal. */
export const sanitizeLimit = (value: string): string =>
value.replace(/\D/g, '');
/** A query or formula limit: a positive integer, otherwise none. */
export const parseLimit = (value: string): number | null => {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : null;
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value
@@ -881,14 +871,7 @@ export const convertHavingToExpression = (
formattedValue = String(value);
}
// The old having filter upper-cased the function (`COUNT()`); v5 only
// resolves the aggregation's own lower-case expression.
const reference = columnName.replace(
/^\s*([A-Za-z_]\w*)\(/,
(_, fn: string) => `${fn.toLowerCase()}(`,
);
return `${reference} ${op} ${formattedValue}`;
return `${columnName} ${op} ${formattedValue}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions

View File

@@ -183,11 +183,7 @@ export default function CheckboxFilterV2(
wasSearchShown.current = isSearchShown;
return (
<div
className={styles.checkboxFilter}
data-testid="checkbox-filter-v2"
data-filter-key={filter.attributeKey.key}
>
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}

View File

@@ -22,9 +22,7 @@ function KeyCreatedPhase({
<div className="add-key-modal__field">
<span className="add-key-modal__label">Key</span>
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text" data-testid="created-key-value">
{createdKey.key}
</span>
<span className="add-key-modal__key-text">{createdKey.key}</span>
<Button
size="md"
variant="link"

View File

@@ -61,7 +61,6 @@ export function RevokeKeyFooter({
color="danger"
loading={isRevoking}
onClick={onConfirm}
testId="revoke-key-confirm-btn"
prefix={<Trash2 size={12} />}
>
Revoke Key

View File

@@ -384,7 +384,6 @@ function ServiceAccountDrawer({
void setIsDeleteOpen(true);
}}
prefix={<Trash2 size={12} />}
testId="sa-delete-btn"
>
Delete Service Account
</AuthZButton>
@@ -410,7 +409,6 @@ function ServiceAccountDrawer({
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
testId="sa-save-btn"
>
Save Changes
</AuthZButton>
@@ -439,7 +437,6 @@ function ServiceAccountDrawer({
type="single"
value={activeTab}
size="sm"
testId="sa-tab"
onChange={(val: string): void => {
if (val) {
void setActiveTab(val as ServiceAccountDrawerTab);
@@ -481,7 +478,6 @@ function ServiceAccountDrawer({
void setIsAddKeyOpen(true);
}}
prefix={<Plus size={12} />}
testId="sa-add-key-btn"
>
Add Key
</AuthZButton>

View File

@@ -4,7 +4,6 @@ import type {
TouchEvent as ReactTouchEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import {
ArrowDown,
ArrowUp,
@@ -32,7 +31,7 @@ type TanStackHeaderRowProps<TData = unknown> = {
onRemoveColumn?: (columnId: string) => void;
orderBy?: SortState | null;
onSort?: (sort: SortState | null) => void;
/** The fill column and any after it cannot be resized */
/** Last column cannot be resized */
isLastColumn?: boolean;
};
@@ -90,19 +89,6 @@ function TanStackHeaderRow<TData>({
): void => {
event.preventDefault();
event.stopPropagation();
// TanStack starts the drag from the configured size, which an auto or
// redistributed column does not render at.
const renderedWidth = Math.round(
event.currentTarget.closest('th')?.getBoundingClientRect().width ?? 0,
);
if (header && renderedWidth > 0 && renderedWidth !== header.getSize()) {
flushSync(() => {
header.getContext().table.setColumnSizing((sizing) => ({
...sizing,
[header.column.id]: renderedWidth,
}));
});
}
resizeHandler?.(event);
};
const {

View File

@@ -53,11 +53,7 @@ import { useFlatItems } from './useFlatItems';
import { useResetScroll } from './useResetScroll';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import {
buildPageSizeItems,
buildTanstackColumnDef,
getFillColumnIndex,
} from './utils';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
@@ -432,9 +428,6 @@ function TanStackTableInner<TData, TItemKey = string>(
);
const tableHeader = useCallback(() => {
const fillColumnIndex = getFillColumnIndex(
flatHeaders.map((header) => columnsById.get(header.id)),
);
return (
<DndContext
sensors={sensors}
@@ -460,7 +453,7 @@ function TanStackTableInner<TData, TItemKey = string>(
canRemoveColumn={canRemoveColumn}
orderBy={orderBy}
onSort={setOrderBy}
isLastColumn={index >= fillColumnIndex}
isLastColumn={index === flatHeaders.length - 1}
/>
);
})}

View File

@@ -1,7 +1,7 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef } from './types';
import { getColumnWidthStyle, getFillColumnIndex } from './utils';
import { getColumnWidthStyle } from './utils';
export function VirtuosoTableColGroup<TData>({
columns,
@@ -13,9 +13,6 @@ export function VirtuosoTableColGroup<TData>({
const visibleTanstackColumns = table.getVisibleFlatColumns();
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
const columnSizing = table.getState().columnSizing;
const fillColumnIndex = getFillColumnIndex(
visibleTanstackColumns.map((c) => columnDefsById.get(c.id)),
);
return (
<colgroup>
@@ -25,7 +22,7 @@ export function VirtuosoTableColGroup<TData>({
return <col key={tanstackCol.id} />;
}
const persistedWidth = columnSizing[tanstackCol.id];
const isLastColumn = index === fillColumnIndex;
const isLastColumn = index === visibleTanstackColumns.length - 1;
return (
<col
key={tanstackCol.id}

View File

@@ -1,51 +0,0 @@
import { screen } from '@testing-library/react';
import type { TableColumnDef } from '../types';
import { defaultColumns, renderTanStackTable, type TestRow } from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
const actionsColumn: TableColumnDef<TestRow> = {
id: 'actions',
header: 'Actions',
cell: (): string => 'act',
width: { fixed: '96px', passLastColumnFill: true },
};
const colWidths = (container: HTMLElement): string[] =>
Array.from(container.querySelectorAll('colgroup col')).map(
(col) => (col as HTMLElement).style.width,
);
const isResizable = (headerText: string): boolean =>
Boolean(
screen
.getByText(headerText)
.closest('th')
?.querySelector('[title="Drag to resize column"]'),
);
describe('TanStackTable column fill', () => {
it('fills and locks the last column by default', () => {
const { container } = renderTanStackTable();
expect(colWidths(container).at(-1)).toBe('100%');
expect(isResizable('Name')).toBe(true);
expect(isResizable('Value')).toBe(false);
});
it('hands the fill to the previous column when the last passes it on', () => {
const { container } = renderTanStackTable({
props: { columns: [...defaultColumns, actionsColumn] },
});
const widths = colWidths(container);
expect(widths.at(-2)).toBe('100%');
expect(widths.at(-1)).toBe('96px');
expect(isResizable('Name')).toBe(true);
expect(isResizable('Value')).toBe(false);
expect(isResizable('Actions')).toBe(false);
});
});

View File

@@ -263,33 +263,6 @@ describe('useColumnStore', () => {
expect(result.current).toBe(firstSnapshot);
});
it.each([
['only hiddenColumnIds', { hiddenColumnIds: ['persisted'] }],
['only columnOrder', { columnOrder: ['a', 'b'] }],
['an empty object', {}],
['a wrong-typed hiddenColumnIds', { hiddenColumnIds: 'nope' }],
])(
'normalises a persisted value carrying %s so selectors never yield undefined',
(_label, stored) => {
localStorage.setItem(
'@signoz/table-columns/test-table',
JSON.stringify(stored),
);
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
const hidden = renderHook(() => useHiddenColumnIds(TEST_KEY));
const order = renderHook(() => useColumnOrder(TEST_KEY));
const sizing = renderHook(() => useColumnSizing(TEST_KEY));
expect(Array.isArray(hidden.result.current)).toBe(true);
expect(Array.isArray(order.result.current)).toBe(true);
expect(typeof sizing.result.current).toBe('object');
},
);
it('useColumnSizing returns sizing', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);

View File

@@ -1,28 +0,0 @@
import type { TableColumnDef } from '../types';
import { getFillColumnIndex } from '../utils';
const column = (
id: string,
width?: TableColumnDef<unknown>['width'],
): TableColumnDef<unknown> => ({
id,
header: id,
cell: (): null => null,
width,
});
describe('getFillColumnIndex', () => {
it('fills the last column by default', () => {
expect(getFillColumnIndex([column('a'), column('b')])).toBe(1);
});
it('fills the previous column when the last passes it on', () => {
expect(
getFillColumnIndex([
column('a'),
column('b'),
column('actions', { fixed: '96px', passLastColumnFill: true }),
]),
).toBe(1);
});
});

View File

@@ -74,8 +74,6 @@ export type TableColumnDef<
default?: number | string;
max?: number | string;
ignoreLastColumnFill?: boolean;
/** When last, hand the fill to the previous column instead of taking it. */
passLastColumnFill?: boolean;
};
};

View File

@@ -66,19 +66,7 @@ const loadTableFromStorage = (tableKey: string): ColumnState | null => {
return cached.parsed;
}
// Selectors read these fields directly, so a value persisted under an older
// schema must be filled in rather than handed to callers as `undefined`.
const stored = JSON.parse(raw) as Partial<ColumnState> | null;
const parsed: ColumnState = {
hiddenColumnIds: Array.isArray(stored?.hiddenColumnIds)
? stored.hiddenColumnIds
: [],
columnOrder: Array.isArray(stored?.columnOrder) ? stored.columnOrder : [],
columnSizing:
stored?.columnSizing && typeof stored.columnSizing === 'object'
? stored.columnSizing
: {},
};
const parsed = JSON.parse(raw) as ColumnState;
persistedTableCache.set(tableKey, { raw, parsed });
return parsed;
} catch {

View File

@@ -1,38 +0,0 @@
import { useCallback } from 'react';
export type UseRowNavigationParams<TData> = {
rows: TData[];
activeIndex: number;
onNavigate: (row: TData, index: number) => void;
};
export type UseRowNavigationResult = {
goToPrev: () => void;
goToNext: () => void;
isPrevDisabled: boolean;
isNextDisabled: boolean;
};
/** Steps the active row through the loaded rows; stops at either end. */
export function useRowNavigation<TData>({
rows,
activeIndex,
onNavigate,
}: UseRowNavigationParams<TData>): UseRowNavigationResult {
const isPrevDisabled = activeIndex <= 0;
const isNextDisabled = activeIndex < 0 || activeIndex >= rows.length - 1;
const goToPrev = useCallback((): void => {
if (!isPrevDisabled) {
onNavigate(rows[activeIndex - 1], activeIndex - 1);
}
}, [isPrevDisabled, onNavigate, rows, activeIndex]);
const goToNext = useCallback((): void => {
if (!isNextDisabled) {
onNavigate(rows[activeIndex + 1], activeIndex + 1);
}
}, [isNextDisabled, onNavigate, rows, activeIndex]);
return { goToPrev, goToNext, isPrevDisabled, isNextDisabled };
}

View File

@@ -63,19 +63,6 @@ export const getColumnWidthStyle = <TData>(
};
};
/**
* Index of the column that fills spare width: the last one, or the one before
* it when the last sets `width.passLastColumnFill`.
*/
export const getFillColumnIndex = <TData>(
columns: (TableColumnDef<TData> | undefined)[],
): number => {
const lastIndex = columns.length - 1;
return columns[lastIndex]?.width?.passLastColumnFill
? lastIndex - 1
: lastIndex;
};
const isSkeletonRow = (row: unknown): boolean => {
const r = row as Record<string, unknown>;
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');

View File

@@ -1,27 +0,0 @@
import { timeItems } from 'constants/timePreference';
import { render, screen, userEvent } from 'tests/test-utils';
import TimePreference from '../index';
describe('TimePreferenceDropDown', () => {
it('calls setSelectedTime with the clicked item', async () => {
const setSelectedTime = jest.fn();
render(
<TimePreference
selectedTime={timeItems[0]}
setSelectedTime={setSelectedTime}
/>,
);
await userEvent.click(screen.getByRole('button', { name: /Global Time/ }));
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Last 15 min' }),
);
expect(setSelectedTime).toHaveBeenCalledTimes(1);
expect(setSelectedTime).toHaveBeenCalledWith({
name: 'Last 15 min',
enum: 'LAST_15_MIN',
});
});
});

View File

@@ -46,5 +46,4 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
}

View File

@@ -34,8 +34,6 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
[PANEL_TYPES.TOP_LIST]: null,
[PANEL_TYPES.GEO_MAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,

View File

@@ -340,8 +340,6 @@ export enum PANEL_TYPES {
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TOP_LIST = 'top_list',
GEO_MAP = 'geo_map',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -26,7 +26,6 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -48,10 +47,6 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -338,11 +333,6 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
);
return;
}
// Read before toUrlCompositeQuery, which drops the requestType envelope.
const panelType = getPanelTypeForRequestType(
requestTypeFromActionQuery(action.query),
action.signal,
);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
@@ -352,9 +342,7 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
deps.redirectWithQueryBuilderData(normalized);
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -365,10 +353,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
});
navigate(url);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */
@@ -627,7 +613,6 @@ export default function ActionsSection({
disabled={isSuccess}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>

View File

@@ -1,172 +0,0 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
jest.mock('api/ai-assistant/chat', () => ({
undoExecution: jest.fn(),
revertExecution: jest.fn(),
restoreExecution: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUndo = undoExecution as jest.MockedFunction<typeof undoExecution>;
const UNDO_ACTION = {
kind: MessageActionKindDTO.undo,
label: 'Undo change',
actionMetadataId: 'exec-1',
} as MessageActionDTO;
function renderUndoChip(): HTMLElement {
render(<ActionsSection actions={[UNDO_ACTION]} messageId="m1" />);
return screen.getByRole('button', { name: 'Undo change' });
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
},
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
}
describe('ActionsSection rollback chip', () => {
beforeEach(() => {
mockUndo.mockReset();
});
it('ignores clicks while the rollback is in flight', async () => {
const user = userEvent.setup();
mockUndo.mockReturnValue(new Promise(() => {}));
const chip = renderUndoChip();
await user.click(chip);
await waitFor(() => expect(chip).toHaveAttribute('aria-busy', 'true'));
await user.click(chip);
expect(mockUndo).toHaveBeenCalledTimes(1);
expect(mockUndo).toHaveBeenCalledWith('exec-1');
});
it('disables the chip after success and says why', async () => {
const user = userEvent.setup();
mockUndo.mockResolvedValue({} as Awaited<ReturnType<typeof undoExecution>>);
const chip = renderUndoChip();
await user.click(chip);
await waitFor(() => expect(chip).toHaveAttribute('aria-disabled', 'true'));
await user.click(chip);
expect(mockUndo).toHaveBeenCalledTimes(1);
// A click closes a Base UI tooltip until the pointer leaves the trigger.
await user.unhover(chip);
await user.hover(chip);
await expect(
screen.findByText('This action already ran'),
).resolves.toBeInTheDocument();
});
it('shows the error text when the rollback fails', async () => {
const user = userEvent.setup();
mockUndo.mockRejectedValue(new Error('Execution already reverted'));
const chip = renderUndoChip();
await user.click(chip);
await waitFor(() => expect(chip).not.toHaveAttribute('aria-busy', 'true'));
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
await user.unhover(chip);
await user.hover(chip);
await expect(
screen.findByText('Execution already reverted'),
).resolves.toBeInTheDocument();
});
});
describe('ActionsSection apply_filter', () => {
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open in Logs Explorer')]}
messageId="message-1"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
});
it('switches the open explorer to the Table view when applied on it', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[countByServiceAction('Apply on Logs Explorer')]}
messageId="message-2"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
});
});

View File

@@ -1,106 +0,0 @@
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from '../getPanelTypeForRequestType';
describe('getPanelTypeForRequestType', () => {
it.each([
[
Querybuildertypesv5RequestTypeDTO.scalar,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TABLE,
],
[
Querybuildertypesv5RequestTypeDTO.time_series,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TIME_SERIES,
],
[
Querybuildertypesv5RequestTypeDTO.raw,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.traces,
PANEL_TYPES.TRACE,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.metrics,
PANEL_TYPES.LIST,
],
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
});
it.each([
undefined,
null,
'',
Querybuildertypesv5RequestTypeDTO.raw_stream,
Querybuildertypesv5RequestTypeDTO.heatmap,
'distribution',
'nonsense',
'toString',
'constructor',
42,
{},
])(
'falls back to the List view for unmapped or invalid requestType (%p)',
(value) => {
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
PANEL_TYPES.LIST,
);
},
);
});
describe('requestTypeFromActionQuery', () => {
it('reads the requestType envelope field and returns undefined when absent', () => {
expect(
requestTypeFromActionQuery({
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: { queries: [] },
}),
).toBe('scalar');
expect(requestTypeFromActionQuery({})).toBeUndefined();
expect(requestTypeFromActionQuery(null)).toBeUndefined();
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
});
it('maps a grouped count() payload to the Table view', () => {
const query = {
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
};
expect(
getPanelTypeForRequestType(
requestTypeFromActionQuery(query),
ApplyFilterSignalDTO.logs,
),
).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -1,30 +0,0 @@
import {
ApplyFilterSignalDTO,
type MessageActionDTOQuery,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
export function getPanelTypeForRequestType(
requestType: unknown,
signal: ApplyFilterSignalDTO,
): PANEL_TYPES {
switch (requestType) {
case Querybuildertypesv5RequestTypeDTO.scalar:
return PANEL_TYPES.TABLE;
case Querybuildertypesv5RequestTypeDTO.time_series:
return PANEL_TYPES.TIME_SERIES;
case Querybuildertypesv5RequestTypeDTO.trace:
return signal === ApplyFilterSignalDTO.traces
? PANEL_TYPES.TRACE
: PANEL_TYPES.LIST;
default:
return PANEL_TYPES.LIST;
}
}
export function requestTypeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): unknown {
return query?.requestType;
}

View File

@@ -340,7 +340,6 @@ function AllErrors(): JSX.Element {
render: (value, record): JSX.Element => (
<Tooltip overlay={(): JSX.Element => value}>
<AppLink
data-testid="exception-row-link"
to={`${ROUTES.ERROR_DETAIL}?groupId=${
record.groupID
}&timestamp=${getNanoSeconds(record.lastSeen)}`}
@@ -487,7 +486,6 @@ function AllErrors(): JSX.Element {
return (
<ResizeTable
data-testid="exceptions-table"
columns={columns}
tableLayout="fixed"
dataSource={data?.payload as Exception[]}

View File

@@ -161,7 +161,7 @@ function DomainDetails({
<Divider type="vertical" />
{domainData?.domainName && (
<Typography.Text className="title" testId="domain-details-title">
<Typography.Text className="title">
{domainData.domainName}
</Typography.Text>
)}
@@ -214,7 +214,6 @@ function DomainDetails({
background: 'var(--l1-background)',
}}
className="domain-detail-drawer"
data-testid="domain-details-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
@@ -234,21 +233,9 @@ function DomainDetails({
value={selectedView}
size="md"
items={[
{
value: VIEW_TYPES.ALL_ENDPOINTS,
label: 'All Endpoints',
testId: `domain-view-${VIEW_TYPES.ALL_ENDPOINTS}`,
},
{
value: VIEW_TYPES.ENDPOINT_STATS,
label: 'Endpoint(s) Stats',
testId: `domain-view-${VIEW_TYPES.ENDPOINT_STATS}`,
},
{
value: VIEW_TYPES.TOP_ERRORS,
label: 'Top 10 Errors',
testId: `domain-view-${VIEW_TYPES.TOP_ERRORS}`,
},
{ value: VIEW_TYPES.ALL_ENDPOINTS, label: 'All Endpoints' },
{ value: VIEW_TYPES.ENDPOINT_STATS, label: 'Endpoint(s) Stats' },
{ value: VIEW_TYPES.TOP_ERRORS, label: 'Top 10 Errors' },
]}
/>
</div>

View File

@@ -176,7 +176,6 @@ function TopErrors({
textPlacement="right"
value={showStatusCodeErrors}
onChange={setShowStatusCodeErrors}
testId="top-errors-status-message-toggle"
/>
<span style={{ color: 'white', fontSize: '14px' }}>
Status Message Exists
@@ -187,7 +186,7 @@ function TopErrors({
</div>
</div>
<div className="endpoints-table-container" data-testid="top-errors-table">
<div className="endpoints-table-container">
<div className="endpoints-table-header">
{showStatusCodeErrors ? 'Errors with Status Message' : 'All Errors'}{' '}
<Tooltip

View File

@@ -64,10 +64,7 @@ function DependentServices({
return (
<div className="top-services-content">
<div
className="dependent-services-container"
data-testid="dependent-services-table"
>
<div className="dependent-services-container">
<Table
loading={isLoading || isRefetching}
dataSource={dependentServicesData || []}

View File

@@ -86,7 +86,7 @@ function DomainMetrics({
}
return (
<div className="domain-detail-drawer__endpoint" data-testid="domain-metrics">
<div className="domain-detail-drawer__endpoint">
<div className="domain-details-grid">
<div className="labels-row">
<Typography.Text color="muted" className="domain-details-metadata-label">
@@ -103,7 +103,7 @@ function DomainMetrics({
</Typography.Text>
</div>
<div className="values-row" data-testid="domain-metrics-values">
<div className="values-row">
<Typography.Text className="domain-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />

View File

@@ -35,10 +35,7 @@ function StatusCodeTable({
}
return (
<div
className="status-code-table-container"
data-testid="endpoint-status-code-table"
>
<div className="status-code-table-container">
<Table
loading={isLoading || isRefetching}
dataSource={statusCodeData || []}

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