mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 16:30:42 +00:00
Compare commits
49 Commits
ns/trace-v
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbab3ca8d7 | ||
|
|
ab28e69397 | ||
|
|
d98fca08dd | ||
|
|
648c8d0740 | ||
|
|
e6c19dd15a | ||
|
|
ccfe8114f1 | ||
|
|
b87e2b6375 | ||
|
|
b45c4828ac | ||
|
|
40d3f9c81d | ||
|
|
c14d256e6f | ||
|
|
9a541c851f | ||
|
|
e0802b8274 | ||
|
|
add47f6516 | ||
|
|
2d3c4fd7d2 | ||
|
|
8f86b59822 | ||
|
|
e53d16157b | ||
|
|
83ebe26877 | ||
|
|
33d2ff917f | ||
|
|
13440740c4 | ||
|
|
711f0b6beb | ||
|
|
98f9850c53 | ||
|
|
07fd9ac5b8 | ||
|
|
e1fdd1282a | ||
|
|
b5cac39eb7 | ||
|
|
7d6ea4df77 | ||
|
|
b8353eccf8 | ||
|
|
0cc223fb5a | ||
|
|
436a13430a | ||
|
|
825991864d | ||
|
|
d19b9afff4 | ||
|
|
4cb022dd06 | ||
|
|
9d465d54f5 | ||
|
|
7d95fa7b25 | ||
|
|
cd1068e1c3 | ||
|
|
dca68d89bc | ||
|
|
1be806c58d | ||
|
|
8569702161 | ||
|
|
1b8451650a | ||
|
|
a5bfebbc29 | ||
|
|
afbe1ac7a6 | ||
|
|
2103d6c7b7 | ||
|
|
efb2100e69 | ||
|
|
ce44f1a9cf | ||
|
|
b76a566f14 | ||
|
|
ed50e4aed1 | ||
|
|
a2d281881c | ||
|
|
74d35ebafa | ||
|
|
12108b410a | ||
|
|
9d91040a55 |
@@ -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). **Read that doc first.** Adhere to it.
|
||||
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.
|
||||
|
||||
# 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`.
|
||||
- **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.
|
||||
- **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`.
|
||||
```ts
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
@@ -22,42 +22,24 @@ 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.
|
||||
- **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';
|
||||
```
|
||||
- **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.
|
||||
- **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 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.
|
||||
- **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.
|
||||
- **Serial mode** when tests in a file mutate the same list page:
|
||||
```ts
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
```
|
||||
- **Locator priority** (matches Playwright best practice):
|
||||
1. `data-testid` (preferred — these are stable, app-author-provided handles)
|
||||
2. `getByRole('button', { name: 'Submit' })`
|
||||
3. `getByLabel('Email')`, `getByPlaceholder(...)`, `getByText(...)`
|
||||
4. CSS / `locator('.ant-…')` — last resort
|
||||
- **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)).
|
||||
- **Never commit `test.only`.** CI runs with `forbidOnly: true`.
|
||||
- **No `page.waitForTimeout(ms)`** — always prefer `await expect(locator).toBe…()`.
|
||||
|
||||
@@ -78,8 +60,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 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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -140,7 +122,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. 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. 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.
|
||||
|
||||
# Known UI gotchas (apply when relevant)
|
||||
|
||||
|
||||
@@ -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) before you start — it documents 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) and [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md) before you start — they document 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 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.
|
||||
- **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.
|
||||
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
|
||||
5. **Fix.** Edit the spec 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.
|
||||
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.
|
||||
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,17 +41,20 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
|
||||
# Repo-specific signals
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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)`).
|
||||
- **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.** 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.
|
||||
- **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`).
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
- **Type-check.** After edits, run `pnpm typecheck` and `pnpm lint` from `tests/e2e/`.
|
||||
|
||||
# Hard rules
|
||||
|
||||
|
||||
@@ -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) 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 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 will:
|
||||
|
||||
@@ -16,28 +16,62 @@ 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 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.*`.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
2. **Work out which layer each scenario belongs to — before planning it.**
|
||||
|
||||
3. **Navigate and explore**
|
||||
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**
|
||||
- Invoke `planner_setup_page` once before any other browser tool.
|
||||
- Use `browser_snapshot` to read the page's accessibility tree. **Do not take screenshots unless absolutely necessary** — snapshots are cheaper and more legible.
|
||||
- Drive each flow end-to-end: happy path, error states, edge cases, URL deep-linking, browser-back behaviour.
|
||||
|
||||
4. **Design comprehensive scenarios**
|
||||
5. **Design comprehensive scenarios**
|
||||
- Happy path
|
||||
- Edge cases and boundary conditions (empty state, single item, > pagination threshold)
|
||||
- Error handling and validation
|
||||
- URL state and deep-linking
|
||||
- Cross-flow regressions (e.g. searching while paginated)
|
||||
|
||||
5. **Structure the test plan**
|
||||
6. **Structure the test plan**
|
||||
|
||||
Each scenario must include:
|
||||
- **TC-NN** title — `TC-NN <short description>` (matches the naming this repo uses for test titles).
|
||||
@@ -46,7 +80,7 @@ You will:
|
||||
- Expected outcomes per step.
|
||||
- Cleanup notes (what gets created and how to remove it — usually via API).
|
||||
|
||||
6. **Save the plan and the checklist**
|
||||
7. **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.
|
||||
@@ -97,8 +131,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. **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.
|
||||
- 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).
|
||||
- **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.
|
||||
|
||||
@@ -4,9 +4,9 @@ Applies to everything in the repo — code, config, workflows.
|
||||
|
||||
- **No unnecessary comments.** Do not comment where the code is self-explanatory; never restate what the code already says.
|
||||
- **Document only** non-obvious behavior, constraints, formats, and edge cases.
|
||||
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
|
||||
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
|
||||
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
|
||||
- **Rationale goes in prose, not source.** Why a job exists, how a subsystem fits together: that belongs in the README or the PR. Exception: a config entry that would look like a mistake without its reason (a pinned version, a trust or lint exclusion) keeps a short reason next to it.
|
||||
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there. When the code a comment describes changes, update the comment; when that code is deleted, delete the comment with it.
|
||||
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges. A banner a skill requires (the AI-owned banner on story mocks and `tests/e2e/drivers/`) tells maintainers how the file is made; keep it.
|
||||
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
|
||||
|
||||
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).
|
||||
|
||||
40
.claude/rules/frontend-state.md
Normal file
40
.claude/rules/frontend-state.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
paths:
|
||||
- "frontend/src/**/*.ts"
|
||||
- "frontend/src/**/*.tsx"
|
||||
---
|
||||
|
||||
# Frontend state
|
||||
|
||||
Pick the store by what the state is. Never add Redux or React Context for shared state; to move existing Redux or Context state, use the `migrate-state-management` skill ([`frontend/.claude/skills/migrate-state-management/SKILL.md`](../../frontend/.claude/skills/migrate-state-management/SKILL.md)).
|
||||
|
||||
| State | Use | Never |
|
||||
|---|---|---|
|
||||
| Server / API data | React Query, generated hooks from `src/api/generated` | a copy in Redux, Context or Zustand |
|
||||
| Shareable: filters, time range, selection, page | nuqs (`useQueryState` with a parser) | Redux, Context, hand-parsed `useSearchParams` |
|
||||
| Global client state | a small Zustand store per domain | Redux, Context |
|
||||
| One component's UI state | `useState` / `useReducer` | any store |
|
||||
|
||||
## Server state
|
||||
|
||||
- Expose a query through a hook that returns its result plus memoized derived values. Never dispatch a response into another store.
|
||||
- **The query key holds every value the request reads.** A field change that alters the request (name, context, data type) must change the key; a lossy signature serves stale rows.
|
||||
|
||||
## URL state
|
||||
|
||||
- Keep the URL small (Chrome trims near 2k characters). No large datasets, no sensitive data.
|
||||
- **A rewrite touches only the params it owns.** Repairing or normalising one param keeps the rest (`activeLogId`, pagination) as they were.
|
||||
- **Links carry the current time range.** A row link, "open in new tab" and "copy link" land on the same period as an in-app click.
|
||||
- **A preset read from the URL applies once.** A later user change or reset must not re-apply it.
|
||||
|
||||
## Zustand
|
||||
|
||||
- One `create()` per module; split a large store into slice factories combined in that one `create()`.
|
||||
- Always read through a selector: `useDashboardStore((s) => s.isDashboardLocked)`, never `useDashboardStore()`.
|
||||
- Update only through `set`; never mutate state. State fields before actions in the store object.
|
||||
- Hooks never call `useStore.getState()` (`signoz/no-zustand-getstate-in-hooks`); export standalone actions instead.
|
||||
|
||||
## Local and form state
|
||||
|
||||
- **Editable state starts from saved data once.** Initialise the form from the saved value, then let the user own it; never re-derive it from saved data on each render.
|
||||
- **A mode switch owns its in-flight work.** While a request from one mode runs, disable the switch or drop the result when the mode changes, so it can't close or overwrite the new mode.
|
||||
20
.claude/rules/frontend-tests.md
Normal file
20
.claude/rules/frontend-tests.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
paths:
|
||||
- "frontend/src/**/*.test.ts"
|
||||
- "frontend/src/**/*.test.tsx"
|
||||
---
|
||||
|
||||
# Frontend tests
|
||||
|
||||
Examples and the harness API live in [`frontend/src/tests/README.md`](../../frontend/src/tests/README.md).
|
||||
|
||||
- **Import from the harness.** `render`, `screen`, `userEvent`, `waitFor` and the rest come from `tests/test-utils`, never from `@testing-library/react`. The harness mounts the router, React Query, Redux, app context and preferences. When it can't do what a test needs, extend `tests/test-utils` instead of importing around it.
|
||||
- **Find elements by test ID.** Query with `getByTestId`/`findByTestId` on the `data-testid` or `testId` the component exposes, and add one to the component when it has none. Fall back to role or text only for markup the app doesn't own. A test ID matches exactly one element and survives copy and markup changes; `getByRole` computes the accessibility tree on every call and is the slowest query.
|
||||
- **Interact like a user.** `const user = userEvent.setup({ pointerEventsCheck: 0 })`, then an awaited `user.click`, `hover`, `keyboard` or `type`. `fireEvent` only for events `userEvent` has no equivalent for: scroll, resize, setting `scrollTop`.
|
||||
- **Fake timers per test only**, for debounce or throttle, with `userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) })`. Never global.
|
||||
- **Network through MSW.** The global server runs for every test; override per test with `server.use(...)` from `mocks-server/server`. Large fixtures go in `mocks-server/__mockdata__`.
|
||||
- **Router through the harness.** Pass `initialRoute`/`routePath` to `render` and assert with `getCurrentLocation()` from `lib/router/navigation`. Never mock `react-router`, `react-router-dom` or `lib/history`; when a test can't render at a route, use the `lib/router` spies in `tests/router-mocks`.
|
||||
- **Mock only business boundaries.** Local `jest.mock` for API modules, custom hooks and domain components. Never re-mock what the harness provides or what `jest.config.ts` maps (`uplot`, `@signozhq/icons`, `useSafeNavigate`, `logEvent`).
|
||||
- **Type mocks and fixtures.** `jest.mocked(fn)` for module mocks, `jest.fn() as jest.MockedFunction<T>` for a standalone mock you configure or assert on. Fixtures carry their real type, from `src/api/generated` where it exists. No `any`, no `as unknown as`. An inline `onX={jest.fn()}` on a typed prop that is never asserted needs no annotation.
|
||||
- **A test fails without its change.** Revert the change, or break the line under test, and run the test before you finish: it must fail. Assert the specific outcome (exact text, request payload, call arguments), never only that an element exists.
|
||||
- **No limit on tests per file.** Split a file when it mixes unrelated behaviour, not to hit a count.
|
||||
15
.claude/rules/log-body.md
Normal file
15
.claude/rules/log-body.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
paths:
|
||||
- "frontend/src/**/*.ts"
|
||||
- "frontend/src/**/*.tsx"
|
||||
---
|
||||
|
||||
# Log body
|
||||
|
||||
`ILog.body` is `string | ILogBody`. On `use_json_body` tenants the API sends an object, and the collector wraps a plain-string body into `{ message }`. Every read goes through `frontend/src/lib/logs/logBody.ts`.
|
||||
|
||||
- **Read with a getter, never directly.** `getBodyDisplay` for a list cell or a log line, `getBodyMessage` for the drawer header. Both accept `undefined` and collapse a `{message}`-only object to its message.
|
||||
- **Never cast or stringify a body inline.** No `as string`, no `String(log.body)`, no `JSON.stringify(log.body)`, no `typeof body === 'string' ? … : …` outside the module. The v5 converter collapses `{ message }` once with `extractOnlyMessageBody`; live logs and the dashboard list panel skip the converter, so their rows carry the raw object and only a getter renders them right.
|
||||
- **Resolve a selected field with `getLogFieldValue`** from `lib/logs/flatLogData.ts`, so a `body.<key>` column works on both string and object bodies. An object value renders as JSON and is not offered as an add-to-query filter.
|
||||
- **The drawer tree and field lookup are the only places that use the raw value.** Everything else renders a string from a getter.
|
||||
- **Review check.** A raw body value used outside a getter call, `extractOnlyMessageBody` in the converter, `flatLogData.ts` and `LogDetailedView/utils.tsx` is a finding.
|
||||
@@ -9,3 +9,6 @@
|
||||
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
|
||||
- **Use convensional commit format** for commits and PR title.
|
||||
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.
|
||||
- **Review fixes are new commits too.** A force push hides what changed from human reviewers and makes Greptile post its findings again. Bring in `main` with a merge; only a stacked branch (`gh stack`) rebases, since the stack moves as a unit.
|
||||
- **Answer every Greptile thread.** Reply with the commit that fixes it (`Fixed in 4df6f3198d: <what changed>`) or the reason it stays, then resolve. Never resolve one with neither: Greptile withdraws a finding, and remembers it, only when told why.
|
||||
- **Leave human review threads to the author.** Fix the code they ask for, but never reply to or resolve a human reviewer's thread.
|
||||
|
||||
@@ -10,6 +10,9 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
|
||||
- **A UI change lands with its story.** Any change that alters what a page renders — a new page, a new state, a new branch, a changed layout — updates the story of every page that renders it, or creates one where the page has none. Go through the `signoz-page-story` skill; never hand-write a page story outside it.
|
||||
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
|
||||
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
|
||||
- **Plays live with the mocks.** A `play` is a named function exported from `stories/__story_mockdata__/<page>.play.ts`, which is collapsed for review like the rest of the AI-owned mocks. The story file only names it: `play: openEditDrawer`. A story file that still holds play bodies moves them there the first time you edit it.
|
||||
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, row count, hidden header). A value the page supports must never fail the play.
|
||||
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
|
||||
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
|
||||
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.
|
||||
- **Prove it with a visual diff when asked.** Before/after screenshots and "did this touch other screens" go through `sbshot-visual-diff`, which can only show what a story renders.
|
||||
|
||||
@@ -115,8 +115,8 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- **A variant earns a story only when it is worth linking to**: an empty workspace, a viewer, a page mid-load. Everything else stays a control.
|
||||
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
|
||||
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
|
||||
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
|
||||
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`, play functions in `stories/__story_mockdata__/<page>.play.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
|
||||
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file under a `__story_mockdata__/` open with this banner, above the imports:
|
||||
|
||||
```ts
|
||||
@@ -143,6 +143,8 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it reads, each reached by `revoked`, none of them a role preset, and each one checked by the `disabled` state of what the permission gates
|
||||
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
|
||||
- [ ] every control flipped once, its effect seen on screen
|
||||
- [ ] every play passes with each control value its story exposes, and the story file holds no play body
|
||||
- [ ] every request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`, `pnpm exec oxfmt --check <files>` all clean. The repo has no prettier: `pnpm exec prettier` prints a pass while exiting 254
|
||||
|
||||
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -2,3 +2,9 @@
|
||||
*.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
|
||||
|
||||
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -24,7 +24,7 @@ go.mod @therealpandey
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/types/ @therealpandey @srikanthccv
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
@@ -136,7 +136,7 @@ go.mod @therealpandey
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @therealpandey
|
||||
/tests/integration/ @therealpandey @srikanthccv
|
||||
|
||||
# e2e tests
|
||||
/tests/e2e/ @AshwinBhatkal
|
||||
@@ -302,3 +302,8 @@ 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
|
||||
17
.github/workflows/e2eci.yaml
vendored
17
.github/workflows/e2eci.yaml
vendored
@@ -2,6 +2,9 @@ name: e2eci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -13,14 +16,18 @@ 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 }}
|
||||
FORCE: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
|
||||
run: |
|
||||
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select(startswith("tests/integration/") | not)')
|
||||
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select((startswith("tests/integration/") or startswith("frontend/src/api/generated/")) | not)')
|
||||
PATTERN='^(frontend/|tests/|\.github/workflows/e2eci\.yaml$|cmd/enterprise/Dockerfile\.with-web\.integration$)'
|
||||
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
@@ -40,7 +47,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -60,7 +67,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -98,7 +105,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -155,7 +162,7 @@ jobs:
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
4
.github/workflows/integrationci.yaml
vendored
4
.github/workflows/integrationci.yaml
vendored
@@ -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 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
|
||||
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
|
||||
sudo apt-get update -qqy
|
||||
sudo apt-get -qqy install google-chrome-stable
|
||||
CHROME_VERSION=$(google-chrome-stable --version)
|
||||
|
||||
@@ -16,6 +16,14 @@ 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
|
||||
|
||||
@@ -178,6 +178,9 @@ 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:
|
||||
|
||||
@@ -552,26 +552,47 @@ components:
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
AlertmanagertypesChannelJiraAuth:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraCloudAuth'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraSelfHostedAuth'
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraCloudAuth:
|
||||
properties:
|
||||
apiToken:
|
||||
description: The Atlassian API token paired with email. A secret; returned
|
||||
by create, update and fetch by ID, never by list.
|
||||
format: password
|
||||
type: string
|
||||
email:
|
||||
description: The Atlassian account email used for basic auth.
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
auth:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraAuth'
|
||||
description: 'The credentials: email and apiToken when deployment is cloud,
|
||||
pat when deployment is selfhosted.'
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
description: Extra issue fields keyed by Jira field ID. Stored, but not
|
||||
currently included in the issue Jira receives.
|
||||
nullable: true
|
||||
type: object
|
||||
deployment:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesJiraDeployment'
|
||||
description: 'The Jira edition: cloud for an Atlassian-hosted site, selfhosted
|
||||
for Jira Server or Data Center.'
|
||||
description:
|
||||
description: Alertmanager template for the issue description, rendered as
|
||||
markdown; defaults to the SigNoz Jira default.
|
||||
type: string
|
||||
email:
|
||||
description: The Atlassian account email used for basic auth.
|
||||
type: string
|
||||
issueType:
|
||||
description: The issue type name, such as Bug.
|
||||
type: string
|
||||
@@ -604,8 +625,8 @@ components:
|
||||
nullable: true
|
||||
type: boolean
|
||||
site:
|
||||
description: The base URL of the Jira Cloud site, for example https://example.atlassian.net.
|
||||
Only Jira Cloud is supported.
|
||||
description: The base URL of the Jira site, for example https://example.atlassian.net
|
||||
or https://jira.example.com.
|
||||
type: string
|
||||
summary:
|
||||
description: Alertmanager template for the issue summary; defaults to the
|
||||
@@ -615,11 +636,21 @@ components:
|
||||
description: The resolution name that stops an issue from being reopened.
|
||||
type: string
|
||||
required:
|
||||
- deployment
|
||||
- site
|
||||
- project
|
||||
- issueType
|
||||
- email
|
||||
- apiToken
|
||||
- auth
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraSelfHostedAuth:
|
||||
properties:
|
||||
pat:
|
||||
description: The Jira personal access token, sent as a Bearer header. A
|
||||
secret; returned by create, update and fetch by ID, never by list.
|
||||
format: password
|
||||
type: string
|
||||
required:
|
||||
- pat
|
||||
type: object
|
||||
AlertmanagertypesChannelKind:
|
||||
enum:
|
||||
@@ -1155,6 +1186,11 @@ components:
|
||||
description: Comma-separated tags attached to the alert.
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesJiraDeployment:
|
||||
enum:
|
||||
- cloud
|
||||
- selfhosted
|
||||
type: string
|
||||
AlertmanagertypesJiraReceiverConfig:
|
||||
properties:
|
||||
custom_fields:
|
||||
@@ -1162,6 +1198,10 @@ components:
|
||||
description: Extra issue fields keyed by Jira field ID. Stored, but not
|
||||
currently included in the issue Jira receives.
|
||||
type: object
|
||||
deployment:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesJiraDeployment'
|
||||
description: The Jira edition, cloud or selfhosted; defaults to cloud.
|
||||
description:
|
||||
description: Alertmanager template for the issue description.
|
||||
type: string
|
||||
@@ -1169,7 +1209,8 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
description: HTTP client settings; basic auth carries the account email
|
||||
and API token.
|
||||
and API token on cloud, authorization carries the Bearer personal access
|
||||
token on self-hosted.
|
||||
issue_type:
|
||||
description: The issue type name.
|
||||
type: string
|
||||
@@ -1199,7 +1240,7 @@ components:
|
||||
send_resolved:
|
||||
type: boolean
|
||||
site:
|
||||
description: The Jira Cloud base URL.
|
||||
description: The Jira base URL.
|
||||
type: string
|
||||
summary:
|
||||
description: Alertmanager template for the issue summary.
|
||||
@@ -2205,10 +2246,10 @@ components:
|
||||
type: object
|
||||
CloudintegrationtypesAWSConnectionArtifact:
|
||||
properties:
|
||||
connectionUrl:
|
||||
cliCommand:
|
||||
type: string
|
||||
required:
|
||||
- connectionUrl
|
||||
- cliCommand
|
||||
type: object
|
||||
CloudintegrationtypesAWSIntegrationConfig:
|
||||
properties:
|
||||
@@ -2364,11 +2405,8 @@ components:
|
||||
properties:
|
||||
cliCommand:
|
||||
type: string
|
||||
cloudPowerShellCommand:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
- cloudPowerShellCommand
|
||||
type: object
|
||||
CloudintegrationtypesAzureIntegrationConfig:
|
||||
properties:
|
||||
@@ -4030,7 +4068,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -4067,10 +4107,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -4082,8 +4123,11 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -9565,9 +9609,10 @@ components:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
buckets:
|
||||
description: The ascending upper bounds of the value bands, set only for
|
||||
heatmap results; each point then carries one count per bound plus a trailing
|
||||
overflow count in values.
|
||||
description: The ascending upper bounds of the value buckets, set for heatmap
|
||||
and distribution results; each point then carries one count per bound
|
||||
plus a trailing overflow count in values. The first bucket starts at -Inf
|
||||
when its bound is at or under zero, else at zero.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -9722,6 +9767,101 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Distribution:
|
||||
properties:
|
||||
counts:
|
||||
description: One count per bound in meta.buckets plus a trailing overflow
|
||||
count, summed over every step of the requested time range.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
labels:
|
||||
description: The group-by field values that identify this distribution.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
type: array
|
||||
required:
|
||||
- counts
|
||||
type: object
|
||||
Querybuildertypesv5DistributionAggregation:
|
||||
properties:
|
||||
alias:
|
||||
description: The alias of the aggregation, or the internal name __result_N,
|
||||
with N its index, when no alias was given.
|
||||
type: string
|
||||
distributions:
|
||||
description: One distribution per group-by combination.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
|
||||
nullable: true
|
||||
type: array
|
||||
index:
|
||||
description: The zero-based position of the aggregation in the query.
|
||||
type: integer
|
||||
meta:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
description: Display metadata for the aggregation, including the bucket
|
||||
upper bounds the counts are positional against.
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5DistributionPercentiles'
|
||||
description: Percentiles over the counts of every distribution added together,
|
||||
estimated the way histogram_quantile does by interpolating linearly within
|
||||
the bucket each rank falls in. Omitted when nothing was counted or meta.buckets
|
||||
is empty.
|
||||
required:
|
||||
- index
|
||||
- alias
|
||||
- distributions
|
||||
type: object
|
||||
Querybuildertypesv5DistributionData:
|
||||
properties:
|
||||
aggregations:
|
||||
description: One entry per aggregation of the query. The order is not guaranteed;
|
||||
match entries to aggregations by index.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
queryName:
|
||||
description: The name of the query that produced this result.
|
||||
type: string
|
||||
required:
|
||||
- queryName
|
||||
- aggregations
|
||||
type: object
|
||||
Querybuildertypesv5DistributionPercentiles:
|
||||
properties:
|
||||
p50:
|
||||
description: The 50th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p75:
|
||||
description: The 75th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p90:
|
||||
description: The 90th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p95:
|
||||
description: The 95th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p99:
|
||||
description: The 99th percentile.
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- p50
|
||||
- p75
|
||||
- p90
|
||||
- p95
|
||||
- p99
|
||||
type: object
|
||||
Querybuildertypesv5ExecStats:
|
||||
description: 'Execution cost of the request: rows and bytes scanned and execution
|
||||
time. A measure of cost, not of whether data exists.'
|
||||
@@ -10105,6 +10245,10 @@ components:
|
||||
description: The unique name of the formula, such as F1, used by selectedQueryName
|
||||
and by panels. Referencing a formula from another formula is not reliable.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of ordered series or rows to skip before the page
|
||||
starts. Requires limit.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the formula result.
|
||||
items:
|
||||
@@ -10130,7 +10274,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10166,10 +10312,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10181,8 +10328,11 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10244,7 +10394,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10281,10 +10433,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10296,8 +10449,11 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10359,7 +10515,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10396,10 +10554,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10411,8 +10570,11 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10513,8 +10675,9 @@ components:
|
||||
description: The unique name of the trace operator query.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of raw results to skip before returning. Aggregation
|
||||
requests ignore it.
|
||||
description: The number of raw or trace rows, or of ordered series or rows
|
||||
for time_series and scalar requests, to skip before the page starts. Requires
|
||||
limit.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the matched traces; only span_count and trace_duration
|
||||
@@ -10545,10 +10708,12 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
|
||||
properties:
|
||||
results:
|
||||
description: 'One result per enabled query, typed by the request type: TimeSeriesData
|
||||
for time_series, ScalarData for scalar and RawData for raw requests.'
|
||||
for time_series and heatmap, ScalarData for scalar, RawData for raw and
|
||||
DistributionData for distribution requests.'
|
||||
items: {}
|
||||
nullable: true
|
||||
type: array
|
||||
@@ -10738,8 +10903,9 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeResponse:
|
||||
description: 'Response from the v5 query range endpoint. The data.results array
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series,
|
||||
ScalarData for scalar, or RawData for raw requests.'
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series
|
||||
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
|
||||
distribution requests.'
|
||||
properties:
|
||||
data:
|
||||
allOf:
|
||||
@@ -10836,6 +11002,7 @@ components:
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
- distribution
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -10947,8 +11114,8 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
values:
|
||||
description: The per-band counts for heatmap results, in the order of meta.buckets
|
||||
plus a trailing overflow count.
|
||||
description: The per-bucket counts for heatmap results, in the order of
|
||||
meta.buckets plus a trailing overflow count.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -24053,14 +24220,16 @@ paths:
|
||||
jira:
|
||||
description: Creates one Jira Cloud issue per alert group and reopens
|
||||
it if the alert fires again within `reopenDuration`. Authenticates
|
||||
with an Atlassian account email and API token.
|
||||
with an Atlassian account email and API token under `auth`.
|
||||
summary: Jira Cloud issues
|
||||
value:
|
||||
config:
|
||||
kind: jira
|
||||
spec:
|
||||
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
email: oncall@example.com
|
||||
auth:
|
||||
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
email: oncall@example.com
|
||||
deployment: cloud
|
||||
issueType: Bug
|
||||
labels:
|
||||
- signoz
|
||||
@@ -24070,6 +24239,23 @@ paths:
|
||||
site: https://example.atlassian.net
|
||||
displayName: Jira platform
|
||||
name: jira-platform
|
||||
jira-selfhosted:
|
||||
description: Creates issues on a Jira Data Center or Server site.
|
||||
Authenticates with a personal access token under `auth`, sent as
|
||||
a Bearer header.
|
||||
summary: Self-hosted Jira issues
|
||||
value:
|
||||
config:
|
||||
kind: jira
|
||||
spec:
|
||||
auth:
|
||||
pat: XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
deployment: selfhosted
|
||||
issueType: Bug
|
||||
project: OPS
|
||||
site: https://jira.example.com
|
||||
displayName: Jira Data Center
|
||||
name: jira-datacenter
|
||||
jsmops:
|
||||
description: Creates JSM Ops alerts through an integration API key.
|
||||
JSM Ops speaks the Opsgenie alert API, so `priority` takes P1 to
|
||||
@@ -25153,9 +25339,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
summary: List rule saved views
|
||||
tags:
|
||||
- rules
|
||||
@@ -25211,9 +25397,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
summary: Create rule saved view
|
||||
tags:
|
||||
- rules
|
||||
@@ -25265,9 +25451,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
summary: Delete rule saved view
|
||||
tags:
|
||||
- rules
|
||||
@@ -25334,9 +25520,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
summary: Update rule saved view
|
||||
tags:
|
||||
- rules
|
||||
@@ -28290,6 +28476,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28298,9 +28490,15 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
summary: Get rule history filter keys
|
||||
tags:
|
||||
- rules
|
||||
@@ -28468,6 +28666,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28476,9 +28680,15 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
summary: Get rule history filter values
|
||||
tags:
|
||||
- rules
|
||||
@@ -28551,9 +28761,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
summary: Get rule overall status timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28623,9 +28833,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
summary: Get rule history stats
|
||||
tags:
|
||||
- rules
|
||||
@@ -28707,6 +28917,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28715,9 +28931,19 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Get rule history timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28782,6 +29008,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28790,9 +29022,19 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Get top contributors to rule firing
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -28,7 +28,7 @@ func ConvertConfig(src ExternalConfig) (InternalConfig, error)
|
||||
// Over this:
|
||||
type ConfigAdapter struct { ... }
|
||||
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
|
||||
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
func (adapter *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 (o *Order) AllLineItems() []LineItem { ... }
|
||||
func (order *Order) AllLineItems() []LineItem { ... }
|
||||
|
||||
// Better: the validation logic operates on the full structure.
|
||||
func ValidateOrder(o *Order) error { ... }
|
||||
func ValidateOrder(order *Order) error { ... }
|
||||
```
|
||||
|
||||
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.
|
||||
|
||||
@@ -74,7 +74,7 @@ The `*Visitor` passed in provides everything needed to build predicates. Use the
|
||||
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
|
||||
|
||||
```go
|
||||
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
func (resolver sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
switch key {
|
||||
case "created_by":
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)
|
||||
|
||||
@@ -82,7 +82,7 @@ In the module implementation, implement the new method. A typical implementation
|
||||
- Uses the `render` package to write responses or errors
|
||||
|
||||
```go
|
||||
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
func (handler *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 (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
// Call module functions
|
||||
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
|
||||
out, err := handler.module.CreateThing(req.Context(), claims.OrgID, &in)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -59,7 +59,8 @@ Follow these rules:
|
||||
|
||||
### Unexported symbols
|
||||
|
||||
- Struct receivers: one or two characters (`c`, `f`, `br`).
|
||||
- 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).
|
||||
- Helper functions: descriptive lowercase names (`parseToken`, `buildQuery`).
|
||||
|
||||
### Constants
|
||||
|
||||
@@ -70,16 +70,16 @@ The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but
|
||||
```go
|
||||
// pkg/instrumentation/sdk.go
|
||||
|
||||
func (i *SDK) Start(ctx context.Context) error {
|
||||
<-i.startCh
|
||||
func (sdk *SDK) Start(ctx context.Context) error {
|
||||
<-sdk.startCh
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *SDK) Stop(ctx context.Context) error {
|
||||
close(i.startCh)
|
||||
func (sdk *SDK) Stop(ctx context.Context) error {
|
||||
close(sdk.startCh)
|
||||
return errors.Join(
|
||||
i.sdk.Shutdown(ctx),
|
||||
i.meterProviderShutdownFunc(ctx),
|
||||
sdk.sdk.Shutdown(ctx),
|
||||
sdk.meterProviderShutdownFunc(ctx),
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -197,8 +197,8 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Start(ctx context.Context) error { ... }
|
||||
func (s *service) Stop(ctx context.Context) error { ... }
|
||||
func (service *service) Start(ctx context.Context) error { ... }
|
||||
func (service *service) Stop(ctx context.Context) error { ... }
|
||||
```
|
||||
|
||||
### Provider that is also a service
|
||||
|
||||
@@ -17,7 +17,7 @@ Two rules shape how the core type behaves:
|
||||
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
|
||||
|
||||
// Receiver form
|
||||
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
func (maintenance *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 (x *X) Validate() error { ... }
|
||||
func (entity *X) Validate() error { ... }
|
||||
|
||||
// Request-shape-only: checks that do not apply once the value is persisted.
|
||||
func (p *PostableX) Validate() error { ... }
|
||||
func (postable *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`.
|
||||
|
||||
@@ -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 Yarn
|
||||
- Node 18+ and pnpm
|
||||
|
||||
### Initial Setup
|
||||
|
||||
@@ -24,8 +24,8 @@ uv sync
|
||||
2. Install Node deps and Playwright browsers:
|
||||
```bash
|
||||
cd e2e
|
||||
yarn install
|
||||
yarn install:browsers # one-time Playwright browser install
|
||||
pnpm install
|
||||
pnpm install:browsers # one-time Playwright browser install
|
||||
```
|
||||
|
||||
### Starting the Test Environment
|
||||
@@ -84,127 +84,118 @@ 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 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
|
||||
├── 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
|
||||
│ └── alerts/
|
||||
│ └── alerts.spec.ts # (example)
|
||||
│ └── list.spec.ts # (example)
|
||||
└── artifacts/ # per-run output (gitignored)
|
||||
├── html/ # HTML reporter output
|
||||
├── json/ # JSON reporter output
|
||||
└── results/ # per-test traces / screenshots / videos on failure
|
||||
```
|
||||
|
||||
### `fixtures/` vs `helpers/` — what goes where
|
||||
### The driver kit
|
||||
|
||||
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:
|
||||
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`:
|
||||
|
||||
```ts
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
import { expect, test } from '../../drivers/alerts';
|
||||
|
||||
test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
test('TC-01 seeded rules render', async ({ alerts, alertList }) => {
|
||||
await alerts.list.goto({ search: alertList.namePrefix });
|
||||
await expect(alerts.list.rows()).toHaveCount(10);
|
||||
});
|
||||
```
|
||||
|
||||
- `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>/`.
|
||||
- **`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.
|
||||
|
||||
**Fixture scopes:**
|
||||
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
|
||||
### Which layer does this test belong to?
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
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:
|
||||
|
||||
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."
|
||||
- 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.
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
|
||||
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
|
||||
4. **Self-contained state**: each spec 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.
|
||||
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.
|
||||
|
||||
## How to write an E2E test?
|
||||
|
||||
Create a new file `tests/e2e/tests/alerts/smoke.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import { ALERT_LIST_PAGE_SIZE, expect, test } from '../../drivers/alerts';
|
||||
|
||||
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();
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
To run just this test (assuming the stack is up via `test_setup`):
|
||||
|
||||
@@ -213,52 +204,56 @@ cd tests/e2e
|
||||
npx playwright test tests/alerts/smoke.spec.ts --project=chromium
|
||||
```
|
||||
|
||||
Here's a more comprehensive example that exercises a CRUD flow via the UI:
|
||||
Here's a more comprehensive example, driven entirely through the feature's
|
||||
driver:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import { expect, test } from '../../drivers/alerts';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
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('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts?tab=AlertRules');
|
||||
const name = 'smoke-rule';
|
||||
await alerts.list.goto({ search: name });
|
||||
await expect(alerts.list.stateCell(ruleId)).toHaveText('OK');
|
||||
|
||||
// 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 ...
|
||||
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(),
|
||||
]);
|
||||
|
||||
// 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);
|
||||
// Re-read from the server rather than trusting the optimistic update.
|
||||
await page.reload();
|
||||
await expect(alerts.list.stateCell(ruleId)).toHaveText('Disabled');
|
||||
});
|
||||
```
|
||||
|
||||
### Locator priority
|
||||
`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.
|
||||
|
||||
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)
|
||||
### 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.
|
||||
|
||||
## Agents
|
||||
|
||||
Three Claude agents in `.claude/agents/` accelerate writing and maintaining E2E specs:
|
||||
|
||||
- **`playwright-test-planner`** — explores a feature in a real browser plus the local frontend source and writes a test plan as a scratch markdown file (under `tests/e2e/specs/`, which is gitignored — plans are working artifacts for the generator, not committed docs).
|
||||
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the `authedPage` fixture and the API-seed pattern.
|
||||
- **`playwright-test-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-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).
|
||||
@@ -270,15 +265,15 @@ The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` too
|
||||
With the stack already up, from `tests/e2e/`:
|
||||
|
||||
```bash
|
||||
yarn test # headless, all projects
|
||||
pnpm test # headless, all projects
|
||||
```
|
||||
|
||||
### Running Specific Projects
|
||||
|
||||
```bash
|
||||
yarn test:chromium # chromium only
|
||||
yarn test:firefox
|
||||
yarn test:webkit
|
||||
pnpm test:chromium # chromium only
|
||||
pnpm test:firefox
|
||||
pnpm test:webkit
|
||||
```
|
||||
|
||||
### Running Specific Tests
|
||||
@@ -296,17 +291,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 "AL-01"
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
|
||||
```bash
|
||||
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)
|
||||
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)
|
||||
```
|
||||
|
||||
### Staging fallback
|
||||
@@ -316,7 +311,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
|
||||
yarn test:staging
|
||||
pnpm test:staging
|
||||
```
|
||||
|
||||
## How to configure different options for E2E tests?
|
||||
@@ -364,12 +359,14 @@ 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. Drop to `page.request.*` only when the UI can't reach what you need.
|
||||
- **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.*`.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -2,8 +2,6 @@ package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
@@ -18,29 +16,14 @@ 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) {
|
||||
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)
|
||||
connectionArtifact, err := cloudintegrationtypes.NewAWSConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.AWS.DeploymentRegion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
|
||||
AWS: connectionArtifact,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -146,5 +146,5 @@ Do not use Zustand, Redux, or Context for purely local UI state.
|
||||
|
||||
## Additional resources
|
||||
|
||||
- Project rule: [.cursor/rules/state-management.mdc](../../rules/state-management.mdc)
|
||||
- Project rule: [.claude/rules/frontend-state.md](../../../../.claude/rules/frontend-state.md)
|
||||
- Detailed patterns and rationale: [reference.md](reference.md)
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
globs: **/*.store.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
# State Management: React Query, nuqs, Zustand
|
||||
|
||||
Use the following stack. Do **not** introduce or recommend Redux or React Context for shared/global state.
|
||||
|
||||
## Server state → React Query
|
||||
|
||||
- **Use for:** API responses, time-series data, caching, background refetch, retries, stale/refresh.
|
||||
- **Do not use Redux/Context** to store or mirror data that comes from React Query (e.g. do not dispatch API results into Redux).
|
||||
- Prefer generated React Query hooks from `frontend/src/api/generated` when available.
|
||||
- Keep server state in React Query; expose it via hooks that return the query result (and optionally memoized derived values). Do not duplicate it in Redux or Context.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: single source of truth from React Query
|
||||
export function useAppStateHook() {
|
||||
const { data, isError } = useQuery(...)
|
||||
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
|
||||
return { configs: memoizedConfigs, isError, ... }
|
||||
}
|
||||
|
||||
// ❌ BAD: copying React Query result into Redux
|
||||
dispatch({ type: UPDATE_LATEST_VERSION, payload: queryResponse.data })
|
||||
```
|
||||
|
||||
## URL state → nuqs
|
||||
|
||||
- **Use for:** shareable state, filters, time range, selected values, pagination, view state that belongs in the URL.
|
||||
- **Do not use Redux/Context** for state that should be shareable or reflected in the URL.
|
||||
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed, type-safe URL search params. Avoid ad-hoc `useSearchParams` encoding/decoding.
|
||||
- Keep URL payload small; respect browser URL length limits (e.g. Chrome ~2k chars). Do not put large datasets or sensitive data in query params.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: nuqs for filters / time range / selection
|
||||
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
|
||||
|
||||
// ❌ BAD: Redux/Context for shareable or URL-synced state
|
||||
const { timeRange } = useContext(SomeContext)
|
||||
```
|
||||
|
||||
## Client state → Zustand
|
||||
|
||||
- **Use for:** global/client state, cross-component state, feature flags, complex or large client objects (e.g. dashboard state, query builder state).
|
||||
- **Do not use Redux or React Context** for global or feature-level client state.
|
||||
- Prefer small, domain-scoped stores (e.g. DashboardStore, QueryBuilderStore).
|
||||
|
||||
### Zustand best practices (align with eslint-plugin-zustand-rules)
|
||||
|
||||
- **One store per module.** Do not define multiple `create()` calls in the same file; use one store per module (or compose slices into one store).
|
||||
- **Always use selectors.** Call the store hook with a selector so only the used slice triggers re-renders. Never use `useStore()` with no selector.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: selector — re-renders only when isDashboardLocked changes
|
||||
const isLocked = useDashboardStore(state => state.isDashboardLocked)
|
||||
|
||||
// ❌ BAD: no selector — re-renders on any store change
|
||||
const state = useDashboardStore()
|
||||
```
|
||||
|
||||
- **Never mutate state directly.** Update only via `set` or `setState` (or `getState()` + `set` for reads). No `state.foo = x` or `state.bears += 1` inside actions.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: use set
|
||||
increment: () => set(state => ({ bears: state.bears + 1 }))
|
||||
|
||||
// ❌ BAD: direct mutation
|
||||
increment: () => { state.bears += 1 }
|
||||
```
|
||||
|
||||
- **State properties before actions.** In the store object, list all state fields first, then action functions.
|
||||
- **Split into slices when state is large.** If a store has many top-level properties (e.g. more than 5–10), split into slice factories and combine with one `create()`.
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD: slices for large state
|
||||
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
|
||||
const createFishSlice = set => ({ fish: 0, addFish: () => set(s => ({ fish: s.fish + 1 })) })
|
||||
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
|
||||
```
|
||||
|
||||
- **In projects using Zustand:** add `eslint-plugin-zustand-rules` and extend `plugin:zustand-rules/recommended` to enforce these rules automatically.
|
||||
|
||||
## Local state → React state only
|
||||
|
||||
- **Use useState/useReducer** for: component-local UI state, form inputs, toggles, hover state, data that never leaves the component.
|
||||
- Do not use Zustand, Redux, or Context for state that is purely local to one component or a small subtree.
|
||||
|
||||
## Summary
|
||||
|
||||
| State type | Use | Avoid |
|
||||
|-------------------|------------------|--------------------|
|
||||
| Server / API | React Query | Redux, Context |
|
||||
| URL / shareable | nuqs | Redux, Context |
|
||||
| Global client | Zustand | Redux, Context |
|
||||
| Local UI | useState/useReducer | Zustand, Redux, Context |
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
description: Core testing conventions - imports, rendering, MSW, interactions, queries
|
||||
globs: **/*.test.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Testing Conventions
|
||||
|
||||
## Imports
|
||||
|
||||
Always import from the test harness, never directly from `@testing-library/react`:
|
||||
|
||||
```ts
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
```
|
||||
|
||||
## Router
|
||||
|
||||
Use the built-in router in `render`:
|
||||
|
||||
```ts
|
||||
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
|
||||
```
|
||||
|
||||
Only mock `useLocation` / `useParams` if the test depends on their values.
|
||||
|
||||
## MSW
|
||||
|
||||
Global MSW server runs automatically. Override per-test:
|
||||
|
||||
```ts
|
||||
server.use(
|
||||
rest.get('*/api/v1/foo', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ ok: true })))
|
||||
);
|
||||
```
|
||||
|
||||
Keep large response fixtures in `mocks-server/__mockdata_`.
|
||||
|
||||
## Interactions
|
||||
|
||||
- Prefer `userEvent` for real user interactions (click, type, select, tab).
|
||||
- Use `fireEvent` only for low-level events not covered by `userEvent` (e.g., scroll, resize). Wrap in `act(...)` if needed.
|
||||
- Always `await` interactions:
|
||||
|
||||
```ts
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
```
|
||||
|
||||
## Timers
|
||||
|
||||
No global fake timers. Per-test only, for debounce/throttle:
|
||||
|
||||
```ts
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
|
||||
await user.type(screen.getByRole('textbox'), 'query');
|
||||
jest.advanceTimersByTime(400);
|
||||
jest.useRealTimers();
|
||||
```
|
||||
|
||||
## Queries
|
||||
|
||||
Prefer accessible queries: `getByRole` > `findByRole` > `getByLabelText` > visible text > `data-testid` (last resort).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never import from `@testing-library/react` directly
|
||||
- Never use global fake timers
|
||||
- Never wrap `render` in `act(...)`
|
||||
- Never mock infra dependencies locally (router, react-query)
|
||||
- Limit to 3-5 focused tests per file
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
description: When to use global vs local mocks in tests
|
||||
globs: **/*.test.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Mock Strategy
|
||||
|
||||
## Use Global Mocks For
|
||||
|
||||
High-frequency dependencies (20+ test files):
|
||||
- Core infrastructure: react-router-dom, react-query, antd
|
||||
- Browser APIs: ResizeObserver, matchMedia, localStorage
|
||||
- Utility libraries: date-fns, lodash
|
||||
|
||||
Available global mock files (from jest.config.ts):
|
||||
- `uplot` -> `__mocks__/uplotMock.ts`
|
||||
|
||||
## Use Local Mocks For
|
||||
|
||||
- Business logic dependencies (API endpoints, custom hooks, domain components)
|
||||
- Test-specific behavior (different data per test, error scenarios, loading states)
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Used in 20+ test files?
|
||||
YES -> Global mock
|
||||
NO -> Business logic or test-specific?
|
||||
YES -> Local mock
|
||||
NO -> Consider global if usage grows
|
||||
```
|
||||
|
||||
## Correct Usage
|
||||
|
||||
```ts
|
||||
// Global mocks are already available - just import
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Local mocks for business logic
|
||||
jest.mock('../api/tracesService', () => ({
|
||||
getTraces: jest.fn(() => mockTracesData),
|
||||
}));
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
```ts
|
||||
// Never re-mock globally mocked dependencies locally
|
||||
jest.mock('react-router-dom', () => ({ ... }));
|
||||
|
||||
// Never put test-specific data in global mocks
|
||||
jest.mock('../api/tracesService', () => ({ getTraces: jest.fn(() => specificTestData) }));
|
||||
```
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
description: TypeScript type safety requirements for Jest tests
|
||||
globs: **/*.test.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# TypeScript Type Safety in Tests
|
||||
|
||||
All Jest tests must be fully type-safe. Never use `any`.
|
||||
|
||||
## Mock Function Typing
|
||||
|
||||
```ts
|
||||
// Use jest.mocked for module mocks
|
||||
import useFoo from 'hooks/useFoo';
|
||||
jest.mock('hooks/useFoo');
|
||||
const mockUseFoo = jest.mocked(useFoo);
|
||||
|
||||
// Use jest.MockedFunction for standalone mocks
|
||||
const mockFetch = jest.fn() as jest.MockedFunction<(id: number) => Promise<User>>;
|
||||
```
|
||||
|
||||
## Mock Data
|
||||
|
||||
Define interfaces for all mock data:
|
||||
|
||||
```ts
|
||||
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
|
||||
const mockProps: ComponentProps = {
|
||||
title: 'Test',
|
||||
data: [mockUser],
|
||||
onSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
|
||||
};
|
||||
```
|
||||
|
||||
## Hook Mocking Pattern
|
||||
|
||||
```ts
|
||||
import useFoo from 'hooks/useFoo';
|
||||
jest.mock('hooks/useFoo');
|
||||
const mockUseFoo = jest.mocked(useFoo);
|
||||
mockUseFoo.mockReturnValue(/* minimal shape */);
|
||||
```
|
||||
|
||||
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
|
||||
|
||||
## Checklist
|
||||
|
||||
- All mock functions use `jest.MockedFunction<T>` or `jest.mocked()`
|
||||
- All mock data has proper interfaces
|
||||
- No `any` types in test files
|
||||
- Component props are typed
|
||||
- API response types are defined
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
description: Prefer SigNoz UI and icons across frontend code
|
||||
globs: **/*.{ts,tsx,js,jsx}
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# UI Components and Icons Source of Truth
|
||||
|
||||
For all frontend implementation work in this repository:
|
||||
|
||||
- Always use UI primitives/components from `@signozhq/ui`.
|
||||
- Always use icons from `@signozhq/icons`.
|
||||
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
|
||||
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
|
||||
|
||||
## Migration guidance
|
||||
|
||||
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
|
||||
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.
|
||||
@@ -143,7 +143,12 @@ const preview: Preview = {
|
||||
'Logs',
|
||||
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', 'Funnel Details'],
|
||||
[
|
||||
'Explorer',
|
||||
['Overview', 'Span Drawer'],
|
||||
'Trace Details',
|
||||
'Funnel Details',
|
||||
],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
|
||||
@@ -25,15 +25,24 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- UI primitives come from `@signozhq/ui` (subpath imports) and icons from `@signozhq/icons`. No new `antd` component or direct icon library (`lucide-react`) where `@signozhq/ui` has an equivalent; `signoz/no-antd-components` enforces part of this list.
|
||||
- Exception: a control added beside `antd` siblings of the same kind (a tab in an `antd` tab strip) matches them until that surface migrates. Say so in the PR.
|
||||
- When `@signozhq/ui` or `@signozhq/icons` lacks what you need, say so in the PR before reaching for another library.
|
||||
- Before styling a `@signozhq/ui` component, check its props in `node_modules/@signozhq/ui/dist/<component>/*.d.ts` for `className`/`style` support; some omit them. Avoid overriding the component's CSS vars unless explicitly needed.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
- A flow that gains a request also gates on that request's permission: a rename that now reads the view first needs `read` as well as `update`.
|
||||
- Identity keys (legend groups, color groups, CSV headers, React keys) come from structured values, never from joined display text: `['us, east', 'prod']` and `['us', 'east, prod']` both join to `us, east, prod`.
|
||||
- A payload saved under another kind or schema copies only the fields that schema accepts, and drops bindings to columns the user removed. The server rejects unknown or stale fields, so the preview works and the save fails.
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. Run these checks once all changes of a task are done, never after each edit. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
- Run `pnpm lint:js --quiet` to find critical errors
|
||||
- Run `pnpm oxlint <file1> <file2>` and fix all warnings
|
||||
- Run `pnpm build`
|
||||
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run.
|
||||
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run. Run them before coding the change or after all changes, never in between.
|
||||
- For e2e, run only the specs you added or changed: the stack is heavy. Leave the full jest and e2e suites to CI unless the user asks.
|
||||
- For every test you added or changed, revert the change it covers and confirm the test fails
|
||||
- On a stacked branch (`gh stack`), run `pnpm tsgo --noEmit` on every branch above it before pushing: a renamed prop or moved export breaks them
|
||||
- Fixed ALL resulting errors
|
||||
|
||||
4. BEHAVIOR CHANGE DETECTION: When modifying existing behavior:
|
||||
@@ -41,6 +50,14 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Update test assertions to match new behavior
|
||||
- If no tests exist, add them BEFORE changing behavior
|
||||
|
||||
## Repo Rules
|
||||
|
||||
Claude Code loads `../.claude/rules/` on its own. Other agents read the matching file before editing:
|
||||
- Every file: [comments](../.claude/rules/comments.md), [pull requests](../.claude/rules/pull-requests.md)
|
||||
- `src/**/*.ts(x)` that holds state, a query, URL params or a form: [frontend state](../.claude/rules/frontend-state.md)
|
||||
- `*.test.ts(x)`: [frontend tests](../.claude/rules/frontend-tests.md)
|
||||
- `*.tsx` that changes what a page renders: [storybook](../.claude/rules/storybook.md)
|
||||
|
||||
## Context Management
|
||||
|
||||
1. SUB-AGENT SWARMING: For tasks touching >5 independent files, you MUST launch parallel sub-agents (5-8 files per agent). Each agent gets its own context window. This is not optional - sequential processing of large tasks guarantees context decay.
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.4.15",
|
||||
"dompurify": "3.4.17",
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
@@ -200,17 +200,17 @@
|
||||
"oxfmt": "0.73.0",
|
||||
"oxlint": "1.88.0",
|
||||
"oxlint-tsgolint": "7.0.2004",
|
||||
"postcss": "8.5.26",
|
||||
"postcss": "8.5.32",
|
||||
"postcss-scss": "4.0.9",
|
||||
"react-resizable": "3.0.4",
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.35.4",
|
||||
"sharp": "0.35.5",
|
||||
"storybook": "10.6.3",
|
||||
"stylelint": "17.15.0",
|
||||
"svgo": "4.1.0",
|
||||
"ts-jest": "29.4.15",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"typescript-plugin-css-modules": "5.3.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
|
||||
465
frontend/pnpm-lock.yaml
generated
465
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -37,9 +37,9 @@ overrides:
|
||||
cookie@<0.7.0: '>=0.7.1 <1'
|
||||
# via: @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
|
||||
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
|
||||
# remove: blocked. The direct dep is already on 3.4.15, but @grafana/data
|
||||
# remove: blocked. The direct dep is already on 3.4.17, but @grafana/data
|
||||
# (latest 13.1.0) and monaco-editor (latest 0.55.1) still pin vulnerable versions
|
||||
dompurify@<=3.4.12: '>=3.4.13 <4'
|
||||
dompurify@<=3.4.16: '>=3.4.17 <4'
|
||||
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); ts-jest@29.4.9 (~0.27.4)
|
||||
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
|
||||
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
|
||||
@@ -60,17 +60,10 @@ overrides:
|
||||
# @commitlint/cli > @commitlint/load > cosmiconfig@9 (^4.1.0)
|
||||
# remove: blocked, both consumers cap js-yaml at ^4 and only 4.3.2 carries the fix
|
||||
'js-yaml@>=4.0.0 <4.3.2': '>=4.3.2 <5'
|
||||
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
|
||||
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
|
||||
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
|
||||
less@<4.5.0: '>=4.5.0 <5'
|
||||
# via: direct dep postcss (nanoid ^3.3.17)
|
||||
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
|
||||
nanoid@<3.3.18: '>=3.3.18 <4'
|
||||
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
|
||||
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
|
||||
prismjs@<1.30.0: '>=1.30.0 <2'
|
||||
# via: typescript-plugin-css-modules@5.2.0 > postcss-modules-local-by-default@4.2.0
|
||||
# via: typescript-plugin-css-modules@5.3.0 > postcss-modules-local-by-default@4.2.0
|
||||
# (postcss-selector-parser ^7.0.0)
|
||||
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
|
||||
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
|
||||
|
||||
@@ -18,15 +18,25 @@
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_deployment": "Jira deployment",
|
||||
"option_jira_deployment_cloud": "Jira Cloud",
|
||||
"option_jira_deployment_selfhosted": "Self-hosted Jira",
|
||||
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
|
||||
"jira_selfhosted_tip_link": "Learn how",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, project and issue type are required",
|
||||
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
|
||||
"jira_pat_required": "A personal access token is required for self-hosted Jira",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_pat": "Personal access token",
|
||||
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
@@ -34,6 +44,7 @@
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
@@ -103,7 +114,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",
|
||||
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
|
||||
"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",
|
||||
|
||||
@@ -26,15 +26,25 @@
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_deployment": "Jira deployment",
|
||||
"option_jira_deployment_cloud": "Jira Cloud",
|
||||
"option_jira_deployment_selfhosted": "Self-hosted Jira",
|
||||
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
|
||||
"jira_selfhosted_tip_link": "Learn how",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, project and issue type are required",
|
||||
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
|
||||
"jira_pat_required": "A personal access token is required for self-hosted Jira",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_pat": "Personal access token",
|
||||
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
@@ -42,6 +52,7 @@
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
@@ -169,7 +180,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",
|
||||
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
|
||||
"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",
|
||||
|
||||
@@ -829,6 +829,37 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
*/
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
pat: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelJiraAuthDTO =
|
||||
| AlertmanagertypesChannelJiraCloudAuthDTO
|
||||
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
|
||||
|
||||
export enum AlertmanagertypesJiraDeploymentDTO {
|
||||
cloud = 'cloud',
|
||||
selfhosted = 'selfhosted',
|
||||
}
|
||||
/**
|
||||
* Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
@@ -844,26 +875,23 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFields = Alertmanagertype
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
* @description The credentials: email and apiToken when deployment is cloud, pat when deployment is selfhosted.
|
||||
*/
|
||||
apiToken: string;
|
||||
auth: AlertmanagertypesChannelJiraAuthDTO;
|
||||
/**
|
||||
* @type object,null
|
||||
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
* @description The Jira edition: cloud for an Atlassian-hosted site, selfhosted for Jira Server or Data Center.
|
||||
*/
|
||||
deployment: AlertmanagertypesJiraDeploymentDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Alertmanager template for the issue description, rendered as markdown; defaults to the SigNoz Jira default.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The issue type name, such as Bug.
|
||||
@@ -906,7 +934,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The base URL of the Jira Cloud site, for example https://example.atlassian.net. Only Jira Cloud is supported.
|
||||
* @description The base URL of the Jira site, for example https://example.atlassian.net or https://jira.example.com.
|
||||
*/
|
||||
site: string;
|
||||
/**
|
||||
@@ -1716,13 +1744,17 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
|
||||
/**
|
||||
* @description The Jira edition, cloud or selfhosted; defaults to cloud.
|
||||
*/
|
||||
deployment?: AlertmanagertypesJiraDeploymentDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Alertmanager template for the issue description.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @description HTTP client settings; basic auth carries the account email and API token.
|
||||
* @description HTTP client settings; basic auth carries the account email and API token on cloud, authorization carries the Bearer personal access token on self-hosted.
|
||||
*/
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
@@ -1765,7 +1797,7 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Jira Cloud base URL.
|
||||
* @description The Jira base URL.
|
||||
*/
|
||||
site?: string;
|
||||
/**
|
||||
@@ -3936,7 +3968,7 @@ export interface CloudintegrationtypesAWSConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
connectionUrl: string;
|
||||
cliCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
|
||||
@@ -4146,10 +4178,6 @@ export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cloudPowerShellCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
|
||||
@@ -5277,7 +5305,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5310,7 +5338,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5324,7 +5352,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5669,7 +5697,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5702,7 +5730,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5716,7 +5744,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5849,7 +5877,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5882,7 +5910,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5896,7 +5924,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5948,7 +5976,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5981,7 +6009,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5995,7 +6023,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -6941,6 +6969,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
distribution = 'distribution',
|
||||
}
|
||||
/**
|
||||
* The query kind.
|
||||
@@ -7068,6 +7097,11 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
* @description The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of ordered series or rows to skip before the page starts. Requires limit.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The sort order of the formula result.
|
||||
@@ -7150,7 +7184,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
|
||||
* @description The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts. Requires limit.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -10977,7 +11011,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
value?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
*/
|
||||
values?: number[];
|
||||
}
|
||||
@@ -11598,7 +11632,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values. The first bucket starts at -Inf when its bound is at or under zero, else at zero.
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
@@ -11719,6 +11753,91 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
|
||||
*/
|
||||
counts: number[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @description The group-by field values that identify this distribution.
|
||||
*/
|
||||
labels?: Querybuildertypesv5LabelDTO[];
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionPercentilesDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 50th percentile.
|
||||
*/
|
||||
p50: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 75th percentile.
|
||||
*/
|
||||
p75: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 90th percentile.
|
||||
*/
|
||||
p90: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 95th percentile.
|
||||
*/
|
||||
p95: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 99th percentile.
|
||||
*/
|
||||
p99: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
|
||||
*/
|
||||
alias: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One distribution per group-by combination.
|
||||
*/
|
||||
distributions: Querybuildertypesv5DistributionDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The zero-based position of the aggregation in the query.
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @description Percentiles over the counts of every distribution added together, estimated the way histogram_quantile does by interpolating linearly within the bucket each rank falls in. Omitted when nothing was counted or meta.buckets is empty.
|
||||
*/
|
||||
percentiles?: Querybuildertypesv5DistributionPercentilesDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
|
||||
*/
|
||||
aggregations: Querybuildertypesv5DistributionAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the query that produced this result.
|
||||
*/
|
||||
queryName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step interval in seconds that was used for each query, keyed by query name.
|
||||
*/
|
||||
@@ -11974,21 +12093,28 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
| (Querybuildertypesv5TimeSeriesDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5ScalarDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5RawDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5DistributionDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
@@ -12122,7 +12248,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractOnlyMessageBody } from 'lib/logs/logBody';
|
||||
import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
@@ -281,19 +282,6 @@ function convertScalarWithFormatForWeb(
|
||||
});
|
||||
}
|
||||
|
||||
function extractOnlyMessageBody(body: unknown): unknown {
|
||||
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
|
||||
if (isJsonBody) {
|
||||
const keys = Object.keys(body);
|
||||
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
|
||||
if (hasOnlyMessageKey) {
|
||||
const { message } = body as { message: unknown };
|
||||
return typeof message === 'string' ? message : JSON.stringify(message);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 RawData to legacy format
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,13 @@ 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[] = [];
|
||||
@@ -77,6 +84,20 @@ 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' };
|
||||
|
||||
@@ -10,7 +10,7 @@ interface AuthErrorProps {
|
||||
|
||||
function AuthError({ error }: AuthErrorProps): JSX.Element {
|
||||
return (
|
||||
<div className="auth-error-container">
|
||||
<div className="auth-error-container" data-testid="auth-error">
|
||||
<ErrorContent
|
||||
error={error}
|
||||
icon={<CircleAlert size={12} className="auth-error-icon" />}
|
||||
|
||||
@@ -512,6 +512,7 @@ export default function CeleryOverviewTable({
|
||||
allowClear
|
||||
/>
|
||||
<ResizeTable
|
||||
data-testid="mq-overview-table"
|
||||
className="celery-overview-table"
|
||||
pagination={paginationConfig}
|
||||
size="middle"
|
||||
|
||||
@@ -28,6 +28,7 @@ function CeleryTaskConfigOptions(): JSX.Element {
|
||||
Task Name
|
||||
</Typography.Text>
|
||||
<Select
|
||||
data-testid="celery-task-name-select"
|
||||
placeholder="Task Name"
|
||||
showSearch
|
||||
mode="multiple"
|
||||
|
||||
@@ -105,7 +105,10 @@ export default function CeleryTaskGraphGrid({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="celery-task-graph-grid-container">
|
||||
<div
|
||||
className="celery-task-graph-grid-container"
|
||||
data-testid="celery-graph-grid"
|
||||
>
|
||||
<div className="metric-based-graphs">
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<div className="row-panel">
|
||||
|
||||
@@ -80,6 +80,7 @@ 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' : ''
|
||||
|
||||
@@ -251,6 +251,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Button
|
||||
className={cx('data-time-live', isLiveLogsEnabled ? 'active' : '')}
|
||||
type="text"
|
||||
data-testid="time-picker-live"
|
||||
onClick={handleGoLive}
|
||||
>
|
||||
Live
|
||||
|
||||
@@ -88,6 +88,7 @@ function TimezoneItem({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`timezone-item-${timezone.value}`}
|
||||
className={cx('timezone-picker__item', {
|
||||
selected: isSelected,
|
||||
'has-divider': timezone.hasDivider,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.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%;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
|
||||
import cx from 'classnames';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
import styles from './DetailsPanelDrawer.module.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
@@ -14,22 +17,41 @@ function DetailsPanelDrawer({
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showOverlay={false}
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
modal={false}
|
||||
>
|
||||
<div className="details-panel-drawer__body">{children}</div>
|
||||
</DrawerWrapper>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
DetailsPanelDrawer.defaultProps = {
|
||||
className: undefined,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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';
|
||||
@@ -194,6 +195,23 @@ 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 = [
|
||||
|
||||
@@ -52,6 +52,7 @@ function DeleteMemberDialog({
|
||||
variant="solid"
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
testId="member-delete-confirm-btn"
|
||||
loading={isDeleting}
|
||||
prefix={<Trash2 size={12} />}
|
||||
>
|
||||
|
||||
@@ -624,6 +624,7 @@ function EditMemberDrawer({
|
||||
disabledTooltip={getDeleteTooltip(isRootUser, isSelf)}
|
||||
size="md"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
testId="member-delete-btn"
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="danger"
|
||||
@@ -637,6 +638,7 @@ function EditMemberDrawer({
|
||||
disabledTooltip={ROOT_USER_TOOLTIP}
|
||||
size="md"
|
||||
onClick={handleGenerateResetLink}
|
||||
testId="member-reset-link-btn"
|
||||
disabled={isRootUser}
|
||||
loading={isGeneratingLink || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
@@ -674,6 +676,7 @@ function EditMemberDrawer({
|
||||
color="primary"
|
||||
disabled={!isDirty || isRootUser}
|
||||
onClick={handleSave}
|
||||
testId="member-save-btn"
|
||||
loading={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
|
||||
@@ -42,7 +42,12 @@ 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">{resetLink}</span>
|
||||
<span
|
||||
className="reset-link-dialog__link-text"
|
||||
data-testid="reset-link-text"
|
||||
>
|
||||
{resetLink}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import ExportMenu from '../ExportMenu';
|
||||
@@ -76,6 +76,17 @@ 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();
|
||||
|
||||
@@ -29,8 +29,8 @@ export default function WaitlistFragment({
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
logEvent('Infra Monitoring: Get Early Access Clicked', {
|
||||
entity_type: entityType,
|
||||
logEvent('Infra Monitoring: Get early access clicked', {
|
||||
entityType,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ function InputWithLabel({
|
||||
className,
|
||||
closeIcon,
|
||||
disabled,
|
||||
sanitize,
|
||||
}: {
|
||||
label: string;
|
||||
initialValue?: string | number | null;
|
||||
@@ -29,14 +30,17 @@ 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 => {
|
||||
setInputValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
const value = sanitize ? sanitize(e.target.value) : e.target.value;
|
||||
setInputValue(value);
|
||||
onChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,6 +82,7 @@ InputWithLabel.defaultProps = {
|
||||
initialValue: undefined,
|
||||
className: undefined,
|
||||
closeIcon: undefined,
|
||||
sanitize: undefined,
|
||||
};
|
||||
|
||||
export default InputWithLabel;
|
||||
|
||||
100
frontend/src/components/ListFilter/FilterZone/FilterChips.tsx
Normal file
100
frontend/src/components/ListFilter/FilterZone/FilterChips.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ConfigProvider, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
import type { CreatorOption } from '../types';
|
||||
import { UPDATED_OPTIONS } from './constants';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
// The option checkbox already marks selection, so selected rows stay regular weight.
|
||||
const CREATOR_SELECT_THEME = {
|
||||
components: { Select: { optionSelectedFontWeight: 400 } },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
creatorOptions: CreatorOption[];
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
// Run the staged draft — fired when a dropdown closes.
|
||||
onApply: () => void;
|
||||
// Clear all Created-by selections and run immediately.
|
||||
onClearCreatedBy: () => void;
|
||||
// Ids: `${testIdPrefix}-filter-created-by`, `-filter-updated`.
|
||||
testIdPrefix: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FilterChips({
|
||||
createdBy,
|
||||
updated,
|
||||
creatorOptions,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
onApply,
|
||||
onClearCreatedBy,
|
||||
testIdPrefix,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
const creatorOptionsData = creatorOptions.map((o) => ({
|
||||
value: o.email,
|
||||
label: o.label,
|
||||
}));
|
||||
|
||||
const runOnClose = (open: boolean): void => {
|
||||
if (!open) {
|
||||
onApply();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<ConfigProvider theme={CREATOR_SELECT_THEME}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
className={cx(styles.select, styles.selectWide)}
|
||||
placeholder="Created by"
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
optionFilterProp="label"
|
||||
maxTagCount={1}
|
||||
menuItemSelectedIcon={null}
|
||||
data-testid={`${testIdPrefix}-filter-created-by`}
|
||||
disabled={disabled}
|
||||
onClear={onClearCreatedBy}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<div className={styles.creatorOption}>
|
||||
<div className={styles.creatorCheck}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={createdBy.includes(option.value as string)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.creatorLabel}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={(value): void => onCreatedByChange(value as string[])}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
<Select
|
||||
showSearch
|
||||
className={cx(styles.select, styles.selectNarrow)}
|
||||
placeholder="Updated"
|
||||
value={updated}
|
||||
options={UPDATED_OPTIONS}
|
||||
optionFilterProp="label"
|
||||
data-testid={`${testIdPrefix}-filter-updated`}
|
||||
disabled={disabled}
|
||||
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterChips;
|
||||
@@ -94,6 +94,8 @@
|
||||
}
|
||||
|
||||
.creatorCheck {
|
||||
// Flex, not block: a line box would shift with the check mark's baseline.
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -103,3 +105,8 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
166
frontend/src/components/ListFilter/FilterZone/FilterZone.tsx
Normal file
166
frontend/src/components/ListFilter/FilterZone/FilterZone.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import type { DslGrammar, SuggestionSource } from '../dsl/types';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import type {
|
||||
CreatorOption,
|
||||
FilterAppliedPayload,
|
||||
FilterAppliedType,
|
||||
} from '../types';
|
||||
import FilterChips from './FilterChips';
|
||||
import { useFilterZoneDraft } from './useFilterZoneDraft';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
// The last-run query (source of truth for fetching + the dirty baseline).
|
||||
query: string;
|
||||
grammar: DslGrammar;
|
||||
// Ids: `${testIdPrefix}-search-zone`, `-list-search*`, `-filter-zone`,
|
||||
// `-filter-clear`, `-filter-created-by`, `-filter-updated`, `-query-error`.
|
||||
testIdPrefix: string;
|
||||
creatorOptions: CreatorOption[];
|
||||
source: SuggestionSource;
|
||||
// Commit (run) the draft — the only path that triggers a fetch.
|
||||
onQueryChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
// Rendered at the end of the search row (e.g. the New Dashboard action).
|
||||
rightSlot?: ReactNode;
|
||||
// Rendered in the filters row after the chips.
|
||||
extraFilters?: ReactNode;
|
||||
// Shows Clear even when the query is empty.
|
||||
hasExtraFilters?: boolean;
|
||||
// Called by Clear after it empties the query.
|
||||
onClearAll?: () => void;
|
||||
// Shown under the search row.
|
||||
error?: string;
|
||||
// Wraps the search box and the filter controls (e.g. in an authz tooltip).
|
||||
wrapControl?: (node: ReactElement) => ReactElement;
|
||||
onSubmitted?: (hasQuery: boolean) => void;
|
||||
onFilterApplied?: (
|
||||
filterType: FilterAppliedType,
|
||||
payload: FilterAppliedPayload,
|
||||
) => void;
|
||||
onCleared?: () => void;
|
||||
/** Filtering needs `list`: the box and chips edit a query that cannot be run. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const identity = (node: ReactElement): ReactElement => node;
|
||||
|
||||
// The filter command zone: a draft DSL query box (run with Cmd/Ctrl+Enter or the
|
||||
// Run button; a dirty dot signals unrun changes) plus Created-by / Updated chips.
|
||||
function FilterZone({
|
||||
query,
|
||||
grammar,
|
||||
testIdPrefix,
|
||||
creatorOptions,
|
||||
source,
|
||||
onQueryChange,
|
||||
placeholder,
|
||||
rightSlot,
|
||||
extraFilters,
|
||||
hasExtraFilters = false,
|
||||
onClearAll,
|
||||
error,
|
||||
wrapControl = identity,
|
||||
onSubmitted,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
reflected,
|
||||
dirty,
|
||||
isEmpty,
|
||||
run,
|
||||
handleCreatedByChange,
|
||||
handleUpdatedChange,
|
||||
handleClearCreatedBy,
|
||||
handleClear,
|
||||
} = useFilterZoneDraft({
|
||||
query,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.filterZone}>
|
||||
<div className={styles.searchRow}>
|
||||
{wrapControl(
|
||||
<div
|
||||
className={styles.searchInput}
|
||||
data-testid={`${testIdPrefix}-search-zone`}
|
||||
>
|
||||
<SearchBar
|
||||
value={draft}
|
||||
grammar={grammar}
|
||||
testIdPrefix={`${testIdPrefix}-list-search`}
|
||||
placeholder={placeholder}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
disabled={disabled}
|
||||
onChange={setDraft}
|
||||
onSubmit={run}
|
||||
onSubmitted={onSubmitted}
|
||||
/>
|
||||
</div>,
|
||||
)}
|
||||
{rightSlot}
|
||||
</div>
|
||||
{error && (
|
||||
<Typography.Text
|
||||
className={styles.error}
|
||||
testId={`${testIdPrefix}-query-error`}
|
||||
>
|
||||
{error}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={styles.filtersRow}>
|
||||
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
|
||||
{wrapControl(
|
||||
<div
|
||||
className={styles.filterControls}
|
||||
data-testid={`${testIdPrefix}-filter-zone`}
|
||||
>
|
||||
<FilterChips
|
||||
createdBy={reflected.createdBy}
|
||||
updated={reflected.updated}
|
||||
creatorOptions={creatorOptions}
|
||||
testIdPrefix={testIdPrefix}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onApply={run}
|
||||
onClearCreatedBy={handleClearCreatedBy}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{extraFilters}
|
||||
{(!isEmpty || hasExtraFilters) && (
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={12} />}
|
||||
disabled={disabled}
|
||||
onClick={handleClear}
|
||||
testId={`${testIdPrefix}-filter-clear`}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterZone;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
import { useFilterZoneDraft } from '../useFilterZoneDraft';
|
||||
|
||||
const BASE_QUERY = "name = 'x'";
|
||||
|
||||
type Args = Required<Parameters<typeof useFilterZoneDraft>[0]>;
|
||||
|
||||
function setup(query = BASE_QUERY): {
|
||||
result: { current: ReturnType<typeof useFilterZoneDraft> };
|
||||
rerender: (props: { query: string }) => void;
|
||||
onQueryChange: jest.MockedFunction<Args['onQueryChange']>;
|
||||
onFilterApplied: jest.MockedFunction<Args['onFilterApplied']>;
|
||||
onCleared: jest.MockedFunction<Args['onCleared']>;
|
||||
onClearAll: jest.MockedFunction<Args['onClearAll']>;
|
||||
} {
|
||||
const onQueryChange = jest.fn() as jest.MockedFunction<Args['onQueryChange']>;
|
||||
const onFilterApplied = jest.fn() as jest.MockedFunction<
|
||||
Args['onFilterApplied']
|
||||
>;
|
||||
const onCleared = jest.fn() as jest.MockedFunction<Args['onCleared']>;
|
||||
const onClearAll = jest.fn() as jest.MockedFunction<Args['onClearAll']>;
|
||||
const { result, rerender } = renderHook(
|
||||
({ query: q }: { query: string }) =>
|
||||
useFilterZoneDraft({
|
||||
query: q,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
}),
|
||||
{ initialProps: { query } },
|
||||
);
|
||||
return {
|
||||
result,
|
||||
rerender,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useFilterZoneDraft', () => {
|
||||
it('edits a draft without running it until run is called', () => {
|
||||
const { result, onQueryChange } = setup();
|
||||
|
||||
act(() => result.current.setDraft("name = 'y' "));
|
||||
|
||||
expect(result.current.dirty).toBe(true);
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.run());
|
||||
|
||||
expect(onQueryChange).toHaveBeenCalledWith("name = 'y'");
|
||||
});
|
||||
|
||||
it('does not run a draft that matches the last-run query', () => {
|
||||
const { result, onQueryChange } = setup();
|
||||
|
||||
act(() => result.current.run());
|
||||
|
||||
expect(result.current.dirty).toBe(false);
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resyncs the draft when the query changes from outside', () => {
|
||||
const { result, rerender } = setup();
|
||||
|
||||
act(() => result.current.setDraft("name = 'typing'"));
|
||||
rerender({ query: "name = 'from-view'" });
|
||||
|
||||
expect(result.current.draft).toBe("name = 'from-view'");
|
||||
expect(result.current.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it('stages a created-by pick into the draft without running it', () => {
|
||||
const { result, onQueryChange, onFilterApplied } = setup();
|
||||
|
||||
act(() => result.current.handleCreatedByChange(['a@x.io', 'b@x.io']));
|
||||
|
||||
expect(result.current.draft).toBe(
|
||||
"name = 'x' AND created_by IN ['a@x.io', 'b@x.io']",
|
||||
);
|
||||
expect(result.current.reflected.createdBy).toStrictEqual([
|
||||
'a@x.io',
|
||||
'b@x.io',
|
||||
]);
|
||||
expect(onFilterApplied).toHaveBeenCalledWith('createdBy', { valueCount: 2 });
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs an updated-window pick immediately', () => {
|
||||
const { result, onQueryChange, onFilterApplied } = setup();
|
||||
|
||||
act(() => result.current.handleUpdatedChange('7d'));
|
||||
|
||||
expect(onFilterApplied).toHaveBeenCalledWith('updatedWindow', {});
|
||||
expect(onQueryChange).toHaveBeenCalledTimes(1);
|
||||
expect(onQueryChange.mock.calls[0][0]).toContain("name = 'x' AND updated_at");
|
||||
expect(result.current.reflected.updated).toBe('7d');
|
||||
});
|
||||
|
||||
it('drops the created-by clause and runs when created-by is cleared', () => {
|
||||
const { result, onQueryChange } = setup(
|
||||
"created_by = 'a@x.io' AND name = 'x'",
|
||||
);
|
||||
|
||||
act(() => result.current.handleClearCreatedBy());
|
||||
|
||||
expect(result.current.draft).toBe("name = 'x'");
|
||||
expect(onQueryChange).toHaveBeenCalledWith("name = 'x'");
|
||||
});
|
||||
|
||||
it('empties the draft and runs the empty query on clear', () => {
|
||||
const { result, onQueryChange, onCleared, onClearAll } = setup();
|
||||
|
||||
act(() => result.current.handleClear());
|
||||
|
||||
expect(result.current.draft).toBe('');
|
||||
expect(result.current.isEmpty).toBe(true);
|
||||
expect(onCleared).toHaveBeenCalledTimes(1);
|
||||
expect(onQueryChange).toHaveBeenCalledWith('');
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips re-running an already-empty query on clear', () => {
|
||||
const { result, onQueryChange, onClearAll } = setup('');
|
||||
|
||||
act(() => result.current.handleClear());
|
||||
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
15
frontend/src/components/ListFilter/FilterZone/constants.ts
Normal file
15
frontend/src/components/ListFilter/FilterZone/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
|
||||
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
|
||||
any: 'Any time',
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
export const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
|
||||
value: w,
|
||||
label: UPDATED_LABELS[w],
|
||||
}));
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
createdByClause,
|
||||
parseReflectedClauses,
|
||||
type ReflectedClauses,
|
||||
spliceClause,
|
||||
updatedClause,
|
||||
} from '../dsl/filterQuery';
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
import type { FilterAppliedPayload, FilterAppliedType } from '../types';
|
||||
|
||||
interface Args {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onFilterApplied?: (
|
||||
filterType: FilterAppliedType,
|
||||
payload: FilterAppliedPayload,
|
||||
) => void;
|
||||
onCleared?: () => void;
|
||||
onClearAll?: () => void;
|
||||
}
|
||||
|
||||
interface FilterZoneDraft {
|
||||
draft: string;
|
||||
setDraft: (value: string) => void;
|
||||
reflected: ReflectedClauses;
|
||||
dirty: boolean;
|
||||
isEmpty: boolean;
|
||||
run: () => void;
|
||||
handleCreatedByChange: (emails: string[]) => void;
|
||||
handleUpdatedChange: (window: UpdatedWindow) => void;
|
||||
handleClearCreatedBy: () => void;
|
||||
handleClear: () => void;
|
||||
}
|
||||
|
||||
// The query box is a DRAFT: typing and the Created-by / Updated dropdowns all edit
|
||||
// the draft (dropdowns splice their clause in); nothing fetches until the draft is
|
||||
// run. The draft re-syncs when the last-run query changes externally (view
|
||||
// select, back/forward).
|
||||
export function useFilterZoneDraft({
|
||||
query,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
}: Args): FilterZoneDraft {
|
||||
const [draft, setDraft] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(query);
|
||||
}, [query]);
|
||||
|
||||
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
|
||||
const dirty = draft.trim() !== query.trim();
|
||||
const isEmpty = !draft.trim();
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => {
|
||||
const trimmed = next.trim();
|
||||
if (trimmed !== query) {
|
||||
onQueryChange(trimmed);
|
||||
}
|
||||
},
|
||||
[query, onQueryChange],
|
||||
);
|
||||
|
||||
const run = useCallback((): void => commit(draft), [commit, draft]);
|
||||
|
||||
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
|
||||
// when the dropdown closes (FilterChips fires onApply), not on each pick.
|
||||
const handleCreatedByChange = useCallback(
|
||||
(emails: string[]): void => {
|
||||
onFilterApplied?.('createdBy', { valueCount: emails.length });
|
||||
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
|
||||
},
|
||||
[onFilterApplied],
|
||||
);
|
||||
|
||||
// Updated (single-select) is a definitive single choice, so it splices AND runs
|
||||
// immediately (with the fresh value, avoiding the async draft-state lag).
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
onFilterApplied?.('updatedWindow', {});
|
||||
const next = spliceClause(draft, 'updated_at', updatedClause(window));
|
||||
setDraft(next);
|
||||
commit(next);
|
||||
},
|
||||
[draft, commit, onFilterApplied],
|
||||
);
|
||||
|
||||
// Clear-all on the Created-by select removes its clause and runs immediately.
|
||||
const handleClearCreatedBy = useCallback((): void => {
|
||||
const next = spliceClause(draft, 'created_by', null);
|
||||
setDraft(next);
|
||||
commit(next);
|
||||
}, [draft, commit]);
|
||||
|
||||
// Clear resets the draft and runs immediately (empty query).
|
||||
const handleClear = useCallback((): void => {
|
||||
onCleared?.();
|
||||
setDraft('');
|
||||
if (query !== '') {
|
||||
onQueryChange('');
|
||||
}
|
||||
onClearAll?.();
|
||||
}, [query, onQueryChange, onCleared, onClearAll]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
reflected,
|
||||
dirty,
|
||||
isEmpty,
|
||||
run,
|
||||
handleCreatedByChange,
|
||||
handleUpdatedChange,
|
||||
handleClearCreatedBy,
|
||||
handleClear,
|
||||
};
|
||||
}
|
||||
@@ -165,8 +165,9 @@
|
||||
|
||||
.leadIcon {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -176,8 +177,10 @@
|
||||
|
||||
.submit {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
right: 6px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -15,18 +15,20 @@ import CodeMirror, {
|
||||
Prec,
|
||||
type ReactCodeMirrorRef,
|
||||
} from '@uiw/react-codemirror';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
import type { DslGrammar, SuggestionSource } from '../dsl/types';
|
||||
import { dslCompletionSource } from './dslCompletions';
|
||||
import { dslHighlight } from './dslHighlight';
|
||||
import { BASIC_SETUP, EMPTY_SOURCE, editorTheme } from './editorConfig';
|
||||
|
||||
import styles from './SearchBar.module.scss';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
grammar: DslGrammar;
|
||||
// Ids: `${testIdPrefix}`, `-submit`, `-dirty`.
|
||||
testIdPrefix: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
@@ -35,53 +37,20 @@ interface Props {
|
||||
dirty?: boolean;
|
||||
/** Filtering needs `list`: the draft edits a query that cannot be run. */
|
||||
disabled?: boolean;
|
||||
onSubmitted?: (hasQuery: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SOURCE: SuggestionSource = {
|
||||
tagKeys: [],
|
||||
tagValuesByKey: {},
|
||||
creatorEmails: [],
|
||||
};
|
||||
|
||||
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
|
||||
// handles layout — transparent background, monospace, single line.
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': { fontSize: '13px', backgroundColor: 'transparent' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
// Leave room for the overlaid lead icon (left) and Run button (right).
|
||||
padding: '8px 120px 8px 30px',
|
||||
fontFamily: "'Space Mono', monospace",
|
||||
color: 'var(--l1-foreground)',
|
||||
caretColor: 'var(--l1-foreground)',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
|
||||
'.cm-placeholder': { color: 'var(--l3-foreground)' },
|
||||
});
|
||||
|
||||
const BASIC_SETUP = {
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
|
||||
// styling and doesn't blink by default).
|
||||
drawSelection: false,
|
||||
} as const;
|
||||
|
||||
function SearchBar({
|
||||
value,
|
||||
grammar,
|
||||
testIdPrefix,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Filter with DSL (e.g. name CONTAINS 'foo')",
|
||||
source = EMPTY_SOURCE,
|
||||
dirty = false,
|
||||
disabled = false,
|
||||
onSubmitted,
|
||||
}: Props): JSX.Element {
|
||||
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
@@ -89,12 +58,12 @@ function SearchBar({
|
||||
// Refs so the (memoised, stable) extensions always see the latest values.
|
||||
const sourceRef = useRef(source);
|
||||
sourceRef.current = source;
|
||||
const grammarRef = useRef(grammar);
|
||||
grammarRef.current = grammar;
|
||||
const handleSubmit = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.SearchExecuted, {
|
||||
hasQuery: !!value.trim(),
|
||||
});
|
||||
onSubmitted?.(!!value.trim());
|
||||
onSubmit();
|
||||
}, [onSubmit, value]);
|
||||
}, [onSubmit, onSubmitted, value]);
|
||||
const onSubmitRef = useRef(handleSubmit);
|
||||
onSubmitRef.current = handleSubmit;
|
||||
|
||||
@@ -110,7 +79,12 @@ function SearchBar({
|
||||
? []
|
||||
: [
|
||||
autocompletion({
|
||||
override: [dslCompletionSource(() => sourceRef.current)],
|
||||
override: [
|
||||
dslCompletionSource(
|
||||
() => sourceRef.current,
|
||||
() => grammarRef.current,
|
||||
),
|
||||
],
|
||||
closeOnBlur: true,
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 100,
|
||||
@@ -168,7 +142,7 @@ function SearchBar({
|
||||
basicSetup={BASIC_SETUP}
|
||||
indentWithTab={false}
|
||||
editable={!disabled}
|
||||
data-testid="dashboards-list-search"
|
||||
data-testid={testIdPrefix}
|
||||
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
|
||||
/>
|
||||
<span
|
||||
@@ -182,13 +156,13 @@ function SearchBar({
|
||||
size="sm"
|
||||
aria-label="Run search"
|
||||
disabled={disabled}
|
||||
testId="dashboards-list-search-submit"
|
||||
testId={`${testIdPrefix}-submit`}
|
||||
onClick={handleSubmit}
|
||||
prefix={
|
||||
dirty ? (
|
||||
<span
|
||||
className={styles.dirtyDot}
|
||||
data-testid="dashboards-list-search-dirty"
|
||||
data-testid={`${testIdPrefix}-dirty`}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -7,18 +7,21 @@ import {
|
||||
} from '@codemirror/autocomplete';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
|
||||
import {
|
||||
getSuggestions,
|
||||
type SuggestionSource,
|
||||
} from '../../utils/dslSuggestions';
|
||||
import { getSuggestions } from '../dsl/dslSuggestions';
|
||||
import type { DslGrammar, SuggestionSource } from '../dsl/types';
|
||||
|
||||
// `getSource` is a getter so the freshest tags/users (which load async) are read
|
||||
// on every keystroke.
|
||||
// Getters so the freshest keys/users (which load async) are read on every
|
||||
// keystroke.
|
||||
export const dslCompletionSource =
|
||||
(getSource: () => SuggestionSource) =>
|
||||
(getSource: () => SuggestionSource, getGrammar: () => DslGrammar) =>
|
||||
(context: CompletionContext): CompletionResult | null => {
|
||||
const query = context.state.doc.toString();
|
||||
const { items, ctx } = getSuggestions(query, context.pos, getSource());
|
||||
const { items, ctx } = getSuggestions(
|
||||
query,
|
||||
context.pos,
|
||||
getSource(),
|
||||
getGrammar(),
|
||||
);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
|
||||
// CodeMirror syntax highlighting for the list-filter DSL, driven by the same
|
||||
// tokenizer used for autocomplete (dslTokenizer). Each key / operator / value /
|
||||
// AND-OR span gets a `cm-dsl-*` class coloured in SearchBar.module.scss.
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { scanTerm, splitTopLevelTerms } from '../../utils/dslTokenizer';
|
||||
import { scanTerm, splitTopLevelTerms } from '../dsl/dslTokenizer';
|
||||
|
||||
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
|
||||
const OP = Decoration.mark({ class: 'cm-dsl-op' });
|
||||
41
frontend/src/components/ListFilter/SearchBar/editorConfig.ts
Normal file
41
frontend/src/components/ListFilter/SearchBar/editorConfig.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { SuggestionSource } from '../dsl/types';
|
||||
|
||||
export const EMPTY_SOURCE: SuggestionSource = {
|
||||
dynamicKeys: [],
|
||||
valuesByKey: {},
|
||||
creatorEmails: [],
|
||||
};
|
||||
|
||||
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
|
||||
// handles layout — transparent background, monospace, single line.
|
||||
export const editorTheme = EditorView.theme({
|
||||
'&': { fontSize: '13px', backgroundColor: 'transparent' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
// Leave room for the overlaid lead icon (left) and Run button (right).
|
||||
padding: '8px 120px 8px 30px',
|
||||
fontFamily: "'Space Mono', monospace",
|
||||
color: 'var(--l1-foreground)',
|
||||
caretColor: 'var(--l1-foreground)',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
// Whole pixels: at 19.5px the placeholder line rounds 1px taller than text.
|
||||
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '20px' },
|
||||
'.cm-placeholder': { color: 'var(--l3-foreground)' },
|
||||
});
|
||||
|
||||
export const BASIC_SETUP = {
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
|
||||
// styling and doesn't blink by default).
|
||||
drawSelection: false,
|
||||
} as const;
|
||||
@@ -1,36 +1,21 @@
|
||||
// Frontend mirror of the backend dashboards-list filter DSL grammar
|
||||
// (pkg/types/dashboardtypes/list_filter.go). There is no keys/operators API, so
|
||||
// the valid keys, per-field operators, and value shapes are encoded here. Keep in
|
||||
// sync with the backend allow-lists.
|
||||
// Grammar-independent operator tables for the list-filter DSL. Key sets and
|
||||
// field types come from a DslGrammar config mirroring the backend allow-list
|
||||
// (e.g. pkg/types/dashboardtypes/list_filter.go); keep those configs in sync.
|
||||
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
|
||||
|
||||
// Reserved keys the backend recognises as dashboard columns. Any other key is a
|
||||
// tag key. (`updated_by` and `source` are NOT queryable.)
|
||||
export const RESERVED_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
'created_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'locked',
|
||||
] as const;
|
||||
import type { DslField, DslFieldType, DslGrammar } from './types';
|
||||
|
||||
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
|
||||
export const findField = (
|
||||
grammar: DslGrammar,
|
||||
key: string,
|
||||
): DslField | undefined =>
|
||||
grammar.fields.find((f) => f.key === key.toLowerCase());
|
||||
|
||||
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
|
||||
name: 'string',
|
||||
description: 'string',
|
||||
created_by: 'string',
|
||||
created_at: 'timestamp',
|
||||
updated_at: 'timestamp',
|
||||
locked: 'bool',
|
||||
};
|
||||
// A reserved key maps to its column type; anything else is dynamic.
|
||||
export const classifyField = (grammar: DslGrammar, key: string): DslFieldType =>
|
||||
findField(grammar, key)?.type ?? 'dynamic';
|
||||
|
||||
// A reserved key maps to its column type; anything else is a tag.
|
||||
export const classifyField = (key: string): DslFieldType =>
|
||||
RESERVED_FIELD_TYPES[key.toLowerCase()] ?? 'tag';
|
||||
|
||||
// String/tag comparison operators, reusing the shared operator constants
|
||||
// String/dynamic comparison operators, reusing the shared operator constants
|
||||
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
|
||||
const STRING_OPS = [
|
||||
OPERATORS['='],
|
||||
@@ -45,10 +30,10 @@ const STRING_OPS = [
|
||||
negateOperator(OPERATORS.IN),
|
||||
];
|
||||
|
||||
// Valid operators per field type. Tags additionally support existence checks.
|
||||
// Valid operators per field type. Dynamic keys additionally support existence checks.
|
||||
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
|
||||
string: STRING_OPS,
|
||||
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
|
||||
dynamic: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
|
||||
timestamp: [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
@@ -60,6 +45,12 @@ export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
|
||||
negateOperator(OPERATORS.BETWEEN),
|
||||
],
|
||||
bool: [OPERATORS['='], OPERATORS['!=']],
|
||||
enum: [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
OPERATORS.IN,
|
||||
negateOperator(OPERATORS.IN),
|
||||
],
|
||||
};
|
||||
|
||||
// Operators that take no value (the clause is complete after the operator).
|
||||
@@ -1,16 +1,23 @@
|
||||
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
|
||||
import { DASHBOARDS_DSL_GRAMMAR } from 'pages/DashboardsListPage/list.config';
|
||||
|
||||
import { getSuggestions } from './dslSuggestions';
|
||||
import type { SuggestionSource } from './types';
|
||||
|
||||
const source: SuggestionSource = {
|
||||
tagKeys: ['env', 'team'],
|
||||
tagValuesByKey: { env: ['prod', 'dev'] },
|
||||
dynamicKeys: ['env', 'team'],
|
||||
valuesByKey: { env: ['prod', 'dev'] },
|
||||
creatorEmails: ['alice@x.io', 'bob@x.io'],
|
||||
currentUserEmail: 'me@x.io',
|
||||
};
|
||||
|
||||
const labels = (q: string, caret: number): string[] =>
|
||||
getSuggestions(q, caret, source).items.map((i) => i.label);
|
||||
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
|
||||
(i) => i.label,
|
||||
);
|
||||
const inserts = (q: string, caret: number): string[] =>
|
||||
getSuggestions(q, caret, source).items.map((i) => i.insertText);
|
||||
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
|
||||
(i) => i.insertText,
|
||||
);
|
||||
|
||||
describe('getSuggestions — key stage', () => {
|
||||
it('suggests reserved + tag keys matching the partial', () => {
|
||||
@@ -61,7 +68,12 @@ describe('getSuggestions — operator stage', () => {
|
||||
|
||||
describe('getSuggestions — value stage', () => {
|
||||
it('suggests creator emails (current user first, labelled) for created_by', () => {
|
||||
const out = getSuggestions('created_by = ', 13, source);
|
||||
const out = getSuggestions(
|
||||
'created_by = ',
|
||||
13,
|
||||
source,
|
||||
DASHBOARDS_DSL_GRAMMAR,
|
||||
);
|
||||
expect(out.items[0].label).toBe('me@x.io (me)');
|
||||
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
|
||||
});
|
||||
@@ -86,7 +98,7 @@ describe('getSuggestions — value stage', () => {
|
||||
describe('getSuggestions — IN list values', () => {
|
||||
it('wraps a fresh IN value in a bracketed list, caret before the close', () => {
|
||||
const q = 'created_by IN ';
|
||||
const out = getSuggestions(q, q.length, source);
|
||||
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
|
||||
const alice = out.items.find((i) => i.label === 'alice@x.io');
|
||||
expect(alice?.insertText).toBe("['alice@x.io']");
|
||||
expect(alice?.caretOffset).toBe(1);
|
||||
@@ -100,7 +112,7 @@ describe('getSuggestions — IN list values', () => {
|
||||
it('appends to an open list and excludes already-entered values', () => {
|
||||
const q = "env IN ['prod', ]";
|
||||
const caret = q.indexOf(']'); // inside the brackets, after "'prod', "
|
||||
const out = getSuggestions(q, caret, source);
|
||||
const out = getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR);
|
||||
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
|
||||
const dev = out.items[0];
|
||||
// No closing bracket (the existing `]` stays); caret lands at the end.
|
||||
@@ -110,7 +122,7 @@ describe('getSuggestions — IN list values', () => {
|
||||
|
||||
it('treats a complete last literal (no trailing comma) as entered', () => {
|
||||
const q = "env IN ['prod'";
|
||||
const out = getSuggestions(q, q.length, source);
|
||||
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
|
||||
// 'prod' is committed; only 'dev' remains and it appends after it.
|
||||
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
|
||||
expect(out.items[0].insertText).toBe("['prod', 'dev'");
|
||||
@@ -1,48 +1,29 @@
|
||||
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
|
||||
// Stage-aware autocomplete for the list-filter DSL: as the caret moves, it
|
||||
// suggests keys, then operators (valid for that key's field type), then values
|
||||
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
|
||||
// caret/stage detection in dslTokenizer.ts.
|
||||
// (drawn from the dynamic keys/users the API reports). Operator tables live in
|
||||
// dslGrammar.ts; caret/stage detection in dslTokenizer.ts.
|
||||
import {
|
||||
classifyField,
|
||||
findField,
|
||||
LIST_OPERATORS,
|
||||
literal,
|
||||
OPERATOR_MATRIX,
|
||||
RESERVED_KEYS,
|
||||
VALUELESS_OPERATORS,
|
||||
} from './dslGrammar';
|
||||
import { type CaretContext, getCaretContext } from './dslTokenizer';
|
||||
import type {
|
||||
DslGrammar,
|
||||
Suggestion,
|
||||
SuggestionSource,
|
||||
SuggestionsResult,
|
||||
} from './types';
|
||||
|
||||
// Show the full key set (the popup scrolls); values are capped a bit higher too.
|
||||
const KEY_LIMIT = 100;
|
||||
const VALUE_LIMIT = 50;
|
||||
|
||||
export interface SuggestionSource {
|
||||
// Reserved column keys (from the list response, falling back to RESERVED_KEYS).
|
||||
reservedKeys?: string[];
|
||||
tagKeys: string[];
|
||||
// Known values per tag key (lower-cased key → values).
|
||||
tagValuesByKey: Record<string, string[]>;
|
||||
// Creator emails for `created_by` value suggestions.
|
||||
creatorEmails: string[];
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: 'key' | 'operator' | 'value' | 'connector';
|
||||
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
|
||||
// are distinguishable from org tag keys.
|
||||
detail?: string;
|
||||
// Chars to move the caret back from the end of `insertText` after applying.
|
||||
// Used to land the caret inside a `[...]` list so multi-select can continue.
|
||||
caretOffset?: number;
|
||||
}
|
||||
|
||||
export interface SuggestionsResult {
|
||||
items: Suggestion[];
|
||||
ctx: CaretContext;
|
||||
}
|
||||
// Reserved keys whose values are user emails.
|
||||
const USER_KEYS = new Set(['created_by', 'updated_by']);
|
||||
|
||||
const includesInsensitive = (haystack: string, needle: string): boolean =>
|
||||
haystack.toLowerCase().includes(needle.toLowerCase());
|
||||
@@ -52,17 +33,20 @@ const dedupe = (values: string[]): string[] => Array.from(new Set(values));
|
||||
const keySuggestions = (
|
||||
partial: string,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): Suggestion[] => {
|
||||
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
|
||||
const hidden = new Set(grammar.hiddenKeys ?? []);
|
||||
const known = grammar.fields.map((f) => f.key);
|
||||
const reserved = (source.reservedKeys ?? known).filter((k) => !hidden.has(k));
|
||||
const reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
|
||||
// Show reserved keys in our defined (purpose) order — name, description, … —
|
||||
// rather than the API's alphabetical order; any extra API keys follow, then tags.
|
||||
const known = RESERVED_KEYS as readonly string[];
|
||||
// Show reserved keys in the grammar's (purpose) order — name, description, … —
|
||||
// rather than the API's alphabetical order; any extra API keys follow, then
|
||||
// dynamic keys.
|
||||
const orderedReserved = [
|
||||
...known.filter((k) => reserved.includes(k)),
|
||||
...reserved.filter((k) => !known.includes(k)),
|
||||
];
|
||||
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
|
||||
const keys = dedupe([...orderedReserved, ...source.dynamicKeys]);
|
||||
const lower = partial.toLowerCase();
|
||||
return keys
|
||||
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
|
||||
@@ -71,16 +55,19 @@ const keySuggestions = (
|
||||
label: k,
|
||||
insertText: `${k} `,
|
||||
kind: 'key',
|
||||
// Distinguish built-in columns from org tag keys in the popup.
|
||||
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
|
||||
// Distinguish built-in columns from dynamic keys in the popup.
|
||||
detail: reservedSet.has(k.toLowerCase())
|
||||
? 'field'
|
||||
: grammar.dynamicKey.detail,
|
||||
}));
|
||||
};
|
||||
|
||||
const operatorSuggestions = (
|
||||
fieldKey: string,
|
||||
partial: string,
|
||||
grammar: DslGrammar,
|
||||
): Suggestion[] => {
|
||||
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
|
||||
const ops = OPERATOR_MATRIX[classifyField(grammar, fieldKey)];
|
||||
const upper = partial.toUpperCase();
|
||||
return ops
|
||||
.filter((op) => op.startsWith(upper))
|
||||
@@ -155,15 +142,39 @@ const parseListPartial = (partial: string): ListPartial => {
|
||||
};
|
||||
};
|
||||
|
||||
// Known values for a key: user emails, enum values, or the dynamic key's values
|
||||
// (a reserved dynamic key falls back to its prefixed alias, e.g. `labels.severity`).
|
||||
const knownValues = (
|
||||
key: string,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): string[] | null => {
|
||||
const type = classifyField(grammar, key);
|
||||
if (USER_KEYS.has(key) && type === 'string') {
|
||||
return source.currentUserEmail
|
||||
? dedupe([source.currentUserEmail, ...source.creatorEmails])
|
||||
: source.creatorEmails;
|
||||
}
|
||||
if (type === 'enum') {
|
||||
return [...(findField(grammar, key)?.values ?? [])];
|
||||
}
|
||||
if (type === 'dynamic') {
|
||||
const alias = `${grammar.dynamicKey.prefix}${key}`.toLowerCase();
|
||||
return source.valuesByKey[key] ?? source.valuesByKey[alias] ?? [];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const valueSuggestions = (
|
||||
ctx: CaretContext,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): Suggestion[] => {
|
||||
if (VALUELESS_OPERATORS.has(ctx.operator)) {
|
||||
return [];
|
||||
}
|
||||
const key = ctx.fieldKey.toLowerCase();
|
||||
const type = classifyField(ctx.fieldKey);
|
||||
const type = classifyField(grammar, ctx.fieldKey);
|
||||
|
||||
// List operators (`IN`/`NOT IN`) take a `[...]` list: parse what's already in
|
||||
// the brackets so we exclude entered values and append rather than replace.
|
||||
@@ -180,15 +191,8 @@ const valueSuggestions = (
|
||||
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
|
||||
}
|
||||
|
||||
let raw: string[] = [];
|
||||
if (key === 'created_by') {
|
||||
const emails = source.currentUserEmail
|
||||
? dedupe([source.currentUserEmail, ...source.creatorEmails])
|
||||
: source.creatorEmails;
|
||||
raw = emails;
|
||||
} else if (type === 'tag') {
|
||||
raw = source.tagValuesByKey[key] ?? [];
|
||||
} else {
|
||||
const raw = knownValues(key, source, grammar);
|
||||
if (raw === null) {
|
||||
// name / description / timestamps have no known value set — free text.
|
||||
return [];
|
||||
}
|
||||
@@ -199,7 +203,7 @@ const valueSuggestions = (
|
||||
.slice(0, VALUE_LIMIT)
|
||||
.map((v) => {
|
||||
const label =
|
||||
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
|
||||
v === source.currentUserEmail && USER_KEYS.has(key) ? `${v} (me)` : v;
|
||||
if (isList) {
|
||||
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
|
||||
return {
|
||||
@@ -239,15 +243,16 @@ export const getSuggestions = (
|
||||
query: string,
|
||||
caret: number,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): SuggestionsResult => {
|
||||
const ctx = getCaretContext(query, caret);
|
||||
let items: Suggestion[] = [];
|
||||
if (ctx.stage === 'key') {
|
||||
items = keySuggestions(ctx.partial, source);
|
||||
items = keySuggestions(ctx.partial, source, grammar);
|
||||
} else if (ctx.stage === 'operator') {
|
||||
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
|
||||
items = operatorSuggestions(ctx.fieldKey, ctx.partial, grammar);
|
||||
} else if (ctx.stage === 'value') {
|
||||
items = valueSuggestions(ctx, source);
|
||||
items = valueSuggestions(ctx, source, grammar);
|
||||
} else if (ctx.stage === 'connector') {
|
||||
items = connectorSuggestions(ctx.partial);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
|
||||
// Quote/paren/bracket-aware scanning of the list-filter DSL, shared by
|
||||
// the autocomplete engine (dslSuggestions) and the clause splicer (filterQuery).
|
||||
// The DSL is a boolean composition of `key OP value` terms joined by AND/OR.
|
||||
import {
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pure, side-effect-free helpers over the backend list-filter DSL string
|
||||
// (`GET /api/v2/dashboards?query=...`). The query string is the single source of
|
||||
// (e.g. `GET /api/v2/dashboards?query=...`). The query string is the single source of
|
||||
// truth; these build/parse/splice clauses for it. Free of React so the same
|
||||
// logic backs live filtering, saved views, and built-in views.
|
||||
//
|
||||
@@ -7,11 +7,11 @@
|
||||
// name CONTAINS 'text'
|
||||
// created_by = 'a@b.com' | created_by IN ['a@b.com', 'c@d.com']
|
||||
// updated_at >= '2026-06-01T00:00:00.000Z' (RFC3339)
|
||||
// env = 'prod' (any non-reserved key is a tag filter)
|
||||
// env = 'prod' (any non-reserved key is a dynamic filter, e.g. a tag)
|
||||
// locked = true
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { UpdatedWindow } from '../types';
|
||||
import type { ClauseValue, UpdatedWindow } from './types';
|
||||
import { literal } from './dslGrammar';
|
||||
import { scanTerm, splitTopLevelTerms } from './dslTokenizer';
|
||||
|
||||
@@ -37,6 +37,16 @@ export const isQueryEmpty = (query: string): boolean => !query.trim();
|
||||
export const areQueriesEqual = (a: string, b: string): boolean =>
|
||||
a.trim() === b.trim();
|
||||
|
||||
export const clause = (key: string, op: string, value: ClauseValue): string => {
|
||||
if (typeof value === 'boolean') {
|
||||
return `${key} ${op} ${value}`;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return `${key} ${op} ${literal(value)}`;
|
||||
}
|
||||
return `${key} ${op} [${value.map(literal).join(', ')}]`;
|
||||
};
|
||||
|
||||
// The `created_by` clause for a set of emails (single `=`, multiple `IN [...]`).
|
||||
export const createdByClause = (emails: string[]): string | null => {
|
||||
if (emails.length === 0) {
|
||||
@@ -0,0 +1,69 @@
|
||||
import { RULES_DSL_GRAMMAR } from 'container/ListAlertRules/list.config';
|
||||
|
||||
import { getSuggestions } from './dslSuggestions';
|
||||
import type { SuggestionSource } from './types';
|
||||
|
||||
const source: SuggestionSource = {
|
||||
reservedKeys: [
|
||||
'alert_type',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'labels.<key>',
|
||||
'name',
|
||||
'rule_type',
|
||||
'severity',
|
||||
'updated_at',
|
||||
'updated_by',
|
||||
],
|
||||
dynamicKeys: ['labels.team', 'labels.env'],
|
||||
valuesByKey: { 'labels.team': ['infra'], 'labels.severity': ['critical'] },
|
||||
creatorEmails: ['alice@x.io'],
|
||||
currentUserEmail: 'me@x.io',
|
||||
};
|
||||
|
||||
const suggest = (q: string): ReturnType<typeof getSuggestions>['items'] =>
|
||||
getSuggestions(q, q.length, source, RULES_DSL_GRAMMAR).items;
|
||||
const labels = (q: string): string[] => suggest(q).map((i) => i.label);
|
||||
|
||||
describe('getSuggestions — rules grammar', () => {
|
||||
it('orders reserved keys, hides labels.<key> and appends label keys', () => {
|
||||
expect(labels('')).toStrictEqual([
|
||||
'name',
|
||||
'severity',
|
||||
'alert_type',
|
||||
'rule_type',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'labels.team',
|
||||
'labels.env',
|
||||
]);
|
||||
expect(suggest('labels.t').map((i) => i.detail)).toStrictEqual(['label']);
|
||||
});
|
||||
|
||||
it('restricts enum keys to = != IN NOT IN and suggests their values', () => {
|
||||
expect(labels('rule_type ')).toStrictEqual([
|
||||
'rule_type =',
|
||||
'rule_type !=',
|
||||
'rule_type IN',
|
||||
'rule_type NOT IN',
|
||||
]);
|
||||
expect(labels('rule_type = ')).toStrictEqual([
|
||||
'threshold_rule',
|
||||
'promql_rule',
|
||||
'anomaly_rule',
|
||||
]);
|
||||
expect(labels("alert_type = 'LOGS")).toStrictEqual(['LOGS_BASED_ALERT']);
|
||||
});
|
||||
|
||||
it('gives severity and labels.* the label operators and values', () => {
|
||||
expect(labels('severity ')).toContain('severity EXISTS');
|
||||
expect(labels('severity = ')).toStrictEqual(['critical']);
|
||||
expect(labels('labels.team = ')).toStrictEqual(['infra']);
|
||||
});
|
||||
|
||||
it('suggests user emails for updated_by', () => {
|
||||
expect(labels('updated_by = ')).toStrictEqual(['me@x.io (me)', 'alice@x.io']);
|
||||
});
|
||||
});
|
||||
61
frontend/src/components/ListFilter/dsl/types.ts
Normal file
61
frontend/src/components/ListFilter/dsl/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { CaretContext } from './dslTokenizer';
|
||||
|
||||
// `dynamic` keys (tags, labels) take string ops plus EXISTS / NOT EXISTS.
|
||||
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'enum' | 'dynamic';
|
||||
|
||||
export interface DslField {
|
||||
key: string;
|
||||
type: DslFieldType;
|
||||
values?: readonly string[];
|
||||
}
|
||||
|
||||
// A list-filter DSL grammar, mirroring a backend `list_filter.go` allow-list.
|
||||
export interface DslGrammar {
|
||||
// Reserved fields in suggestion order. Any key not listed here is dynamic.
|
||||
fields: readonly DslField[];
|
||||
// `detail` labels dynamic keys in the popup. A reserved dynamic key (e.g.
|
||||
// rules' `severity`) aliases `${prefix}${key}` for value lookups.
|
||||
dynamicKey: { detail: string; prefix: string };
|
||||
// Advertised by the API but not filterable (e.g. `labels.<key>`).
|
||||
hiddenKeys?: readonly string[];
|
||||
}
|
||||
|
||||
export type ClauseValue = string | boolean | readonly string[];
|
||||
|
||||
export type ClauseBuilder<K extends string> = (
|
||||
key: K,
|
||||
op: string,
|
||||
value: ClauseValue,
|
||||
) => string;
|
||||
|
||||
export interface SuggestionSource {
|
||||
// Reserved keys from the list response, falling back to the grammar's.
|
||||
reservedKeys?: string[];
|
||||
// Dynamic keys exactly as typed (e.g. `team`, `labels.team`).
|
||||
dynamicKeys: string[];
|
||||
// Known values per dynamic key (lower-cased full key -> values).
|
||||
valuesByKey: Record<string, string[]>;
|
||||
// Values for `created_by` / `updated_by`.
|
||||
creatorEmails: string[];
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: 'key' | 'operator' | 'value' | 'connector';
|
||||
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
|
||||
// are distinguishable from dynamic keys.
|
||||
detail?: string;
|
||||
// Chars to move the caret back from the end of `insertText` after applying.
|
||||
// Used to land the caret inside a `[...]` list so multi-select can continue.
|
||||
caretOffset?: number;
|
||||
}
|
||||
|
||||
export interface SuggestionsResult {
|
||||
items: Suggestion[];
|
||||
ctx: CaretContext;
|
||||
}
|
||||
|
||||
// Relative "updated within" windows offered by the Updated filter dropdown.
|
||||
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
|
||||
9
frontend/src/components/ListFilter/types.ts
Normal file
9
frontend/src/components/ListFilter/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface CreatorOption {
|
||||
email: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type FilterAppliedType = 'createdBy' | 'updatedWindow';
|
||||
|
||||
// Analytics payload for a chip change (`valueCount` for Created by).
|
||||
export type FilterAppliedPayload = Record<string, unknown>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
|
||||
import type { CreatorOption } from '../components/FilterZone/FilterChips';
|
||||
import type { CreatorOption } from './types';
|
||||
|
||||
interface Args {
|
||||
currentUserEmail: string;
|
||||
@@ -0,0 +1,46 @@
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,33 +1,40 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
import styles from './ListCommandHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
isModified: boolean;
|
||||
onCreate: () => void;
|
||||
action?: ReactNode;
|
||||
// Ids: `${testIdPrefix}-list-heading`.
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
function CommandHeader({
|
||||
function ListCommandHeader({
|
||||
label,
|
||||
count,
|
||||
isModified,
|
||||
onCreate,
|
||||
action,
|
||||
testIdPrefix,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
<Typography.Title
|
||||
className={styles.title}
|
||||
testId={testIdPrefix && `${testIdPrefix}-list-heading`}
|
||||
>
|
||||
{label}
|
||||
</Typography.Title>
|
||||
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
<NewDashboardButton onClick={onCreate} />
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandHeader;
|
||||
export default ListCommandHeader;
|
||||
@@ -2,20 +2,25 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PanelLeftClose, PanelLeftOpen } from '@signozhq/icons';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
import styles from './ListStatusBar.module.scss';
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
count: number;
|
||||
total: number;
|
||||
/** Plural noun for the counted items, e.g. "dashboards". */
|
||||
noun: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function StatusBar({
|
||||
function ListStatusBar({
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
count,
|
||||
total,
|
||||
noun,
|
||||
testId,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
@@ -27,15 +32,15 @@ function StatusBar({
|
||||
collapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />
|
||||
}
|
||||
onClick={onToggleCollapse}
|
||||
testId="dashboards-rail-toggle"
|
||||
testId={testId}
|
||||
>
|
||||
{collapsed ? 'Expand' : 'Collapse'}
|
||||
</Button>
|
||||
<Typography.Text className={styles.count}>
|
||||
{count} of {total} dashboards
|
||||
{count} of {total} {noun}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBar;
|
||||
export default ListStatusBar;
|
||||
@@ -9,10 +9,6 @@
|
||||
transition: width 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
.denied {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
width: 0;
|
||||
border-right-color: transparent;
|
||||
@@ -195,30 +191,3 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.savePopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.saveTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.saveLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.saveActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
264
frontend/src/components/ListViews/ViewsRail/ViewsRail.tsx
Normal file
264
frontend/src/components/ListViews/ViewsRail/ViewsRail.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, Plus, Search } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
|
||||
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
|
||||
|
||||
import type { BuiltinListView, ViewSaveMode } from '../types';
|
||||
import ViewsRailDirtyPanel from './ViewsRailDirtyPanel';
|
||||
import ViewsRailItem from './ViewsRailItem';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
activeViewId: string;
|
||||
builtinViews: readonly BuiltinListView[];
|
||||
customViews: { id: string; name: string }[];
|
||||
customViewsLoading: boolean;
|
||||
isCustomActive: boolean;
|
||||
isModified: boolean;
|
||||
collapsed?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSave: (name: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onRename: (id: string, name: string) => void;
|
||||
/** Prefixes the rail's test ids, e.g. `<prefix>-view-<id>`. */
|
||||
testIdPrefix: string;
|
||||
saveTestIdPrefix?: string;
|
||||
renameTestIdPrefix?: string;
|
||||
customSectionLabel?: string;
|
||||
deleteDescription?: string;
|
||||
/** Wraps the rail body, e.g. in an authz guard. */
|
||||
guard?: (content: ReactElement) => ReactElement;
|
||||
onViewSaved?: (mode: ViewSaveMode) => void;
|
||||
onViewRenamed?: () => void;
|
||||
onViewDeleted?: () => void;
|
||||
}
|
||||
|
||||
// Purely presentational — active view, dirty state, and handlers come from
|
||||
// `useListViews`.
|
||||
function ViewsRail({
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
customViewsLoading,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
collapsed = false,
|
||||
onSelect,
|
||||
onSave,
|
||||
onSaveChanges,
|
||||
onReset,
|
||||
onDelete,
|
||||
onRename,
|
||||
testIdPrefix,
|
||||
saveTestIdPrefix = 'save-view',
|
||||
renameTestIdPrefix = 'rename-view',
|
||||
customSectionLabel = 'My views',
|
||||
deleteDescription = 'This removes the saved view.',
|
||||
guard,
|
||||
onViewSaved,
|
||||
onViewRenamed,
|
||||
onViewDeleted,
|
||||
}: Props): JSX.Element {
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQuery = (label: string): boolean =>
|
||||
!q || label.toLowerCase().includes(q);
|
||||
|
||||
const personal = builtinViews.filter(
|
||||
(v) => v.section === 'personal' && matchesQuery(v.label),
|
||||
);
|
||||
const system = builtinViews.filter(
|
||||
(v) => v.section === 'system' && matchesQuery(v.label),
|
||||
);
|
||||
const custom = customViews.filter((v) => matchesQuery(v.name));
|
||||
const noMatches =
|
||||
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
|
||||
|
||||
const onConfirmDelete = useCallback(
|
||||
(id: string, label: string): void => {
|
||||
confirmDelete({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the{' '}
|
||||
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
|
||||
view?
|
||||
</Typography.Title>
|
||||
),
|
||||
content: deleteDescription,
|
||||
// Return the delete promise so the modal's Delete button stays loading
|
||||
// until the backend call settles, then closes — matching dashboard delete.
|
||||
onConfirm: (): Promise<void> => {
|
||||
onViewDeleted?.();
|
||||
return onDelete(id);
|
||||
},
|
||||
});
|
||||
},
|
||||
[confirmDelete, deleteDescription, onDelete, onViewDeleted],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(id: string, name: string): void => {
|
||||
onViewRenamed?.();
|
||||
onRename(id, name);
|
||||
},
|
||||
[onRename, onViewRenamed],
|
||||
);
|
||||
|
||||
const handleSaveAsView = (name: string): void => {
|
||||
onViewSaved?.('new');
|
||||
onSave(name);
|
||||
};
|
||||
|
||||
const handleSaveViewChanges = (): void => {
|
||||
onViewSaved?.('update');
|
||||
onSaveChanges();
|
||||
};
|
||||
|
||||
const renderBuiltin = (v: BuiltinListView): JSX.Element => (
|
||||
<ViewsRailItem
|
||||
key={v.id}
|
||||
id={v.id}
|
||||
label={v.label}
|
||||
icon={v.icon}
|
||||
active={v.id === activeViewId}
|
||||
isModified={isModified}
|
||||
testIdPrefix={testIdPrefix}
|
||||
renameTestIdPrefix={renameTestIdPrefix}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<h4 className={styles.headerTitle}>Views</h4>
|
||||
<ViewNamePopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSubmit={handleSaveAsView}
|
||||
title="Save as view"
|
||||
confirmLabel="Save view"
|
||||
testIdPrefix={saveTestIdPrefix}
|
||||
tooltip="Save current filters as a view"
|
||||
trigger={
|
||||
<Button
|
||||
aria-label="Save current filters as a view"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
testId={`${testIdPrefix}-view-save-trigger`}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Filter views by name"
|
||||
prefix={<Search size={12} />}
|
||||
testId={`${testIdPrefix}-view-search`}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setQuery(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
{personal.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Personal</div>
|
||||
{personal.map(renderBuiltin)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{system.length > 0 && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
System
|
||||
</div>
|
||||
{system.map(renderBuiltin)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!q || custom.length > 0) && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
{customSectionLabel}
|
||||
<Typography.Text className={styles.groupCount}>
|
||||
{customViews.length}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{customViewsLoading ? (
|
||||
<div className={styles.empty}>Loading views…</div>
|
||||
) : customViews.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No saved views yet. Filter the list, then save it as a view.
|
||||
</div>
|
||||
) : (
|
||||
custom.map((v) => (
|
||||
<ViewsRailItem
|
||||
key={v.id}
|
||||
id={v.id}
|
||||
label={v.name}
|
||||
icon={Bookmark}
|
||||
active={v.id === activeViewId}
|
||||
isModified={isModified}
|
||||
testIdPrefix={testIdPrefix}
|
||||
renameTestIdPrefix={renameTestIdPrefix}
|
||||
onSelect={onSelect}
|
||||
onRename={handleRename}
|
||||
onDelete={onConfirmDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{noMatches && (
|
||||
<div className={styles.searchEmpty}>
|
||||
No views match “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModified && (
|
||||
<ViewsRailDirtyPanel
|
||||
isCustomActive={isCustomActive}
|
||||
testIdPrefix={testIdPrefix}
|
||||
onSaveChanges={handleSaveViewChanges}
|
||||
onSaveAs={(): void => setSaveOpen(true)}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)}
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
|
||||
{guard ? guard(content) : content}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRail;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
isCustomActive: boolean;
|
||||
testIdPrefix: string;
|
||||
onSaveChanges: () => void;
|
||||
onSaveAs: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function ViewsRailDirtyPanel({
|
||||
isCustomActive,
|
||||
testIdPrefix,
|
||||
onSaveChanges,
|
||||
onSaveAs,
|
||||
onReset,
|
||||
}: Props): JSX.Element {
|
||||
if (isCustomActive) {
|
||||
return (
|
||||
<div className={styles.dirtyPanel}>
|
||||
<div className={styles.dirtyTitle}>Unsaved changes</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={onSaveChanges}
|
||||
testId={`${testIdPrefix}-view-save-changes`}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onSaveAs}
|
||||
testId={`${testIdPrefix}-view-save-as`}
|
||||
>
|
||||
Save as…
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
testId={`${testIdPrefix}-view-reset`}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
|
||||
<div className={styles.dirtyTitle}>Filters active</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={onSaveAs}
|
||||
testId={`${testIdPrefix}-view-save-as-new`}
|
||||
>
|
||||
Save as new view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
testId={`${testIdPrefix}-view-reset`}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRailDirtyPanel;
|
||||
105
frontend/src/components/ListViews/ViewsRail/ViewsRailItem.tsx
Normal file
105
frontend/src/components/ListViews/ViewsRail/ViewsRailItem.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
|
||||
|
||||
import type { ListViewIcon } from '../types';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ListViewIcon;
|
||||
active: boolean;
|
||||
isModified: boolean;
|
||||
testIdPrefix: string;
|
||||
renameTestIdPrefix: string;
|
||||
onSelect: (id: string) => void;
|
||||
/** Present only for custom (editable) views. */
|
||||
onRename?: (id: string, name: string) => void;
|
||||
onDelete?: (id: string, label: string) => void;
|
||||
}
|
||||
|
||||
function ViewsRailItem({
|
||||
id,
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
isModified,
|
||||
testIdPrefix,
|
||||
renameTestIdPrefix,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.row, { [styles.rowActive]: active })}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
width="100%"
|
||||
onClick={(): void => onSelect(id)}
|
||||
testId={`${testIdPrefix}-view-${id}`}
|
||||
prefix={<Icon size={16} className={styles.itemIcon} />}
|
||||
suffix={
|
||||
active && isModified ? (
|
||||
<div className={styles.dirtyDot} title="Unsaved changes" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{onRename && onDelete && (
|
||||
<div className={styles.itemActions}>
|
||||
<ViewNamePopover
|
||||
open={renaming}
|
||||
onOpenChange={setRenaming}
|
||||
onSubmit={(name): void => onRename(id, name)}
|
||||
title="Rename view"
|
||||
confirmLabel="Rename"
|
||||
initialName={label}
|
||||
testIdPrefix={renameTestIdPrefix}
|
||||
tooltip="Rename view"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Rename view"
|
||||
testId={`${testIdPrefix}-view-rename-${id}`}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<PenLine size={12} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Tooltip title="Delete view">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Delete view"
|
||||
testId={`${testIdPrefix}-view-delete-${id}`}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onDelete(id, label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRailItem;
|
||||
@@ -4,21 +4,21 @@ import { render, screen } from 'tests/test-utils';
|
||||
import { setupAuthzAllow } from 'lib/authz/utils/authz-test-utils';
|
||||
import { DashboardListPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import type { BuiltinView } from '../../../utils/views';
|
||||
import ViewsRailGuard from 'pages/DashboardsListPage/components/DashboardsList/ViewsRailGuard';
|
||||
|
||||
import type { BuiltinListView } from '../../types';
|
||||
import ViewsRail from '../ViewsRail';
|
||||
|
||||
const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
const BUILTIN_VIEWS: BuiltinListView[] = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All dashboards',
|
||||
icon: Bookmark,
|
||||
section: 'system',
|
||||
} as BuiltinView,
|
||||
},
|
||||
];
|
||||
|
||||
const CUSTOM_VIEWS = [
|
||||
{ id: 'view-1', name: 'My prod dashboards', query: '', userEmail: 'a@b.co' },
|
||||
];
|
||||
const CUSTOM_VIEWS = [{ id: 'view-1', name: 'My prod dashboards' }];
|
||||
|
||||
function renderRail(
|
||||
props?: Partial<React.ComponentProps<typeof ViewsRail>>,
|
||||
@@ -27,7 +27,7 @@ function renderRail(
|
||||
<ViewsRail
|
||||
activeViewId="view-1"
|
||||
builtinViews={BUILTIN_VIEWS}
|
||||
customViews={CUSTOM_VIEWS as never}
|
||||
customViews={CUSTOM_VIEWS}
|
||||
customViewsLoading={false}
|
||||
isCustomActive
|
||||
isModified
|
||||
@@ -37,6 +37,8 @@ function renderRail(
|
||||
onReset={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
onRename={jest.fn()}
|
||||
testIdPrefix="dashboards"
|
||||
guard={(content): JSX.Element => <ViewsRailGuard>{content}</ViewsRailGuard>}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Bookmark, Pin } from '@signozhq/icons';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import type { BuiltinListView } from '../../types';
|
||||
import ViewsRail from '../ViewsRail';
|
||||
|
||||
const PREFIX = 'things';
|
||||
|
||||
const BUILTIN_VIEWS: BuiltinListView[] = [
|
||||
{ id: 'mine', label: 'My things', icon: Pin, section: 'personal' },
|
||||
{ id: 'all', label: 'All things', icon: Bookmark, section: 'system' },
|
||||
];
|
||||
|
||||
const CUSTOM_VIEWS = [
|
||||
{ id: 'view-1', name: 'Prod things' },
|
||||
{ id: 'view-2', name: 'Staging things' },
|
||||
];
|
||||
|
||||
type Props = React.ComponentProps<typeof ViewsRail>;
|
||||
|
||||
function renderRail(props?: Partial<Props>): Props {
|
||||
const allProps: Props = {
|
||||
activeViewId: 'all',
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
customViews: CUSTOM_VIEWS,
|
||||
customViewsLoading: false,
|
||||
isCustomActive: false,
|
||||
isModified: false,
|
||||
onSelect: jest.fn(),
|
||||
onSave: jest.fn(),
|
||||
onSaveChanges: jest.fn(),
|
||||
onReset: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
onRename: jest.fn(),
|
||||
testIdPrefix: PREFIX,
|
||||
...props,
|
||||
};
|
||||
render(<ViewsRail {...allProps} />);
|
||||
return allProps;
|
||||
}
|
||||
|
||||
describe('ViewsRail', () => {
|
||||
it('groups built-in views by section and custom views under their label', () => {
|
||||
renderRail({ customSectionLabel: 'Team views' });
|
||||
|
||||
expect(screen.getByText('Personal')).toBeInTheDocument();
|
||||
expect(screen.getByText('System')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team views')).toBeInTheDocument();
|
||||
['mine', 'all', 'view-1', 'view-2'].forEach((id) =>
|
||||
expect(screen.getByTestId(`${PREFIX}-view-${id}`)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('selects a view by id', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const props = renderRail();
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-view-2`));
|
||||
|
||||
expect(props.onSelect).toHaveBeenCalledWith('view-2');
|
||||
});
|
||||
|
||||
it('filters views by name and says when none match', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderRail();
|
||||
const search = screen.getByTestId(`${PREFIX}-view-search`);
|
||||
|
||||
await user.type(search, 'prod');
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-view-1`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-view-2`)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-all`)).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, 'nothing');
|
||||
|
||||
expect(screen.getByText(/No views match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading and empty states for custom views', () => {
|
||||
const { unmount } = render(
|
||||
<ViewsRail
|
||||
activeViewId="all"
|
||||
builtinViews={BUILTIN_VIEWS}
|
||||
customViews={[]}
|
||||
customViewsLoading
|
||||
isCustomActive={false}
|
||||
isModified={false}
|
||||
onSelect={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
onSaveChanges={jest.fn()}
|
||||
onReset={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
onRename={jest.fn()}
|
||||
testIdPrefix={PREFIX}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading views…')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderRail({ customViews: [] });
|
||||
expect(screen.getByText(/No saved views yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the unsaved-changes panel while filters match the view', () => {
|
||||
renderRail();
|
||||
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-reset`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers save, save as and reset for a modified custom view', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onViewSaved = jest.fn();
|
||||
const props = renderRail({
|
||||
activeViewId: 'view-1',
|
||||
isCustomActive: true,
|
||||
isModified: true,
|
||||
onViewSaved,
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-save-changes`));
|
||||
expect(onViewSaved).toHaveBeenCalledWith('update');
|
||||
expect(props.onSaveChanges).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-save-as`)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-reset`));
|
||||
expect(props.onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers only save as new for a modified built-in view', () => {
|
||||
renderRail({ isModified: true });
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-save-as-new`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(`${PREFIX}-view-save-changes`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wraps its body in the guard it is given', () => {
|
||||
renderRail({
|
||||
guard: (content): JSX.Element => (
|
||||
<div data-testid="rail-guard">{content}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('rail-guard')).toContainElement(
|
||||
screen.getByTestId(`${PREFIX}-view-all`),
|
||||
);
|
||||
});
|
||||
});
|
||||
43
frontend/src/components/ListViews/types.ts
Normal file
43
frontend/src/components/ListViews/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Pin } from '@signozhq/icons';
|
||||
|
||||
// All @signozhq icons share this component type.
|
||||
export type ListViewIcon = typeof Pin;
|
||||
|
||||
export interface BuiltinListView {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ListViewIcon;
|
||||
section: 'personal' | 'system';
|
||||
}
|
||||
|
||||
export interface EmptyStateCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// `clientFilter` narrows the rows the server already returned.
|
||||
export interface BuiltinViewConfig<
|
||||
F,
|
||||
Ctx = undefined,
|
||||
Row = never,
|
||||
> extends BuiltinListView {
|
||||
filter: (current: F, ctx: Ctx) => F;
|
||||
clientFilter?: (rows: Row[], ctx: Ctx) => Row[];
|
||||
emptyState?: EmptyStateCopy;
|
||||
}
|
||||
|
||||
export interface SavedListView<F> {
|
||||
id: string;
|
||||
name: string;
|
||||
filter: F;
|
||||
}
|
||||
|
||||
export interface SavedListViewsApi<F> {
|
||||
views: SavedListView<F>[];
|
||||
isLoading: boolean;
|
||||
createView: (name: string, filter: F) => Promise<SavedListView<F> | null>;
|
||||
updateView: (id: string, name: string, filter: F) => void;
|
||||
deleteView: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type ViewSaveMode = 'new' | 'update';
|
||||
@@ -9,7 +9,6 @@ import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicat
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
@@ -23,6 +22,7 @@ import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { getBodyMessage } from 'lib/logs/logBody';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -33,8 +33,6 @@ import {
|
||||
TextSelect,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -67,10 +65,6 @@ function LogDetailInner({
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
|
||||
|
||||
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
const [filters, setFilters] = useState<TagFilter | null>(null);
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
@@ -173,20 +167,7 @@ function LogDetailInner({
|
||||
}
|
||||
};
|
||||
|
||||
const logBody = useMemo(() => {
|
||||
if (!isBodyJsonQueryEnabled) {
|
||||
return (log?.body as string) ?? '';
|
||||
}
|
||||
// Feature enabled: body is always a map; message is always a string
|
||||
const bodyObj = log?.body as ILogBody;
|
||||
if (!bodyObj) {
|
||||
return '';
|
||||
}
|
||||
if (bodyObj.message) {
|
||||
return bodyObj.message;
|
||||
}
|
||||
return JSON.stringify(bodyObj);
|
||||
}, [isBodyJsonQueryEnabled, log?.body]);
|
||||
const logBody = useMemo(() => getBodyMessage(log?.body), [log?.body]);
|
||||
|
||||
const htmlBody = useMemo(
|
||||
() => ({
|
||||
@@ -308,7 +289,11 @@ function LogDetailInner({
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
<div
|
||||
className="log-body"
|
||||
data-testid="log-detail-body"
|
||||
dangerouslySetInnerHTML={htmlBody}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import cx from 'classnames';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { ScalarFieldValue } from 'utils/stringUtils';
|
||||
|
||||
import './AddToQueryHOC.styles.scss';
|
||||
|
||||
@@ -40,10 +41,10 @@ function AddToQueryHOC({
|
||||
|
||||
export interface AddToQueryHOCProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
fieldValue: ScalarFieldValue;
|
||||
onAddToQuery: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
fieldValue: ScalarFieldValue,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
) => void;
|
||||
|
||||
@@ -5,19 +5,19 @@ import cx from 'classnames';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
// utils
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { FlatLogData, getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
// interfaces
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { coerceToString, isScalarFieldValue } from 'utils/stringUtils';
|
||||
|
||||
// components
|
||||
import AddToQueryHOC, { AddToQueryHOCProps } from '../AddToQueryHOC';
|
||||
@@ -26,7 +26,6 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
||||
// styles
|
||||
import { Container, LogContainer, LogText } from './styles';
|
||||
import { isValidLogField } from './util';
|
||||
|
||||
import './ListLogView.styles.scss';
|
||||
|
||||
@@ -37,8 +36,9 @@ interface LogFieldProps {
|
||||
fontSize: FontSize;
|
||||
}
|
||||
|
||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow' | 'fieldValue'> &
|
||||
Pick<AddToQueryHOCProps, 'fieldValue'> &
|
||||
Partial<Pick<AddToQueryHOCProps, 'onAddToQuery'>>;
|
||||
|
||||
function LogGeneralField({
|
||||
fieldKey,
|
||||
@@ -75,27 +75,35 @@ function LogSelectedField({
|
||||
onAddToQuery,
|
||||
fontSize,
|
||||
}: LogSelectedFieldProps): JSX.Element {
|
||||
const fieldKeyText = (
|
||||
<Typography.Text>
|
||||
<span
|
||||
style={{ color: blue[4] }}
|
||||
className={cx('selected-log-field-key', fontSize)}
|
||||
>
|
||||
{fieldKey}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="log-selected-fields">
|
||||
<AddToQueryHOC
|
||||
fieldKey={fieldKey}
|
||||
fieldValue={fieldValue}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
>
|
||||
<Typography.Text>
|
||||
<span
|
||||
style={{ color: blue[4] }}
|
||||
className={cx('selected-log-field-key', fontSize)}
|
||||
>
|
||||
{fieldKey}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</AddToQueryHOC>
|
||||
{onAddToQuery ? (
|
||||
<AddToQueryHOC
|
||||
fieldKey={fieldKey}
|
||||
fieldValue={fieldValue}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
>
|
||||
{fieldKeyText}
|
||||
</AddToQueryHOC>
|
||||
) : (
|
||||
fieldKeyText
|
||||
)}
|
||||
<Typography.Text truncate={1} className={cx('selected-log-kv', fontSize)}>
|
||||
<span className={cx('selected-log-field-key', fontSize)}>{': '}</span>
|
||||
<span className={cx('selected-log-value', fontSize)}>
|
||||
{fieldValue || "''"}
|
||||
{coerceToString(fieldValue) || "''"}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -130,6 +138,7 @@ function ListLogView({
|
||||
onClearActiveLog,
|
||||
}: ListLogViewProps): JSX.Element {
|
||||
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
||||
logData.id,
|
||||
@@ -182,6 +191,7 @@ function ListLogView({
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
data-testid={`logs-list-row-${logData.id}`}
|
||||
$isActiveLog={isHighlighted || activeLog?.id === logData.id}
|
||||
$isDarkMode={isDarkMode}
|
||||
$logType={logType}
|
||||
@@ -199,7 +209,7 @@ function ListLogView({
|
||||
{updatedSelecedFields.some((field) => field.name === 'body') && (
|
||||
<LogGeneralField
|
||||
fieldKey="Log"
|
||||
fieldValue={getBodyDisplayString(logData.body)}
|
||||
fieldValue={getBodyDisplay(logData.body)}
|
||||
linesPerRow={linesPerRow}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
@@ -221,17 +231,22 @@ function ListLogView({
|
||||
|
||||
{updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.map((field) =>
|
||||
isValidLogField(flattenLogData[field.name] as never) ? (
|
||||
.map((field) => {
|
||||
const value = getLogFieldValue(logData, field.name, isBodyJsonEnabled);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const queryValue = isScalarFieldValue(value) ? value : undefined;
|
||||
return (
|
||||
<LogSelectedField
|
||||
key={field.name}
|
||||
fieldKey={field.name}
|
||||
fieldValue={flattenLogData[field.name] as never}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fieldValue={queryValue ?? coerceToString(value)}
|
||||
onAddToQuery={queryValue === undefined ? undefined : onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</LogContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const isValidLogField = (value: never): boolean => value !== undefined;
|
||||
@@ -23,6 +23,7 @@ function LogLinesActionButtons({
|
||||
icon={<TextSelect size={14} />}
|
||||
className="show-context-btn"
|
||||
onClick={handleShowContext}
|
||||
data-testid="log-line-show-context"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy Link">
|
||||
@@ -31,6 +32,7 @@ function LogLinesActionButtons({
|
||||
icon={<Link size={14} />}
|
||||
onClick={onLogCopy}
|
||||
className="copy-log-btn"
|
||||
data-testid="log-line-copy-link"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,16 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip } from 'antd';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
// hooks
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
|
||||
import { getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { isBoolean, isEmpty, isNumber, isUndefined } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons';
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
@@ -46,7 +46,7 @@ function RawLogView({
|
||||
isLogsExplorerPage,
|
||||
onLogCopy,
|
||||
} = useCopyLogLink(data.id);
|
||||
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
|
||||
@@ -60,15 +60,16 @@ function RawLogView({
|
||||
|
||||
const attributesValues = updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.map((field) => flattenLogData[field.name])
|
||||
.map((field) => getLogFieldValue(data, field.name, isBodyJsonEnabled))
|
||||
.filter((attribute) => {
|
||||
// loadash isEmpty doesnot work with numbers
|
||||
if (isNumber(attribute)) {
|
||||
if (isNumber(attribute) || isBoolean(attribute)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isUndefined(attribute) && !isEmpty(attribute);
|
||||
});
|
||||
})
|
||||
.map((attribute) => coerceToString(attribute));
|
||||
|
||||
let attributesText = attributesValues.join(' | ');
|
||||
|
||||
@@ -102,7 +103,7 @@ function RawLogView({
|
||||
// Check if body is selected
|
||||
const showBody = selectedFields.some((field) => field.name === 'body');
|
||||
if (showBody) {
|
||||
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
|
||||
parts.push(`${attributesText} ${getBodyDisplay(data.body)}`);
|
||||
} else {
|
||||
parts.push(attributesText);
|
||||
}
|
||||
@@ -156,6 +157,7 @@ function RawLogView({
|
||||
|
||||
return (
|
||||
<RawLogViewContainer
|
||||
data-testid={`logs-raw-row-${data.id}`}
|
||||
onClick={handleClickExpand}
|
||||
wrap={false}
|
||||
align="middle"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { render, renderHook, screen } from 'tests/test-utils';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { useLogsTableColumns } from '../useLogsTableColumns';
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/Timezone'),
|
||||
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/App/App', () => ({
|
||||
...jest.requireActual('providers/App/App'),
|
||||
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
|
||||
}));
|
||||
|
||||
@@ -165,3 +169,36 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogsTableColumns — cell values', () => {
|
||||
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
|
||||
|
||||
it('shows an empty body cell for a row without a body', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('body', 'log')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
const bodyCol = result.current.find((c) => c.id === 'log:body');
|
||||
|
||||
expect(bodyCol?.accessorFn?.(asLog({}))).toBe('');
|
||||
});
|
||||
|
||||
it('renders an object-valued field as JSON, not [object Object]', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('order')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
const orderCol = result.current.find((c) => c.id === 'order');
|
||||
const Cell = orderCol?.cell as (props: { value: unknown }) => JSX.Element;
|
||||
|
||||
render(<Cell value={{ id: 8412, coupons: ['WELCOME10'] }} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('{"id":8412,"coupons":["WELCOME10"]}'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,16 @@ import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
|
||||
import { getLogFieldValue } from 'lib/logs/flatLogData';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getBodyDisplay } from 'lib/logs/logBody';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
import type { TableColumnDef } from '../../TanStackTableView/types';
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
@@ -28,10 +26,7 @@ export function useLogsTableColumns({
|
||||
fontSize,
|
||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { featureFlags } = useAppContext();
|
||||
const isBodyJsonEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
const isBodyJsonEnabled = useIsBodyJsonEnabled();
|
||||
|
||||
return useMemo<TableColumnDef<ILog>[]>(() => {
|
||||
const stateIndicatorCol: TableColumnDef<ILog> = {
|
||||
@@ -75,7 +70,7 @@ export function useLogsTableColumns({
|
||||
const bodyCol: TableColumnDef<ILog> = {
|
||||
id: buildCompositeKey('body', 'log'),
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
accessorFn: (log): string => getBodyDisplay(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
@@ -99,7 +94,7 @@ export function useLogsTableColumns({
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
<TanStackTable.Text>{coerceToString(value)}</TanStackTable.Text>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user