mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-30 13:00:42 +00:00
Compare commits
10 Commits
refactor/s
...
issue_579
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
006a272297 | ||
|
|
de24f430c2 | ||
|
|
5037a46aad | ||
|
|
4aab7f48c9 | ||
|
|
ffd8c1a152 | ||
|
|
f15570fadd | ||
|
|
c3e4ca277d | ||
|
|
bfbdab95a3 | ||
|
|
0a78925ee7 | ||
|
|
5097d1578f |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
|
||||
tools: Glob, Grep, Read, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`, adding or extending the feature's driver under `tests/e2e/drivers/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
|
||||
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
|
||||
model: sonnet
|
||||
color: blue
|
||||
---
|
||||
@@ -10,17 +10,23 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
|
||||
# Repo conventions you must follow
|
||||
|
||||
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file. Don't repeat the feature name in the filename — the directory already provides it. `dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`.
|
||||
- **Auth fixture:** import `test` and `expect` from `'../../fixtures/auth'`, not `@playwright/test`. Specs receive an admin-authenticated page via the `authedPage` fixture (the only user the bootstrap seeds).
|
||||
- **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.
|
||||
```ts
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
|
||||
- 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
|
||||
@@ -31,15 +37,18 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
|
||||
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
|
||||
- **Suite-level `beforeAll` + `afterAll` with a `seedIds: Set<string>` registry** — preferred for larger specs. Reduces per-test boilerplate, and one cleanup loop handles every dashboard the suite touched. See [tests/e2e/tests/dashboards/list.spec.ts](../../tests/e2e/tests/dashboards/list.spec.ts) for the canonical shape.
|
||||
- **Reuse helpers from `tests/e2e/helpers/`.** Don't reinvent. The current set:
|
||||
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)` for `beforeAll` / `afterAll` (the `authedPage` fixture is test-scoped and not visible to suite hooks).
|
||||
- [`helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) — `authToken`, `gotoDashboardsList`, `createDashboardViaApi`, `importApmMetricsDashboardViaUI`, `deleteDashboardViaApi`, `findDashboardIdByTitle`, `openDashboardActionMenu`, plus the constants used by both helpers and specs (`SEARCH_PLACEHOLDER`, `LIST_HEADING`, `APM_METRICS_TITLE`, `DEFAULT_DASHBOARD_TITLE`).
|
||||
- **Seed via API when the UI flow is multi-step or brittle.** Implementation lives in `createDashboardViaApi` — use it. `page.request.*` does **not** auto-attach `Authorization`; the helpers handle that for you. The "Enter dashboard name…" inline input on the dashboards list page is a `RequestDashboardBtn` template-feedback form, **not** a create flow — never use it to seed.
|
||||
- **Reusable JSON fixtures live in [tests/e2e/fixtures/](../../tests/e2e/fixtures/).** `apm-metrics.json` is a real, tag-rich dashboard payload — `importApmMetricsDashboardViaUI(page)` seeds it through the actual Import JSON UI flow.
|
||||
- **Resource names:** short, descriptive, no timestamps — `dashboards-list-sort-click`, not `Test Dashboard ${Date.now()}`. Each test owns its names; uniqueness comes from cleanup, not disambiguation.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing resources. Cleanup shapes, in order of preference:
|
||||
- **A driver's `dispose`** — the driver tracks what it seeded and deletes it after the test (`dashboards.seed(...)` in `drivers/dashboards.ts`). The spec has no cleanup code.
|
||||
- **A worker-scoped `seeded(...)` layer** — read-only data shared by a worker's tests.
|
||||
- **Per-test `try / finally`** or **suite-level `afterAll` with an id registry** — specs without a driver. Suite hooks can't see `authedPage`; use `newAdminContext(browser)` from `helpers/auth.ts`.
|
||||
- **Golden telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads logs, traces and metrics over the last 6h (constants in `GOLDEN`, `helpers/dashboard-spec.ts`). Query within ≤ 6h and assert structure (series, rows, request payload), never exact counts: per-test seeds from parallel workers land in the same tenant. Seed missing shapes through the seeder with unique service names (`helpers/alerts/seeding.ts`).
|
||||
- **Reuse helpers from `tests/e2e/helpers/`** inside drivers. Don't reinvent:
|
||||
- [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) — `authToken`, `watchConsole`, `collectRequests`, `requestUrl`, `seederUrl`.
|
||||
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)`.
|
||||
- [`helpers/query-range-mock.ts`](../../tests/e2e/helpers/query-range-mock.ts) — `query_range` mocks for error / no-data / warning / loading states only; install before `goto`.
|
||||
- **Seed via API when the UI flow is multi-step or brittle**, through the driver (`dashboards.seed`, `seedAndOpen`, `seedAndEdit`). `page.request.*` does **not** auto-attach `Authorization`; drivers add `authToken(page)`.
|
||||
- **Static payloads live in [tests/e2e/testdata/](../../tests/e2e/testdata/)** (`dashboards/panels.ts`, `traces/large-trace.json`).
|
||||
- **Resource names:** short and descriptive, no timestamps. Where parallel workers share a list, add a random suffix as `uniqueName` in `drivers/dashboard-list.ts` does.
|
||||
- **Serial mode** when tests in a file mutate the same list page:
|
||||
```ts
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -64,13 +73,14 @@ For each scenario in the plan:
|
||||
- For verifications, use the dedicated `browser_verify_*` tools — they capture the assertion as Playwright code in the log.
|
||||
4. **Read the log.** Call `generator_read_log` immediately after the last step. Don't intersperse other tool calls.
|
||||
5. **Write the spec.** Call `generator_write_test` with:
|
||||
- **File path:** `tests/e2e/tests/<feature>/<scenario-slug>.spec.ts` — fs-friendly slug from the scenario title. Drop the feature prefix when it duplicates the directory (`dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`).
|
||||
- **File path:** `tests/e2e/tests/<feature>/<scenario-slug>.spec.ts` — fs-friendly slug from the scenario title. Drop the feature prefix when it duplicates the directory (`dashboards/list/search.spec.ts`, not `dashboards/list/dashboards-list-search.spec.ts`).
|
||||
- **Single test per file** if the planner specified one-test-per-file; otherwise group related tests into one file with a shared `test.describe('<Feature>', () => { … })`.
|
||||
- **`describe` block** matches the top-level plan section.
|
||||
- **Title** matches `TC-NN <description>` exactly.
|
||||
- **Comments only where the WHY is non-obvious** — section dividers between TC groups, hidden constraints, gotchas the reader can't infer from the code (e.g. "Monaco swallows Escape — click the title to blur first"). **Do not narrate steps** by pasting the plan's bullets back as `// 1. Navigate…` `// 2. Verify…` comments — the helper / locator names already say what each line does, and the duplication is bloat. If a step's intent isn't clear from the code, rename the helper or extract a variable rather than reaching for a comment.
|
||||
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
|
||||
- **Try / finally** cleanup using the API (delete the resources you seeded).
|
||||
- **Imports** from the feature's driver module (or `../../fixtures/auth` when none fits). **Do not** import from `@playwright/test` directly.
|
||||
- **Cleanup** through the driver's `dispose`; `try / finally` only without a driver.
|
||||
- **Move recorded locators and waits into the driver.** The log gives raw `page.*` calls; the spec should read as driver actions.
|
||||
|
||||
# Quality bar — what to write, what to skip
|
||||
|
||||
@@ -88,81 +98,57 @@ When you cut, merge, or renumber TCs vs the plan, note it in your final summary.
|
||||
For a plan section:
|
||||
|
||||
```markdown
|
||||
### 1. Page Load
|
||||
### 1. Search
|
||||
|
||||
#### TC-01 page chrome and core controls render
|
||||
#### TC-01 searching by name writes the query to the URL and lists the match
|
||||
**Preconditions:** one dashboard with a unique name (seed via the `dashboards` driver).
|
||||
**Steps:**
|
||||
1. Navigate to `/dashboard`
|
||||
2. Verify the page title is "SigNoz | All Dashboards"
|
||||
3. Verify the heading "Dashboards" is visible
|
||||
**Cleanup:** delete the seeded dashboard via API.
|
||||
1. Open the dashboards list.
|
||||
2. Search `name CONTAINS '<name>'`.
|
||||
**Expected:** the `query` URL param carries the search; only the seeded dashboard is listed.
|
||||
```
|
||||
|
||||
You produce (suite-level shape, preferred for files with multiple scenarios):
|
||||
You produce:
|
||||
|
||||
```ts
|
||||
// tests/e2e/tests/dashboards/list.spec.ts
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
// tests/e2e/tests/dashboards/list/search.spec.ts
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
gotoDashboardsList,
|
||||
} from '../../helpers/dashboards';
|
||||
expect,
|
||||
listDashboard,
|
||||
nameContains,
|
||||
test,
|
||||
uniqueName,
|
||||
} from '../../../drivers/dashboard-list';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboards List Page', () => {
|
||||
test('TC-01 page chrome and core controls render', async ({
|
||||
test.describe('Dashboards — list search', () => {
|
||||
test('TC-01 searching by name writes the query to the URL and lists the match', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
dashboardList,
|
||||
}) => {
|
||||
await seed(page, 'list-chrome');
|
||||
const name = uniqueName('search');
|
||||
await dashboards.seed(listDashboard(name));
|
||||
await dashboardList.open();
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
await dashboardList.runSearch(nameContains(name));
|
||||
|
||||
await expect(page).toHaveTitle('SigNoz | All Dashboards');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get('query')).toBe(
|
||||
nameContains(name),
|
||||
);
|
||||
await expect(dashboardList.rows()).toHaveText([name]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note how the example carries no `// 1. …` `// 2. …` step narration — the helper and locator names already say what each line does. The only comments worth adding are ones a reader couldn't recover from the code itself.
|
||||
Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locators (they live in `drivers/dashboard-list.ts`), and no `// 1. …` step narration. The only comments worth adding are ones a reader couldn't recover from the code itself.
|
||||
|
||||
# Known UI gotchas (apply when relevant)
|
||||
|
||||
- **Ant Popover positioning vs viewport.** Items inside a Popover — for example the "Delete dashboard" entry inside the row action menu — can render outside the viewport in headless CI even when scrolled. `click({ force: true })` skips actionability checks but Playwright still requires the click coordinates to land inside the viewport. Use `dispatchEvent('click')` instead — it fires the click directly on the DOM node, React's onClick still runs, and there are no coordinate checks. Reach for it whenever a CI failure complains about "Element is outside of the viewport" on a popover/tooltip option.
|
||||
- **Sticky-header rows below the fold.** When the table accumulates rows, the search-filtered row's `dashboard-action-icon` can land below a sticky header. Always `await actionIcon.scrollIntoViewIfNeeded()` before clicking. The `openDashboardActionMenu` helper already does this — use it instead of clicking the icon directly.
|
||||
- **Sticky-header rows below the fold.** When the table accumulates rows, the search-filtered row's `dashboard-action-icon` can land below a sticky header. Always `await actionIcon.scrollIntoViewIfNeeded()` before clicking, inside the driver action.
|
||||
- **React Query mutations vs navigation.** UI delete clicks fire an async DELETE through React Query. Navigating away before the mutation completes cancels it. Pair the click with `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` and `await expect(dialog).not.toBeVisible()` before the next `page.goto(...)`.
|
||||
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title (or any non-editor element inside the dialog) first to blur Monaco; Ant's `keyboard` handler then sees the Escape and dismisses.
|
||||
- **Empty zero-state hides controls.** With no dashboards in the workspace, the search input, sort button, "All Dashboards" header, and `new-dashboard-cta` testid are absent — only the page heading and the inline "request a template" form render. Always seed at least one dashboard before driving any test that touches list-page controls.
|
||||
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title first to blur Monaco; `dashboardList.dismissWithEscape()` does this.
|
||||
- **Empty zero-state hides controls.** With no dashboards in the workspace, the list controls and `new-dashboard-cta` are absent. Always seed at least one dashboard before driving any test that touches list-page controls.
|
||||
|
||||
# Quality bar
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: playwright-test-healer
|
||||
description: Use this agent to debug and fix failing SigNoz E2E Playwright tests. Examples — <example>Context: A spec is red. user: 'tests/e2e/tests/dashboards/list.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
|
||||
description: Use this agent to debug and fix failing SigNoz E2E Playwright tests. Examples — <example>Context: A spec is red. user: 'tests/e2e/tests/dashboards/list/search.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
|
||||
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
|
||||
model: sonnet
|
||||
color: red
|
||||
@@ -27,11 +27,11 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
|
||||
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
|
||||
4. **Root-cause.** Distinguish between:
|
||||
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
|
||||
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds via API and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts.
|
||||
- **Selector drift** — the app changed `data-testid` or text. Fix the locator where it lives: in the driver under `tests/e2e/drivers/` when the spec uses one, so every spec sharing it heals at once. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action — inside the driver action when there is one.
|
||||
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Seed through the driver (its `dispose` cleans up) or via API with `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug — assert structure instead.
|
||||
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
|
||||
5. **Fix.** Edit the spec. Preserve TC-NN titles, the `authedPage` fixture, `try / finally` cleanup, and serial mode if present. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
|
||||
5. **Fix.** Edit the spec or its driver. Preserve TC-NN titles, the driver / `authedPage` fixtures, cleanup, and serial mode if present. A driver change must keep every other spec importing it green — run them too. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
|
||||
6. **Re-run only the fixed test** before moving to the next failure. Three options:
|
||||
- `npx playwright test -g 'TC-09' --project=chromium` — target a single TC by title
|
||||
- `npx playwright test --last-failed --project=chromium` — replay everything that failed last run
|
||||
@@ -41,14 +41,13 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
|
||||
# Repo-specific signals
|
||||
|
||||
- **Reuse helpers before adding new code.** [`tests/e2e/helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) and [`tests/e2e/helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) already export the API-seed, cleanup, navigation, and action-menu helpers most fixes need. Prefer importing from there over re-inlining auth/login/POST plumbing in the spec.
|
||||
- **Reuse drivers and helpers before adding new code.** [`tests/e2e/drivers/`](../../tests/e2e/drivers/) holds the per-feature seed, cleanup, navigation and action methods (`kit.ts` explains `driver` / `seeded`); [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) and [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) the plain plumbing. Prefer them over re-inlining auth/login/POST code in the spec.
|
||||
- **Ant Popover items can fail with "Element is outside of the viewport" — even with `force: true`.** `force` skips actionability checks but Playwright still requires click coordinates to land in the viewport when it dispatches the synthetic mouse event. The robust fix is `tooltip.getByText('…').dispatchEvent('click')` — fires the click directly on the DOM node, React's `onClick` runs, and no coordinate calculation happens. Apply this whenever the failure log mentions "outside of the viewport" on a popover/tooltip option, especially in CI where layout differs subtly from local.
|
||||
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. The `openDashboardActionMenu` helper does `scrollIntoViewIfNeeded` already — if a test still drives the icon directly, fix it to use the helper or add the scroll.
|
||||
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. Add `scrollIntoViewIfNeeded` before the click, in the driver action.
|
||||
- **React Query mutations vs page.goto.** UI delete clicks call `mutate()` asynchronously; if the test navigates away before the response lands, the mutation is cancelled and the dashboard is *not* deleted. Wait for the DELETE response and the dialog dismissal explicitly: `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` plus `await expect(dialog).not.toBeVisible()`.
|
||||
- **Monaco editor swallows Escape inside the Import JSON dialog.** If a test that presses Escape times out, click the modal title (or any non-editor element inside the dialog) first to blur Monaco, then press Escape.
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **The list pages render zero-state when the workspace is empty.** List controls and the `new-dashboard-cta` testid are absent in zero-state. A timeout on those usually means the workspace was empty — seed first via `dashboards.seed(...)`.
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/common.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
|
||||
@@ -20,8 +20,10 @@ You will:
|
||||
- The frontend stores its JWT in `localStorage` under `AUTH_TOKEN` and the API requires `Authorization: Bearer <token>` for protected endpoints. Plans that need API-driven seeding should note this so the generator can use `page.request.*`.
|
||||
|
||||
2. **Check what's already wired up.**
|
||||
- [tests/e2e/helpers/dashboards.ts](../../tests/e2e/helpers/dashboards.ts) and [tests/e2e/helpers/auth.ts](../../tests/e2e/helpers/auth.ts) hold reusable helpers (`createDashboardViaApi`, `gotoDashboardsList`, `openDashboardActionMenu`, `newAdminContext`, `importApmMetricsDashboardViaUI`, etc.). When the plan touches dashboards, reference these by name in the steps so the generator can reuse them rather than reinvent.
|
||||
- [tests/e2e/fixtures/apm-metrics.json](../../tests/e2e/fixtures/apm-metrics.json) is a real-world dashboard payload (rich tags, panels, description) suitable as a seed fixture — note in the plan if a scenario benefits from it.
|
||||
- [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.
|
||||
|
||||
3. **Navigate and explore**
|
||||
- Invoke `planner_setup_page` once before any other browser tool.
|
||||
@@ -39,7 +41,7 @@ You will:
|
||||
|
||||
Each scenario must include:
|
||||
- **TC-NN** title — `TC-NN <short description>` (matches the naming this repo uses for test titles).
|
||||
- Preconditions (what state the test expects — note that the bootstrap creates a fresh stack with **zero dashboards / alerts / etc.**, so plans must seed their own data).
|
||||
- Preconditions (what state the test expects — note that the bootstrap creates a fresh stack with **zero dashboards / alerts / etc.** (only the golden telemetry), so plans must seed their own resources).
|
||||
- Step-by-step user actions.
|
||||
- Expected outcomes per step.
|
||||
- Cleanup notes (what gets created and how to remove it — usually via API).
|
||||
@@ -64,7 +66,7 @@ The dashboards list page (`/dashboard`) lists all dashboards in the workspace. F
|
||||
- Open per-row actions: View, Open in New Tab, Copy Link, Export JSON, Delete dashboard
|
||||
- Create a new dashboard via the "New dashboard" dropdown (Create dashboard / Import JSON / View templates)
|
||||
|
||||
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test must seed its own data. The "Enter dashboard name…" inline input is a "request a new template" feedback form — **not** a create flow. The only UI create path is the dropdown.
|
||||
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test seeds its own through the `dashboards` driver (`dashboards.seed(...)`, deleted when the test ends).
|
||||
|
||||
**Known limitations:**
|
||||
- Ascending sort is not yet implemented — repeated clicks on the sort button keep `order=descend`.
|
||||
@@ -87,7 +89,7 @@ The dashboards list page (`/dashboard`) lists all dashboards in the workspace. F
|
||||
- All Dashboards section header rendered.
|
||||
- Search input, sort button, and at least one dashboard thumbnail visible.
|
||||
|
||||
**Cleanup:** delete the seeded dashboard via `DELETE /api/v1/dashboards/<id>`.
|
||||
**Cleanup:** none in the spec — the `dashboards` driver deletes what it seeded.
|
||||
|
||||
#### TC-02 ...
|
||||
</example-spec>
|
||||
@@ -95,7 +97,7 @@ 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. Suite-level `beforeAll` / `afterAll` *within* a single spec file is fine and is the preferred shape for files with > ~10 scenarios; per-test `try / finally` is fine for smaller specs.
|
||||
- Each scenario must own its preconditions and cleanup. **Do not invent cross-file global fixtures** — they break parallel-by-file execution. Prefer a driver's self-cleaning seed (`dispose`) or worker-scoped `seeded(...)` layer; without a driver, suite-level `beforeAll` / `afterAll` within one spec file or per-test `try / finally` both work.
|
||||
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
|
||||
- **Don't pad coverage.** Every TC must catch a regression that would actually ship if the test were missing — a real branch in the source, a real user-visible failure mode. A TC that asserts a hard-coded string is still rendered, or that a button is still a button, adds nothing but maintenance cost: it inflates the suite, slows CI, and trains readers to skim past the directory. Before writing a scenario, ask "what code change would break this?" — if the only answer is "deleting the literal under test," cut it or fold it into a richer scenario as one assertion among many.
|
||||
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should merge into one parameterised scenario unless each input genuinely exercises a distinct branch. Prefer one assertion-rich TC over three thin ones.
|
||||
|
||||
@@ -11389,8 +11389,7 @@ components:
|
||||
evaluation:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
description: The evaluation window, as a kind and spec. Required when schemaVersion
|
||||
is v2alpha1; derived from evalWindow and frequency for v1.
|
||||
description: The evaluation window, as a kind and spec.
|
||||
frequency:
|
||||
description: How often the rule is evaluated, as a Go duration such as 1m.
|
||||
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
|
||||
@@ -11406,8 +11405,7 @@ components:
|
||||
notificationSettings:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour. Required when
|
||||
schemaVersion is v2alpha1; derived from the v1 fields otherwise.
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
preferredChannels:
|
||||
description: The displayName of each notification channel to notify, for
|
||||
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
|
||||
@@ -11420,26 +11418,30 @@ components:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
description: 'The evaluation strategy: threshold_rule compares against thresholds,
|
||||
promql_rule evaluates a PromQL expression, anomaly_rule scores deviation
|
||||
from seasonal history. Defaults to threshold_rule for builder queries
|
||||
and is forced to promql_rule for promql queries.'
|
||||
from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql
|
||||
queries and is forced to promql_rule for promql queries.'
|
||||
schemaVersion:
|
||||
description: 'The rule payload schema: v1 (the default when omitted) uses
|
||||
the flat condition fields, v2alpha1 uses condition.thresholds, evaluation
|
||||
and notificationSettings.'
|
||||
description: The rule payload schema.
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
deprecated: true
|
||||
description: The URL of the page the rule was created from. Informational;
|
||||
it is not used for evaluation.
|
||||
type: string
|
||||
version:
|
||||
description: The query payload version. Only v5 is accepted and it is filled
|
||||
in when omitted.
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- alert
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- evaluation
|
||||
- schemaVersion
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesPostableRuleView:
|
||||
properties:
|
||||
@@ -11528,8 +11530,7 @@ components:
|
||||
evaluation:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
description: The evaluation window, as a kind and spec. Required when schemaVersion
|
||||
is v2alpha1; derived from evalWindow and frequency for v1.
|
||||
description: The evaluation window, as a kind and spec.
|
||||
frequency:
|
||||
description: How often the rule is evaluated, as a Go duration such as 1m.
|
||||
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
|
||||
@@ -11548,8 +11549,7 @@ components:
|
||||
notificationSettings:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour. Required when
|
||||
schemaVersion is v2alpha1; derived from the v1 fields otherwise.
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
preferredChannels:
|
||||
description: The displayName of each notification channel to notify, for
|
||||
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
|
||||
@@ -11562,14 +11562,15 @@ components:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
description: 'The evaluation strategy: threshold_rule compares against thresholds,
|
||||
promql_rule evaluates a PromQL expression, anomaly_rule scores deviation
|
||||
from seasonal history. Defaults to threshold_rule for builder queries
|
||||
and is forced to promql_rule for promql queries.'
|
||||
from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql
|
||||
queries and is forced to promql_rule for promql queries.'
|
||||
schemaVersion:
|
||||
description: 'The rule payload schema: v1 (the default when omitted) uses
|
||||
the flat condition fields, v2alpha1 uses condition.thresholds, evaluation
|
||||
and notificationSettings.'
|
||||
description: The rule payload schema.
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
deprecated: true
|
||||
description: The URL of the page the rule was created from. Informational;
|
||||
it is not used for evaluation.
|
||||
type: string
|
||||
@@ -11584,8 +11585,8 @@ components:
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
description: The query payload version. Only v5 is accepted and it is filled
|
||||
in when omitted.
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
@@ -11594,6 +11595,9 @@ components:
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- evaluation
|
||||
- schemaVersion
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesRuleCondition:
|
||||
properties:
|
||||
@@ -11643,8 +11647,7 @@ components:
|
||||
against, hourly, daily or weekly.'
|
||||
selectedQueryName:
|
||||
description: The name of the query whose result is evaluated, such as A
|
||||
or F1. Must match a query in compositeQuery. When omitted, F1 is used
|
||||
if present, otherwise the enabled query with the highest name.
|
||||
or F1. Must match a query in compositeQuery.
|
||||
type: string
|
||||
target:
|
||||
description: 'schemaVersion v1 only: the value compared against, in targetUnit.
|
||||
@@ -11660,10 +11663,11 @@ components:
|
||||
thresholds:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleThresholdData'
|
||||
description: The thresholds to evaluate, as a kind and spec. Required when
|
||||
schemaVersion is v2alpha1; derived from op, target and matchType for v1.
|
||||
description: The thresholds to evaluate, as a kind and spec.
|
||||
required:
|
||||
- compositeQuery
|
||||
- selectedQueryName
|
||||
- thresholds
|
||||
type: object
|
||||
RuletypesRuleThresholdData:
|
||||
discriminator:
|
||||
@@ -16692,9 +16696,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- route-policy:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- route-policy:list
|
||||
summary: List route policies
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -16749,9 +16753,13 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- route-policy:create
|
||||
- route-policy:attach
|
||||
- notification-channel:attach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- route-policy:create
|
||||
- route-policy:attach
|
||||
- notification-channel:attach
|
||||
summary: Create route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -16796,9 +16804,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- route-policy:delete
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- route-policy:delete
|
||||
summary: Delete route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -16854,9 +16862,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- route-policy:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- route-policy:read
|
||||
summary: Get route policy by ID
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -16923,9 +16931,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- route-policy:update
|
||||
- route-policy:attach
|
||||
- route-policy:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- route-policy:update
|
||||
- route-policy:attach
|
||||
- route-policy:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
summary: Update route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -25312,13 +25328,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -25353,22 +25369,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -26264,13 +26294,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -26305,22 +26335,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -27117,13 +27161,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -27158,22 +27202,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -28676,13 +28734,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -28717,22 +28775,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
|
||||
@@ -17,7 +17,7 @@ Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` fr
|
||||
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
|
||||
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
|
||||
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
|
||||
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
|
||||
| `langfuse.observation.model.name`, `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.observation.type`, `langfuse.session.id` | Langfuse SDK v3 (Python) and v4 (JS), LiteLLM `langfuse_otel` callback |
|
||||
| `session.id` | OpenInference, Langfuse, Claude Code |
|
||||
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |
|
||||
|
||||
@@ -173,9 +173,10 @@ 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. Two patterns work:
|
||||
- **Per-test seed + cleanup in `try / finally`** — small specs where each test owns its data.
|
||||
- **Suite-level seed + `afterAll` teardown** — preferred for larger specs. Each `createDashboard(...)` call adds the resulting ID to a module-level `Set<string>`, and one `test.afterAll(...)` deletes everything in the set. See `tests/e2e/tests/dashboards/list.spec.ts` for the full pattern. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAdminContext(browser)` from `helpers/auth.ts` instead — it performs one fresh login per suite hook.
|
||||
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(
|
||||
|
||||
140
frontend/src/AppRoutes/__tests__/sentryFilters.test.ts
Normal file
140
frontend/src/AppRoutes/__tests__/sentryFilters.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ErrorEvent, StackFrame } from '@sentry/react';
|
||||
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from '../sentryFilters';
|
||||
|
||||
const APP_BUNDLE = 'https://example.us.signoz.cloud/assets/index-B3QqK3ie.js';
|
||||
|
||||
function eventWithFrames(...filenames: string[]): ErrorEvent {
|
||||
return {
|
||||
type: undefined,
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
type: 'TypeError',
|
||||
value: 'Failed to fetch',
|
||||
stacktrace: {
|
||||
frames: filenames.map((filename) => ({ filename, lineno: 1, colno: 1 })),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('isExternalIntegrationError', () => {
|
||||
it.each([
|
||||
[
|
||||
'PostHog recorder',
|
||||
'https://us-assets.i.posthog.com/static/posthog-recorder.js?v=1.298.0',
|
||||
],
|
||||
[
|
||||
'Pylon widget',
|
||||
'https://widget.usepylon.com/widget/00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
['Appcues', 'https://fast.appcues.com/123456.js'],
|
||||
])('drops an error with a %s frame', (_, url) => {
|
||||
expect(isExternalIntegrationError(eventWithFrames(APP_BUNDLE, url))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('drops it when the top frame is our bundle', () => {
|
||||
expect(
|
||||
isExternalIntegrationError(
|
||||
eventWithFrames(
|
||||
'https://us-assets.i.posthog.com/static/posthog-recorder.js?v=1.298.0',
|
||||
'<anonymous>',
|
||||
APP_BUNDLE,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an error from our bundle only', () => {
|
||||
expect(
|
||||
isExternalIntegrationError(eventWithFrames(APP_BUNDLE, '<anonymous>')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error from a host that only contains an integration name', () => {
|
||||
expect(
|
||||
isExternalIntegrationError(
|
||||
eventWithFrames(
|
||||
'https://posthog.example.com/app.js',
|
||||
'https://notusepylon.com/widget.js',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error without a stack trace', () => {
|
||||
expect(isExternalIntegrationError({ type: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasNoAppFrames', () => {
|
||||
const PAGE_URL = 'https://example.us.signoz.cloud/dashboard/01a081e1';
|
||||
|
||||
function eventWithStackFrames(frames: StackFrame[]): ErrorEvent {
|
||||
return {
|
||||
type: undefined,
|
||||
exception: {
|
||||
values: [{ type: 'SyntaxError', stacktrace: { frames } }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('drops a script appended to the page', () => {
|
||||
expect(
|
||||
hasNoAppFrames(
|
||||
eventWithStackFrames([
|
||||
{ filename: PAGE_URL, lineno: 1, colno: 30, function: 'global code' },
|
||||
{ filename: '[native code]', function: 'eval' },
|
||||
{ filename: '[native code]', function: 'append' },
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a browser extension error', () => {
|
||||
expect(
|
||||
hasNoAppFrames(
|
||||
eventWithStackFrames([
|
||||
{ filename: 'chrome-extension://abc/content.js', lineno: 4, colno: 12 },
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an error with a frame from our bundle', () => {
|
||||
expect(hasNoAppFrames(eventWithFrames(PAGE_URL, APP_BUNDLE))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error without a stack trace', () => {
|
||||
expect(hasNoAppFrames({ type: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error whose frames have no position', () => {
|
||||
expect(
|
||||
hasNoAppFrames(
|
||||
eventWithStackFrames([{ filename: '[native code]', function: 'eval' }]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SENTRY_IGNORE_ERRORS', () => {
|
||||
it('ignores the bundled PostHog recorder load failure', () => {
|
||||
expect(
|
||||
SENTRY_IGNORE_ERRORS.some((pattern) =>
|
||||
pattern.test(
|
||||
'Error: Called on script loaded before session recording is available',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { IS_PROD } from 'lib/env';
|
||||
import history from 'lib/history';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import posthog from 'posthog-js';
|
||||
@@ -37,6 +38,11 @@ import defaultRoutes, {
|
||||
LIST_LICENSES,
|
||||
SUPPORT_ROUTE,
|
||||
} from './routes';
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from './sentryFilters';
|
||||
|
||||
const appRouter = (children: ReactNode): ReactNode => (
|
||||
<Router history={history}>
|
||||
@@ -310,16 +316,7 @@ function App(): JSX.Element {
|
||||
tunnel: window.signozBootData.settings.sentry.tunnel,
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
// A tab that outlived a deploy requests hashed assets the new build no longer
|
||||
// has. `lazyRetry` recovers by reloading once, so this class is not worth
|
||||
// reporting. The stylesheet message is Vite's own; the module ones are the
|
||||
// same failure worded differently by Chromium, Firefox and Safari.
|
||||
ignoreErrors: [
|
||||
/Unable to preload CSS for/,
|
||||
/Failed to fetch dynamically imported module/,
|
||||
/error loading dynamically imported module/,
|
||||
/Importing a module script failed/,
|
||||
],
|
||||
ignoreErrors: SENTRY_IGNORE_ERRORS,
|
||||
integrations: [
|
||||
// Kept for the `transaction` tag used in routing, even though
|
||||
// tracing is disabled. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
@@ -354,6 +351,15 @@ function App(): JSX.Element {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isExternalIntegrationError(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dev serves source from /src/, so every frame would look foreign there.
|
||||
if (IS_PROD && hasNoAppFrames(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionReplayUrl = posthog.get_session_replay_url?.({
|
||||
withTimestamp: true,
|
||||
});
|
||||
|
||||
49
frontend/src/AppRoutes/sentryFilters.ts
Normal file
49
frontend/src/AppRoutes/sentryFilters.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ErrorEvent } from '@sentry/react';
|
||||
|
||||
export const SENTRY_IGNORE_ERRORS = [
|
||||
// A tab that outlived a deploy requests hashed assets the new build no longer
|
||||
// has. `lazyRetry` recovers by reloading once, so this class is not worth
|
||||
// reporting. The stylesheet message is Vite's own; the module ones are the
|
||||
// same failure worded differently by Chromium, Firefox and Safari.
|
||||
/Unable to preload CSS for/,
|
||||
/Failed to fetch dynamically imported module/,
|
||||
/error loading dynamically imported module/,
|
||||
/Importing a module script failed/,
|
||||
// Thrown by the bundled posthog-js when its recorder script fails to load, so
|
||||
// its frames point at our own bundle and the URL check below misses it.
|
||||
/Called on script loaded before session recording is available/,
|
||||
];
|
||||
|
||||
const APP_ASSETS_PATH = '/assets/';
|
||||
|
||||
// Frames without a position are `[native code]`, which says nothing about the
|
||||
// script that threw.
|
||||
export function hasNoAppFrames(event: ErrorEvent): boolean {
|
||||
const frames = (event.exception?.values ?? [])
|
||||
.flatMap((exception) => exception.stacktrace?.frames ?? [])
|
||||
.filter(
|
||||
(frame) => frame.filename && (frame.lineno != null || frame.colno != null),
|
||||
);
|
||||
|
||||
return (
|
||||
frames.length > 0 &&
|
||||
!frames.some((frame) => frame.filename?.includes(APP_ASSETS_PATH))
|
||||
);
|
||||
}
|
||||
|
||||
// PostHog's lazy recorder, the Pylon widget and Appcues.
|
||||
const EXTERNAL_SCRIPT_URLS = [
|
||||
/^https?:\/\/[^/]+\.posthog\.com\//,
|
||||
/^https?:\/\/(?:[^/]+\.)?usepylon\.com\//,
|
||||
/^https?:\/\/(?:[^/]+\.)?appcues\.(?:com|net)\//,
|
||||
];
|
||||
|
||||
// Checks every frame, not only the top one: a failed fetch from the recorder
|
||||
// tops out in Sentry's own fetch wrapper, which is bundled with the app.
|
||||
export function isExternalIntegrationError(event: ErrorEvent): boolean {
|
||||
return (event.exception?.values ?? []).some((exception) =>
|
||||
(exception.stacktrace?.frames ?? []).some((frame) =>
|
||||
EXTERNAL_SCRIPT_URLS.some((url) => url.test(frame.filename ?? '')),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -12584,6 +12584,12 @@ export type RuletypesPostableRuleDTOAnnotations = { [key: string]: string };
|
||||
*/
|
||||
export type RuletypesPostableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
/**
|
||||
* The rule payload schema.
|
||||
*/
|
||||
export enum RuletypesPostableRuleDTOSchemaVersion {
|
||||
v2alpha1 = 'v2alpha1',
|
||||
}
|
||||
export enum RuletypesSeasonalityDTO {
|
||||
hourly = 'hourly',
|
||||
daily = 'daily',
|
||||
@@ -12660,9 +12666,9 @@ export interface RuletypesRuleConditionDTO {
|
||||
seasonality?: RuletypesSeasonalityDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery. When omitted, F1 is used if present, otherwise the enabled query with the highest name.
|
||||
* @description The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery.
|
||||
*/
|
||||
selectedQueryName?: string;
|
||||
selectedQueryName: string;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
@@ -12675,9 +12681,9 @@ export interface RuletypesRuleConditionDTO {
|
||||
*/
|
||||
targetUnit?: string;
|
||||
/**
|
||||
* @description The thresholds to evaluate, as a kind and spec. Required when schemaVersion is v2alpha1; derived from op, target and matchType for v1.
|
||||
* @description The thresholds to evaluate, as a kind and spec.
|
||||
*/
|
||||
thresholds?: RuletypesRuleThresholdDataDTO;
|
||||
thresholds: RuletypesRuleThresholdDataDTO;
|
||||
}
|
||||
|
||||
export interface RuletypesPostableRuleDTO {
|
||||
@@ -12715,9 +12721,9 @@ export interface RuletypesPostableRuleDTO {
|
||||
*/
|
||||
evalWindow?: string;
|
||||
/**
|
||||
* @description The evaluation window, as a kind and spec. Required when schemaVersion is v2alpha1; derived from evalWindow and frequency for v1.
|
||||
* @description The evaluation window, as a kind and spec.
|
||||
*/
|
||||
evaluation?: RuletypesEvaluationEnvelopeDTO;
|
||||
evaluation: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
|
||||
@@ -12729,23 +12735,24 @@ export interface RuletypesPostableRuleDTO {
|
||||
*/
|
||||
labels?: RuletypesPostableRuleDTOLabels;
|
||||
/**
|
||||
* @description Grouping, re-notification and routing behaviour. Required when schemaVersion is v2alpha1; derived from the v1 fields otherwise.
|
||||
* @description Grouping, re-notification and routing behaviour.
|
||||
*/
|
||||
notificationSettings?: RuletypesNotificationSettingsDTO;
|
||||
notificationSettings: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
/**
|
||||
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder queries and is forced to promql_rule for promql queries.
|
||||
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
|
||||
*/
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
/**
|
||||
* @enum v2alpha1
|
||||
* @type string
|
||||
* @description The rule payload schema: v1 (the default when omitted) uses the flat condition fields, v2alpha1 uses condition.thresholds, evaluation and notificationSettings.
|
||||
* @description The rule payload schema.
|
||||
*/
|
||||
schemaVersion?: string;
|
||||
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
|
||||
/**
|
||||
* @type string
|
||||
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
|
||||
@@ -12753,7 +12760,7 @@ export interface RuletypesPostableRuleDTO {
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The query payload version. Only v5 is accepted and it is filled in when omitted.
|
||||
* @description The query payload version. Nothing reads it.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
@@ -12776,6 +12783,12 @@ export type RuletypesRuleDTOAnnotations = { [key: string]: string };
|
||||
*/
|
||||
export type RuletypesRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
/**
|
||||
* The rule payload schema.
|
||||
*/
|
||||
export enum RuletypesRuleDTOSchemaVersion {
|
||||
v2alpha1 = 'v2alpha1',
|
||||
}
|
||||
export interface RuletypesRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12820,9 +12833,9 @@ export interface RuletypesRuleDTO {
|
||||
*/
|
||||
evalWindow?: string;
|
||||
/**
|
||||
* @description The evaluation window, as a kind and spec. Required when schemaVersion is v2alpha1; derived from evalWindow and frequency for v1.
|
||||
* @description The evaluation window, as a kind and spec.
|
||||
*/
|
||||
evaluation?: RuletypesEvaluationEnvelopeDTO;
|
||||
evaluation: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
|
||||
@@ -12839,23 +12852,24 @@ export interface RuletypesRuleDTO {
|
||||
*/
|
||||
labels?: RuletypesRuleDTOLabels;
|
||||
/**
|
||||
* @description Grouping, re-notification and routing behaviour. Required when schemaVersion is v2alpha1; derived from the v1 fields otherwise.
|
||||
* @description Grouping, re-notification and routing behaviour.
|
||||
*/
|
||||
notificationSettings?: RuletypesNotificationSettingsDTO;
|
||||
notificationSettings: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
/**
|
||||
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder queries and is forced to promql_rule for promql queries.
|
||||
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
|
||||
*/
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
/**
|
||||
* @enum v2alpha1
|
||||
* @type string
|
||||
* @description The rule payload schema: v1 (the default when omitted) uses the flat condition fields, v2alpha1 uses condition.thresholds, evaluation and notificationSettings.
|
||||
* @description The rule payload schema.
|
||||
*/
|
||||
schemaVersion?: string;
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion;
|
||||
/**
|
||||
* @type string
|
||||
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
|
||||
@@ -12876,7 +12890,7 @@ export interface RuletypesRuleDTO {
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The query payload version. Only v5 is accepted and it is filled in when omitted.
|
||||
* @description The query payload version. Nothing reads it.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
@@ -148,7 +147,7 @@ function LogDetailInner({
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
onChange={handleChangeOperator}
|
||||
operators={operators}
|
||||
className="metrics-operators-select"
|
||||
data-testid="metrics-time-aggregation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +193,10 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
AGGREGATE ACROSS TIME SERIES
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="metrics-aggregation-section-content-item-value">
|
||||
<div
|
||||
className="metrics-aggregation-section-content-item-value"
|
||||
data-testid="metrics-space-aggregation"
|
||||
>
|
||||
<SpaceAggregationOptions
|
||||
panelType={panelType}
|
||||
key={`${panelType}${queryAggregation.spaceAggregation}${queryAggregation.timeAggregation}`}
|
||||
@@ -229,7 +233,10 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
<div className="metrics-space-aggregation-section">
|
||||
<div className="metrics-aggregation-section-content">
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<div className="metrics-aggregation-section-content-item-value">
|
||||
<div
|
||||
className="metrics-aggregation-section-content-item-value"
|
||||
data-testid="metrics-space-aggregation"
|
||||
>
|
||||
<SpaceAggregationOptions
|
||||
panelType={panelType}
|
||||
key={`${panelType}${queryAggregation.spaceAggregation}${queryAggregation.timeAggregation}`}
|
||||
|
||||
@@ -332,6 +332,7 @@ function HavingFilter({
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
data-testid="having-editor"
|
||||
className="having-filter-select-editor"
|
||||
width="100%"
|
||||
extensions={[
|
||||
|
||||
@@ -615,6 +615,7 @@ function QueryAggregationSelect({
|
||||
setInput(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
data-testid="query-aggregation-editor"
|
||||
className={`query-aggregation-select-editor ${
|
||||
validationError ? 'error' : ''
|
||||
}`}
|
||||
|
||||
@@ -1607,6 +1607,7 @@ function QuerySearch({
|
||||
onChange={handleChange}
|
||||
onUpdate={handleUpdate}
|
||||
onCreateEditor={handleEditorCreate}
|
||||
data-testid="query-where-clause-editor"
|
||||
className={cx('query-where-clause-editor', {
|
||||
isValid: validation.isValid === true,
|
||||
hasErrors: validation.errors.length > 0,
|
||||
@@ -1680,6 +1681,7 @@ function QuerySearch({
|
||||
className={cx('query-status-container', {
|
||||
hasErrors: validation.errors.length > 0,
|
||||
})}
|
||||
data-testid="query-validation-status"
|
||||
>
|
||||
<Popover
|
||||
placement="bottomRight"
|
||||
|
||||
@@ -183,6 +183,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
<div
|
||||
className={cx('query-v2', { 'where-clause-view': showOnlyWhereClause })}
|
||||
ref={ref}
|
||||
data-testid={`query-${query.queryName}`}
|
||||
>
|
||||
<div className="qb-content-section">
|
||||
{(!showOnlyWhereClause || showTraceOperator) && (
|
||||
@@ -246,6 +247,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
{isMultiQueryAllowed && (
|
||||
<Dropdown
|
||||
items={queryActionItems}
|
||||
testId="query-actions"
|
||||
nativeButton={false}
|
||||
align="end"
|
||||
side="bottom"
|
||||
|
||||
@@ -61,7 +61,10 @@ export default function TraceOperator({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}>
|
||||
<div
|
||||
className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}
|
||||
data-testid="trace-operator"
|
||||
>
|
||||
<div className="qb-trace-operator-container">
|
||||
<div
|
||||
className={cx(
|
||||
|
||||
@@ -378,6 +378,7 @@ function TraceOperatorEditor({
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
onChange={handleChange}
|
||||
onUpdate={handleUpdate}
|
||||
data-testid="trace-operator-editor"
|
||||
className={cx('query-where-clause-editor', {
|
||||
isValid: validation.isValid === true,
|
||||
hasErrors: validation.errors.length > 0,
|
||||
|
||||
@@ -147,6 +147,28 @@ describe('getAutoContexts', () => {
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAutoContexts(ROUTES.LIST_ALL_ALERT, '?tab=RoutingPolicies'),
|
||||
).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAutoContexts(ROUTES.LIST_ALL_ALERT, '?tab=PlannedDowntime'),
|
||||
).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: { page: 'alert_list' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns dashboard detail context on dashboard page', () => {
|
||||
|
||||
@@ -58,6 +58,12 @@ describe('resolvePageType', () => {
|
||||
expect(resolvePageType(ROUTES.LIST_ALL_ALERT, '?tab=Configuration')).toBe(
|
||||
PageTypeDTO.alert_list,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.LIST_ALL_ALERT, '?tab=RoutingPolicies')).toBe(
|
||||
PageTypeDTO.alert_list,
|
||||
);
|
||||
expect(resolvePageType(ROUTES.LIST_ALL_ALERT, '?tab=PlannedDowntime')).toBe(
|
||||
PageTypeDTO.alert_list,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns log_detail when logs explorer has activeLogId', () => {
|
||||
|
||||
@@ -30,6 +30,19 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// jsdom has no layout, and Floating UI's getComputedStyle walk over every
|
||||
// popover ancestor costs ~1 s per open here, enough to time out under load.
|
||||
jest.mock('@floating-ui/dom', () => ({
|
||||
...jest.requireActual('@floating-ui/dom'),
|
||||
autoUpdate: (): (() => void) => (): void => {},
|
||||
computePosition: (
|
||||
_reference: unknown,
|
||||
_floating: unknown,
|
||||
{ placement = 'bottom', strategy = 'absolute' } = {},
|
||||
): Promise<unknown> =>
|
||||
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
|
||||
}));
|
||||
|
||||
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
@@ -90,7 +103,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
});
|
||||
|
||||
it('keeps save disabled until the project IDs actually change', async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.getByTestId('gcp-update-account-btn')).toHaveAttribute(
|
||||
@@ -109,7 +122,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
});
|
||||
|
||||
it('sends the updated project IDs while preserving the immutable deployment fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDrawer();
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
@@ -152,7 +165,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
});
|
||||
|
||||
it('blocks the update and shows a validation error when every project ID is removed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDrawer();
|
||||
|
||||
// Strip every tag via its remove icon; the list shrinks as we go.
|
||||
@@ -188,7 +201,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDrawer();
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
|
||||
@@ -55,7 +54,7 @@ function LiveLogsContainer({
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Card } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import ListLogView from 'components/Logs/ListLogView';
|
||||
@@ -31,7 +30,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useEventSource } from 'providers/EventSource';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
@@ -69,7 +68,7 @@ function LiveLogsList({
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -17,7 +16,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import { useContextLogData } from './useContextLogData';
|
||||
@@ -46,7 +45,7 @@ function ContextLogRenderer({
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
@@ -78,7 +77,7 @@ function LogsExplorerList({
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator:
|
||||
currentStagedQueryData?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
@@ -29,7 +28,7 @@ function LogsActionsContainer({
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { createElement, ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueries } from 'react-query';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import {
|
||||
FormattingOptions,
|
||||
PreferenceContextValue,
|
||||
PreferenceSlice,
|
||||
} from 'providers/preferences/types';
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { FontSize } from '../types';
|
||||
import useOptionsMenu from '../useOptionsMenu';
|
||||
|
||||
// Mock all dependencies
|
||||
@@ -22,118 +13,104 @@ jest.mock('hooks/useNotifications');
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider');
|
||||
jest.mock('hooks/useUrlQueryData');
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
|
||||
const mockUseNotifications = jest.mocked(useNotifications);
|
||||
const mockUsePreferenceContext = jest.mocked(usePreferenceContext);
|
||||
const mockUseUrlQueryData = jest.mocked(useUrlQueryData);
|
||||
const mockUseGetQueryKeySuggestions = jest.mocked(useGetQueryKeySuggestions);
|
||||
|
||||
type KeySuggestionsResult = ReturnType<typeof useGetQueryKeySuggestions>;
|
||||
|
||||
// The hook reads only `data.data.data.keys` and `isFetching`.
|
||||
const keySuggestions = (
|
||||
attributeKeys: Record<string, string>[],
|
||||
): KeySuggestionsResult =>
|
||||
({
|
||||
data: { data: { data: { complete: true, keys: { attributeKeys } } } },
|
||||
isFetching: false,
|
||||
}) as unknown as KeySuggestionsResult;
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element =>
|
||||
createElement(QueryClientProvider, { client: new QueryClient() }, children);
|
||||
|
||||
const toIds = (columns: TelemetryFieldKey[]): string[] =>
|
||||
columns.map((column) => `${column.fieldContext}:${column.name}`);
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useOptionsMenu', () => {
|
||||
const mockNotifications = {
|
||||
error: jest.fn() as jest.MockedFunction<NotificationInstance['error']>,
|
||||
success: jest.fn() as jest.MockedFunction<NotificationInstance['success']>,
|
||||
} as Partial<NotificationInstance> as NotificationInstance;
|
||||
const mockUpdateColumns = jest.fn() as jest.MockedFunction<
|
||||
PreferenceSlice['updateColumns']
|
||||
>;
|
||||
const mockUpdateFormatting = jest.fn() as jest.MockedFunction<
|
||||
PreferenceSlice['updateFormatting']
|
||||
>;
|
||||
const mockRedirectWithQuery = jest.fn() as jest.MockedFunction<
|
||||
(newQueryData: unknown) => void
|
||||
>;
|
||||
|
||||
const slice = (
|
||||
columns: TelemetryFieldKey[],
|
||||
formatting: FormattingOptions,
|
||||
): PreferenceSlice => ({
|
||||
preferences: { columns, formatting },
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
});
|
||||
|
||||
const rawFormatting: FormattingOptions = {
|
||||
format: 'raw',
|
||||
maxLines: 1,
|
||||
fontSize: FontSize.SMALL,
|
||||
};
|
||||
const mockNotifications = { error: jest.fn(), success: jest.fn() };
|
||||
const mockUpdateColumns = jest.fn();
|
||||
const mockUpdateFormatting = jest.fn();
|
||||
const mockRedirectWithQuery = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseNotifications.mockReturnValue({
|
||||
(useNotifications as jest.Mock).mockReturnValue({
|
||||
notifications: mockNotifications,
|
||||
});
|
||||
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
|
||||
} satisfies PreferenceContextValue);
|
||||
(usePreferenceContext as jest.Mock).mockReturnValue({
|
||||
traces: {
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
format: 'raw',
|
||||
maxLines: 1,
|
||||
fontSize: 'small',
|
||||
},
|
||||
},
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
logs: {
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
format: 'raw',
|
||||
maxLines: 1,
|
||||
fontSize: 'small',
|
||||
},
|
||||
},
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
});
|
||||
|
||||
mockUseUrlQueryData.mockReturnValue({
|
||||
(useUrlQueryData as jest.Mock).mockReturnValue({
|
||||
query: null,
|
||||
queryData: undefined,
|
||||
redirectWithQuery: mockRedirectWithQuery,
|
||||
});
|
||||
|
||||
(useQueries as jest.Mock).mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('does not show isRoot or isEntryPoint in column options when sourcePage is traces', () => {
|
||||
it('does not show isRoot or isEntryPoint in column options when dataSource is TRACES', () => {
|
||||
// Mock the query key suggestions to return data including isRoot and isEntryPoint
|
||||
mockUseGetQueryKeySuggestions.mockReturnValue(
|
||||
keySuggestions([
|
||||
{
|
||||
name: 'isRoot',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'bool',
|
||||
fieldContext: '',
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{
|
||||
name: 'isRoot',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'bool',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'isEntryPoint',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'bool',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'serviceName',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'isEntryPoint',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'bool',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'serviceName',
|
||||
signal: 'traces',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
{ wrapper },
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the column options from the config
|
||||
@@ -150,38 +127,98 @@ describe('useOptionsMenu', () => {
|
||||
expect(optionNames).toContain('serviceName');
|
||||
});
|
||||
|
||||
it('shows body in column options when sourcePage is logs', () => {
|
||||
it('does not show body in column options when dataSource is METRICS', () => {
|
||||
// Mock the query key suggestions to return data including body
|
||||
mockUseGetQueryKeySuggestions.mockReturnValue(
|
||||
keySuggestions([
|
||||
{
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
signal: 'metrics',
|
||||
fieldDataType: 'int64',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
signal: 'metrics',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'int64',
|
||||
fieldContext: '',
|
||||
},
|
||||
]),
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
{ wrapper },
|
||||
// Get the column options from the config
|
||||
const columnOptions = result.current.config.addColumn?.options ?? [];
|
||||
const optionNames = columnOptions.map((option) => option.label);
|
||||
|
||||
// body should NOT be in the options
|
||||
expect(optionNames).not.toContain('body');
|
||||
|
||||
// Other attributes should be present
|
||||
expect(optionNames).toContain('status');
|
||||
expect(optionNames).toContain('value');
|
||||
});
|
||||
|
||||
it('does not show body in column options when dataSource is LOGS', () => {
|
||||
// Mock the query key suggestions to return data including body
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldDataType: 'int64',
|
||||
fieldContext: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the column options from the config
|
||||
@@ -197,15 +234,15 @@ describe('useOptionsMenu', () => {
|
||||
});
|
||||
|
||||
describe('reorderSelectColumns / handleRemoveSelectedColumn — composite ID lookup', () => {
|
||||
const baseFormatting: FormattingOptions = {
|
||||
const baseFormatting = {
|
||||
format: 'table',
|
||||
maxLines: 1,
|
||||
fontSize: FontSize.SMALL,
|
||||
fontSize: 'small',
|
||||
};
|
||||
|
||||
// Two entries share the same `name` but differ in `fieldContext` — the
|
||||
// exact case composite IDs are meant to disambiguate.
|
||||
const seedColumns: TelemetryFieldKey[] = [
|
||||
const seedColumns = [
|
||||
{
|
||||
name: 'body',
|
||||
fieldContext: 'log',
|
||||
@@ -227,26 +264,36 @@ describe('useOptionsMenu', () => {
|
||||
{
|
||||
name: 'timestamp',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
signal: 'logs',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
|
||||
} satisfies PreferenceContextValue);
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: { data: { data: { keys: {} } } },
|
||||
isFetching: false,
|
||||
});
|
||||
(usePreferenceContext as jest.Mock).mockReturnValue({
|
||||
traces: {
|
||||
preferences: { columns: [], formatting: baseFormatting },
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
logs: {
|
||||
preferences: { columns: seedColumns, formatting: baseFormatting },
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reorders by composite IDs — preserves both same-name variants distinctly', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
{ wrapper },
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
|
||||
@@ -259,7 +306,12 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
expect(toIds(reordered)).toStrictEqual([
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'attribute:service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
@@ -268,13 +320,11 @@ describe('useOptionsMenu', () => {
|
||||
});
|
||||
|
||||
it('reorder ignores ids that are not columns (e.g. state-indicator) and skips unknown ids', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
{ wrapper },
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.config.addColumn?.onReorder([
|
||||
@@ -288,7 +338,12 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
// state-indicator + unknown.composite filtered; rest preserved in given order.
|
||||
expect(toIds(reordered)).toStrictEqual([
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'log:timestamp',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
@@ -297,13 +352,11 @@ describe('useOptionsMenu', () => {
|
||||
});
|
||||
|
||||
it('removes only the variant whose composite ID matches', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
{ wrapper },
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
// Removing 'resource:service.name' should drop ONLY the resource variant.
|
||||
@@ -311,21 +364,20 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const remaining = mockUpdateColumns.mock.calls[0][0];
|
||||
expect(toIds(remaining)).toStrictEqual([
|
||||
'log:body',
|
||||
'attribute:service.name',
|
||||
'log:timestamp',
|
||||
]);
|
||||
expect(
|
||||
remaining.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
|
||||
});
|
||||
|
||||
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
{ wrapper },
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.config.addColumn?.onRemove('unknown.composite');
|
||||
@@ -338,34 +390,35 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
describe('fieldsSelector.value drops legacy columns without a name', () => {
|
||||
it('excludes entries missing name while keeping valid columns', () => {
|
||||
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice(
|
||||
[
|
||||
{ name: 'body', fieldContext: 'log' },
|
||||
// A legacy stored column: `key` only, no `name`.
|
||||
{
|
||||
key: 'legacy-key-no-name',
|
||||
fieldContext: 'log',
|
||||
} as unknown as TelemetryFieldKey,
|
||||
{ name: 'timestamp', fieldContext: 'log' },
|
||||
],
|
||||
{
|
||||
format: 'table',
|
||||
maxLines: 1,
|
||||
fontSize: FontSize.SMALL,
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: { data: { data: { keys: {} } } },
|
||||
isFetching: false,
|
||||
});
|
||||
(usePreferenceContext as jest.Mock).mockReturnValue({
|
||||
traces: {
|
||||
preferences: {
|
||||
columns: [
|
||||
{ name: 'body', fieldContext: 'log' },
|
||||
{ key: 'legacy-key-no-name', fieldContext: 'log' },
|
||||
{ name: 'timestamp', fieldContext: 'log' },
|
||||
],
|
||||
formatting: { format: 'table', maxLines: 1, fontSize: 'small' },
|
||||
},
|
||||
),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
logs: {
|
||||
preferences: { columns: [], formatting: {} },
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
{ wrapper },
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
const fields = result.current.config.fieldsSelector?.value ?? [];
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PREFERENCE_SOURCE_PAGES } from 'providers/preferences/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { FontSize, OptionsQuery } from './types';
|
||||
@@ -15,14 +13,6 @@ export const defaultOptionsQuery: OptionsQuery = {
|
||||
fontSize: FontSize.SMALL,
|
||||
};
|
||||
|
||||
export const SOURCE_PAGE_SIGNAL: Record<
|
||||
(typeof PREFERENCE_SOURCE_PAGES)[number],
|
||||
DataSource
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
|
||||
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
|
||||
[DataSource.TRACES]: ['body', 'isRoot', 'isEntryPoint'],
|
||||
[DataSource.METRICS]: ['body'],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { AxiosResponse } from 'axios';
|
||||
@@ -12,7 +11,6 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { has } from 'lodash-es';
|
||||
import { AllTraceFilterKeyValue } from 'constants/traceFilterKeys';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { PreferenceSourcePage } from 'providers/preferences/types';
|
||||
import {
|
||||
QueryKeyRequestProps,
|
||||
QueryKeySuggestionsResponseProps,
|
||||
@@ -22,13 +20,13 @@ import {
|
||||
FieldDataType,
|
||||
SignalType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultOptionsQuery,
|
||||
defaultTraceSelectedColumns,
|
||||
EXCLUDED_COLUMNS,
|
||||
SOURCE_PAGE_SIGNAL,
|
||||
URL_OPTIONS,
|
||||
} from './constants';
|
||||
import {
|
||||
@@ -42,7 +40,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
|
||||
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
sourcePage: PreferenceSourcePage;
|
||||
dataSource: DataSource;
|
||||
aggregateOperator?: string;
|
||||
initialOptions?: InitialOptions;
|
||||
}
|
||||
@@ -54,14 +52,15 @@ interface UseOptionsMenu {
|
||||
}
|
||||
|
||||
const useOptionsMenu = ({
|
||||
sourcePage,
|
||||
dataSource,
|
||||
// aggregateOperator,
|
||||
initialOptions = {},
|
||||
}: UseOptionsMenuProps): UseOptionsMenu => {
|
||||
const signal = SOURCE_PAGE_SIGNAL[sourcePage];
|
||||
const { notifications } = useNotifications();
|
||||
const prefCtx = usePreferenceContext();
|
||||
// TODO: send null to updateColumns and updateFormatting if dataSource is not logs or traces
|
||||
const { preferences, updateColumns, updateFormatting } =
|
||||
usePreferenceContext()[sourcePage];
|
||||
const slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
|
||||
const { preferences, updateColumns, updateFormatting } = slice;
|
||||
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
@@ -69,10 +68,10 @@ const useOptionsMenu = ({
|
||||
|
||||
const initialQueryParamsV5: QueryKeyRequestProps = useMemo(
|
||||
() => ({
|
||||
signal,
|
||||
signal: dataSource,
|
||||
searchText: '',
|
||||
}),
|
||||
[signal],
|
||||
[dataSource],
|
||||
);
|
||||
|
||||
const { query: optionsQuery, redirectWithQuery: redirectWithOptionsData } =
|
||||
@@ -128,7 +127,7 @@ const useOptionsMenu = ({
|
||||
?.map((column) => attributesData.find(({ name }) => name === column))
|
||||
.filter((e) => !!e) || []) as TelemetryFieldKey[];
|
||||
|
||||
if (sourcePage === SavedviewtypesSourceDTO.traces) {
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
initialSelected = initialSelected
|
||||
?.map((col) => {
|
||||
if (col && Object.keys(AllTraceFilterKeyValue).includes(col?.name)) {
|
||||
@@ -158,7 +157,7 @@ const useOptionsMenu = ({
|
||||
isFetchedInitialAttributes,
|
||||
initialOptions?.selectColumns,
|
||||
initialAttributesResult,
|
||||
sourcePage,
|
||||
dataSource,
|
||||
]);
|
||||
|
||||
const {
|
||||
@@ -196,7 +195,7 @@ const useOptionsMenu = ({
|
||||
fieldDataType: e.fieldDataType,
|
||||
}));
|
||||
}
|
||||
if (sourcePage === SavedviewtypesSourceDTO.traces) {
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return defaultTraceSelectedColumns.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
@@ -204,16 +203,16 @@ const useOptionsMenu = ({
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [sourcePage, searchedAttributesDataV5?.data.data.keys]);
|
||||
}, [dataSource, searchedAttributesDataV5?.data.data.keys]);
|
||||
|
||||
const initialOptionsQuery: OptionsQuery = useMemo(() => {
|
||||
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
|
||||
if (sourcePage === SavedviewtypesSourceDTO.traces) {
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
defaultColumns = defaultTraceSelectedColumns.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
}));
|
||||
} else if (sourcePage === SavedviewtypesSourceDTO.logs) {
|
||||
} else if (dataSource === DataSource.LOGS) {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
defaultColumns = defaultLogsSelectedColumns.map((e) => ({
|
||||
...e,
|
||||
@@ -233,7 +232,7 @@ const useOptionsMenu = ({
|
||||
...initialOptions,
|
||||
selectColumns: finalSelectColumns,
|
||||
};
|
||||
}, [sourcePage, initialOptions, initialSelectedColumns]);
|
||||
}, [dataSource, initialOptions, initialSelectedColumns]);
|
||||
|
||||
const selectedColumnKeys = useMemo(
|
||||
() => preferences?.columns?.map(({ name }) => name) || [],
|
||||
@@ -242,7 +241,7 @@ const useOptionsMenu = ({
|
||||
|
||||
const optionsFromAttributeKeys = useMemo(() => {
|
||||
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
|
||||
const exclusions = EXCLUDED_COLUMNS[signal];
|
||||
const exclusions = EXCLUDED_COLUMNS[dataSource];
|
||||
if (exclusions) {
|
||||
return !exclusions.includes(item.name);
|
||||
}
|
||||
@@ -250,7 +249,7 @@ const useOptionsMenu = ({
|
||||
});
|
||||
|
||||
return getOptionsFromKeys(filteredAttributeKeys, selectedColumnKeys);
|
||||
}, [signal, searchedAttributeKeys, selectedColumnKeys]);
|
||||
}, [dataSource, searchedAttributeKeys, selectedColumnKeys]);
|
||||
|
||||
const handleRedirectWithOptionsData = useCallback(
|
||||
(newQueryData: OptionsQuery) => {
|
||||
@@ -286,10 +285,7 @@ const useOptionsMenu = ({
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
|
||||
);
|
||||
|
||||
if (
|
||||
!newSelectedColumns?.length &&
|
||||
sourcePage !== SavedviewtypesSourceDTO.logs
|
||||
) {
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
notifications.error({
|
||||
message: 'There must be at least one selected column',
|
||||
});
|
||||
@@ -298,7 +294,7 @@ const useOptionsMenu = ({
|
||||
|
||||
updateColumns(newSelectedColumns || []);
|
||||
},
|
||||
[sourcePage, notifications, preferences, updateColumns],
|
||||
[dataSource, notifications, preferences, updateColumns],
|
||||
);
|
||||
|
||||
const handleFormatChange = useCallback(
|
||||
|
||||
55
frontend/src/container/PlannedDowntime/AlertRuleTags.tsx
Normal file
55
frontend/src/container/PlannedDowntime/AlertRuleTags.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Pill } from '@signozhq/ui/pill';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { DowntimeAlertOption } from './types';
|
||||
|
||||
interface AlertRuleTagsProps {
|
||||
selectedTags: DowntimeAlertOption[];
|
||||
closable: boolean;
|
||||
handleClose?: (removedTag: DowntimeAlertOption['value']) => void;
|
||||
classname?: string;
|
||||
}
|
||||
export function AlertRuleTags(props: AlertRuleTagsProps): JSX.Element {
|
||||
const { closable, selectedTags, handleClose, classname } = props;
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}
|
||||
className={cx('alert-rule-tags', classname)}
|
||||
>
|
||||
{selectedTags?.map((tag: DowntimeAlertOption, index: number) => {
|
||||
if (closable) {
|
||||
return (
|
||||
<Pill.Closeable
|
||||
key={tag.value}
|
||||
maxWidth={200}
|
||||
onClose={(): void => handleClose?.(tag.value)}
|
||||
>
|
||||
{tag.label}
|
||||
</Pill.Closeable>
|
||||
);
|
||||
}
|
||||
|
||||
const isLongTag = (tag?.label as string)?.length > 20;
|
||||
const tagElem = (
|
||||
<Badge
|
||||
textTransform="none"
|
||||
key={tag.value}
|
||||
color={index % 2 ? 'highlight-danger' : 'primary'}
|
||||
variant="outlined"
|
||||
>
|
||||
{isLongTag
|
||||
? `${(tag?.label as string | null)?.slice(0, 20)}...`
|
||||
: tag?.label}
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Tooltip title={isLongTag ? tag?.label : undefined} key={tag?.value}>
|
||||
{tagElem}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.headerBand {
|
||||
--control-height: 2rem;
|
||||
--input-height: var(--control-height);
|
||||
--input-wrapper-height: var(--control-height);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1 1 0;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.actionsHeader {
|
||||
display: block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l2-background-active);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
--tanstack-table-row-odd-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 2%,
|
||||
transparent
|
||||
);
|
||||
--tanstack-table-row-even-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 1%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding-right: var(--spacing-12);
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.expression {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeText {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.emptySubtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -109,191 +109,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.planned-downtime-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.planned-downtime-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 736px;
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(---bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.schedule-created-at {
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
border-top: 0px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
gap: 6px;
|
||||
height: 40px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on,
|
||||
'cpsp' on,
|
||||
'case' on;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
height: 56px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on,
|
||||
'cpsp' on,
|
||||
'case' on;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-downtime-menu {
|
||||
.create-downtime-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.planned-downtime-table {
|
||||
.ant-table {
|
||||
background: none !important;
|
||||
}
|
||||
.ant-table-cell {
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
width: 736px;
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.collapse-list {
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
|
||||
.render-item-collapse-list {
|
||||
margin-bottom: 13px;
|
||||
display: grid;
|
||||
grid-template-columns: 128px 1fr;
|
||||
}
|
||||
|
||||
.alert-rule-collapse-list {
|
||||
width: 540px;
|
||||
max-height: 100px;
|
||||
overflow: auto;
|
||||
}
|
||||
.ant-collapse-content-active {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none;
|
||||
}
|
||||
.ant-collapse-content-box {
|
||||
padding: 8px 20px 12px 38px;
|
||||
|
||||
.render-item-value {
|
||||
.ant-typography {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-schedule-modal {
|
||||
width: calc(100% - 30px) !important; /* Adjust the 20px as needed */
|
||||
max-width: 384px;
|
||||
|
||||
@@ -1,36 +1,51 @@
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Flex, Form, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Form } from 'antd';
|
||||
import {
|
||||
useDeleteDowntimeScheduleByID,
|
||||
useListDowntimeSchedules,
|
||||
} from 'api/generated/services/downtimeschedules';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import dayjs from 'dayjs';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import 'dayjs/locale/en';
|
||||
|
||||
import styles from './PlannedDowntime.module.scss';
|
||||
import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
|
||||
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
|
||||
import { PlannedDowntimeList } from './PlannedDowntimeList';
|
||||
import PlannedDowntimeList from './PlannedDowntimeList';
|
||||
import {
|
||||
defaultInitialValues,
|
||||
deleteDowntimeHandler,
|
||||
} from './PlannedDowntimeutils';
|
||||
import { getPlannedDowntimeColumns } from './table.config';
|
||||
import { DowntimeSchedulesTableData } from './types';
|
||||
import usePlannedDowntimeTable from './usePlannedDowntimeTable';
|
||||
|
||||
import './PlannedDowntime.styles.scss';
|
||||
|
||||
const COLUMNS_STORAGE_KEY = 'planned-downtime-columns';
|
||||
|
||||
dayjs.locale('en');
|
||||
|
||||
export function PlannedDowntime(): JSX.Element {
|
||||
@@ -69,7 +84,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
}
|
||||
}, [form, isOpen]);
|
||||
|
||||
const [searchValue, setSearchValue] = React.useState<string | number>(
|
||||
const [searchValue, setSearchValue] = React.useState<string>(
|
||||
urlQuery.get('search') || '',
|
||||
);
|
||||
const [deleteData, setDeleteData] = useState<{ id: string; name: string }>();
|
||||
@@ -86,16 +101,62 @@ export function PlannedDowntime(): JSX.Element {
|
||||
history.replace(url);
|
||||
}, 300);
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
calculatedPageSize,
|
||||
filteredDowntimes,
|
||||
pagedDowntimes,
|
||||
setPage,
|
||||
setLimit,
|
||||
} = usePlannedDowntimeTable(
|
||||
downtimeSchedules.data?.data || [],
|
||||
alertOptions || [],
|
||||
searchValue,
|
||||
);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchValue(e.target.value);
|
||||
updateUrlWithSearch(e.target.value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const clearSearch = (): void => {
|
||||
setSearchValue('');
|
||||
updateUrlWithSearch('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const canEdit = user?.role !== USER_ROLES.VIEWER;
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const columns = React.useMemo(
|
||||
() => getPlannedDowntimeColumns(formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback((): void => {
|
||||
setInitialValues(defaultInitialValues);
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
}, [form]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(downtime: DowntimeSchedulesTableData): void => {
|
||||
setInitialValues({ ...downtime });
|
||||
setIsOpen(true);
|
||||
setEditMode(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(downtime: DowntimeSchedulesTableData): void => {
|
||||
setDeleteData({ id: downtime.id, name: downtime.name });
|
||||
setIsDeleteModalOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Delete Downtime Schedule
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const { notifications } = useNotifications();
|
||||
@@ -125,78 +186,107 @@ export function PlannedDowntime(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const isFetching = useMemo(
|
||||
() => downtimeSchedules.isLoading || downtimeSchedules.isFetching,
|
||||
[downtimeSchedules.isLoading, downtimeSchedules.isFetching],
|
||||
);
|
||||
const totalDowntimes = useMemo(
|
||||
() => downtimeSchedules.data?.data?.length ?? 0,
|
||||
[downtimeSchedules.data?.data?.length],
|
||||
);
|
||||
const isEmpty = useMemo(
|
||||
() => !isFetching && !searchValue && totalDowntimes === 0,
|
||||
[isFetching, searchValue, totalDowntimes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="planned-downtime-container">
|
||||
<div className="planned-downtime-content">
|
||||
<Typography.Title className="title">Planned Downtime</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage planned downtimes.
|
||||
</Typography.Text>
|
||||
<Flex className="toolbar">
|
||||
<div className={styles.container}>
|
||||
<header className={styles.headerBand}>
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>
|
||||
Planned Downtime
|
||||
</Typography.Title>
|
||||
<span className={styles.countPill}>{totalDowntimes}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
{!isEmpty && (
|
||||
<ColumnSelector
|
||||
columns={columns}
|
||||
storageKey={COLUMNS_STORAGE_KEY}
|
||||
testId="planned-downtime-columns"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleCreate}
|
||||
disabled={!canEdit}
|
||||
disabledTooltip="You need edit permissions to create a planned downtime"
|
||||
testId="planned-downtime-create"
|
||||
>
|
||||
New downtime
|
||||
</Button>
|
||||
<TextToolTip
|
||||
text="More details on how to schedule planned downtime"
|
||||
url="https://signoz.io/docs/alerts-management/planned-maintenance/"
|
||||
urlText="Learn More"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.filtersRow}>
|
||||
<Input
|
||||
placeholder="Search for a planned downtime..."
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
containerClassName={styles.searchInput}
|
||||
placeholder="Search by name"
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="planned-downtime-search"
|
||||
/>
|
||||
<Tooltip
|
||||
title={
|
||||
user?.role === USER_ROLES.VIEWER
|
||||
? 'You need edit permissions to create a planned downtime'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues(defaultInitialValues);
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
disabled={user?.role === USER_ROLES.VIEWER}
|
||||
>
|
||||
<Flex align="center" gap={4}>
|
||||
<Plus size={16} />
|
||||
New downtime
|
||||
</Flex>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<br />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
<PlannedDowntimeList
|
||||
downtimeSchedules={downtimeSchedules}
|
||||
alertOptions={alertOptions || []}
|
||||
setInitialValues={setInitialValues}
|
||||
setModalOpen={setIsOpen}
|
||||
handleDeleteDowntime={(id, name): void => {
|
||||
setDeleteData({ id, name });
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
setEditMode={setEditMode}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
{isOpen && (
|
||||
<PlannedDowntimeForm
|
||||
alertOptions={alertOptions || []}
|
||||
initialValues={initialValues}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
refetchAllSchedules={refetchAllSchedules}
|
||||
isEditMode={isEditMode}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
<PlannedDowntimeDeleteModal
|
||||
isDeleteLoading={isDeleteLoading}
|
||||
isDeleteModalOpen={isDeleteModalOpen}
|
||||
onDeleteHandler={onDeleteHandler}
|
||||
setIsDeleteModalOpen={setIsDeleteModalOpen}
|
||||
downtimeSchedule={deleteData?.name || ''}
|
||||
columns={columns}
|
||||
downtimes={filteredDowntimes}
|
||||
pagedDowntimes={pagedDowntimes}
|
||||
calculatedPageSize={calculatedPageSize}
|
||||
onLimitChange={setLimit}
|
||||
isFetching={isFetching}
|
||||
isError={downtimeSchedules.isError}
|
||||
refetch={refetchAllSchedules}
|
||||
canEdit={canEdit}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCreate={handleCreate}
|
||||
hasSearchTerm={searchValue.length > 0}
|
||||
onClearSearch={clearSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<PlannedDowntimeForm
|
||||
alertOptions={alertOptions || []}
|
||||
initialValues={initialValues}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
refetchAllSchedules={refetchAllSchedules}
|
||||
isEditMode={isEditMode}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
<PlannedDowntimeDeleteModal
|
||||
isDeleteLoading={isDeleteLoading}
|
||||
isDeleteModalOpen={isDeleteModalOpen}
|
||||
onDeleteHandler={onDeleteHandler}
|
||||
setIsDeleteModalOpen={setIsDeleteModalOpen}
|
||||
downtimeSchedule={deleteData?.name || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
|
||||
import styles from './PlannedDowntime.module.scss';
|
||||
import { DowntimeSchedulesTableData } from './types';
|
||||
|
||||
interface PlannedDowntimeActionsMenuProps {
|
||||
downtime: DowntimeSchedulesTableData;
|
||||
onEdit: (downtime: DowntimeSchedulesTableData) => void;
|
||||
onDelete: (downtime: DowntimeSchedulesTableData) => void;
|
||||
}
|
||||
|
||||
function PlannedDowntimeActionsMenu({
|
||||
downtime,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: PlannedDowntimeActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<DropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'edit',
|
||||
label: 'Edit',
|
||||
prefix: <PenLine size={14} />,
|
||||
onClick: (): void => onEdit(downtime),
|
||||
},
|
||||
{ type: 'separator', value: 'before-delete' },
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete',
|
||||
label: 'Delete',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
onClick: (): void => onDelete(downtime),
|
||||
},
|
||||
],
|
||||
[downtime, onEdit, onDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
// the row itself opens the downtime, so the actions keep their clicks
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.actions}
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
testId={`downtime-actions-${downtime.id}`}
|
||||
aria-label={`Actions for ${downtime.name}`}
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlannedDowntimeActionsMenu;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CalendarClock, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PlannedDowntime.module.scss';
|
||||
|
||||
const DOCS_URL =
|
||||
'https://signoz.io/docs/alerts-management/planned-maintenance/';
|
||||
|
||||
interface PlannedDowntimeEmptyStateProps {
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function PlannedDowntimeEmptyState({
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: PlannedDowntimeEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="planned-downtime-empty-state">
|
||||
<CalendarClock className={styles.emptyIcon} size={16} />
|
||||
<Typography.Text className={styles.emptyTitle}>
|
||||
No planned downtime yet
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.emptySubtitle}>
|
||||
A planned downtime silences alert rules for a window you schedule, once or
|
||||
on repeat, so maintenance does not page anyone.
|
||||
</Typography.Text>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onCreate}
|
||||
testId="planned-downtime-empty-create"
|
||||
>
|
||||
New downtime
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the planned downtime guide
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlannedDowntimeEmptyState;
|
||||
@@ -43,7 +43,7 @@ import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
|
||||
|
||||
import 'dayjs/locale/en';
|
||||
|
||||
import { AlertRuleTags } from './PlannedDowntimeList';
|
||||
import { AlertRuleTags } from './AlertRuleTags';
|
||||
import {
|
||||
getAlertOptionsFromIds,
|
||||
getDurationInfo,
|
||||
|
||||
@@ -1,379 +1,109 @@
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Collapse, Flex, Space, Table, TableProps, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Pill } from '@signozhq/ui/pill';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
AlertmanagertypesPlannedMaintenanceDTO,
|
||||
AlertmanagertypesScheduleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import cx from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { CalendarClock, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { useMemo } from 'react';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import {
|
||||
formatDateTime,
|
||||
getAlertOptionsFromIds,
|
||||
getDuration,
|
||||
recurrenceInfo,
|
||||
} from './PlannedDowntimeutils';
|
||||
import styles from './PlannedDowntime.module.scss';
|
||||
import PlannedDowntimeActionsMenu from './PlannedDowntimeActionsMenu';
|
||||
import PlannedDowntimeEmptyState from './PlannedDowntimeEmptyState';
|
||||
import { DowntimeSchedulesTableData, PlannedDowntimeListProps } from './types';
|
||||
import { PLANNED_DOWNTIME_QUERY_PARAMS } from './usePlannedDowntimeTable';
|
||||
|
||||
import './PlannedDowntime.styles.scss';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface AlertRuleTagsProps {
|
||||
selectedTags: DefaultOptionType | DefaultOptionType[];
|
||||
closable: boolean;
|
||||
handleClose?: (removedTag: DefaultOptionType['value']) => void;
|
||||
classname?: string;
|
||||
}
|
||||
export function AlertRuleTags(props: AlertRuleTagsProps): JSX.Element {
|
||||
const { closable, selectedTags, handleClose, classname } = props;
|
||||
return (
|
||||
<Space
|
||||
wrap
|
||||
style={{ marginBottom: 8 }}
|
||||
className={cx('alert-rule-tags', classname)}
|
||||
>
|
||||
{selectedTags?.map((tag: DefaultOptionType, index: number) => {
|
||||
if (closable) {
|
||||
return (
|
||||
<Pill.Closeable
|
||||
key={tag.value}
|
||||
maxWidth={200}
|
||||
onClose={(): void => handleClose?.(tag.value)}
|
||||
>
|
||||
{tag.label}
|
||||
</Pill.Closeable>
|
||||
);
|
||||
}
|
||||
|
||||
const isLongTag = (tag?.label as string)?.length > 20;
|
||||
const tagElem = (
|
||||
<Badge
|
||||
textTransform="none"
|
||||
key={tag.value}
|
||||
color={index % 2 ? 'highlight-danger' : 'primary'}
|
||||
variant="outlined"
|
||||
>
|
||||
{isLongTag
|
||||
? `${(tag?.label as string | null)?.slice(0, 20)}...`
|
||||
: tag?.label}
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Tooltip title={isLongTag ? tag?.label : undefined} key={tag?.value}>
|
||||
{tagElem}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderComponent({
|
||||
name,
|
||||
duration,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}: {
|
||||
name: string;
|
||||
duration: string;
|
||||
handleEdit: () => void;
|
||||
handleDelete: () => void;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const isCrudEnabled = user?.role !== USER_ROLES.VIEWER;
|
||||
return (
|
||||
<Flex className="header-content" justify="space-between">
|
||||
<Flex gap={8}>
|
||||
<Typography>{name}</Typography>
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{duration}
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{isCrudEnabled && (
|
||||
<div className="action-btn">
|
||||
<PenLine
|
||||
size={14}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
/>
|
||||
<Trash2
|
||||
size={14}
|
||||
color={Color.BG_CHERRY_500}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollapseListContent({
|
||||
created_at,
|
||||
created_by_name,
|
||||
created_by_email,
|
||||
schedule,
|
||||
updated_at,
|
||||
updated_by_name,
|
||||
alertOptions,
|
||||
}: {
|
||||
created_at?: string;
|
||||
created_by_name?: string;
|
||||
created_by_email?: string;
|
||||
schedule?: AlertmanagertypesScheduleDTO;
|
||||
updated_at?: string;
|
||||
updated_by_name?: string;
|
||||
alertOptions?: DefaultOptionType[];
|
||||
}): JSX.Element {
|
||||
const renderItems = (title: string, value: ReactNode): JSX.Element => (
|
||||
<div className="render-item-collapse-list">
|
||||
<Typography>{title}</Typography>
|
||||
<div className="render-item-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
const startTime = formatDateTime(schedule?.startTime, schedule?.timezone);
|
||||
const endTime = formatDateTime(schedule?.endTime, schedule?.timezone);
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
{renderItems(
|
||||
'Created by',
|
||||
created_by_name ? (
|
||||
<Flex gap={8}>
|
||||
<Typography>{created_by_name}</Typography>
|
||||
{created_by_email && (
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{created_by_email}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
)}
|
||||
{renderItems(
|
||||
'Created on',
|
||||
created_at ? (
|
||||
<Typography>{`${formatDateTime(created_at)}`}</Typography>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
)}
|
||||
{updated_at &&
|
||||
renderItems(
|
||||
'Updated on',
|
||||
<Typography>{`${formatDateTime(updated_at)}`}</Typography>,
|
||||
)}
|
||||
{updated_by_name &&
|
||||
renderItems('Updated by', <Typography>{updated_by_name}</Typography>)}
|
||||
|
||||
{renderItems(
|
||||
'Timeframe',
|
||||
schedule?.startTime ? (
|
||||
<Typography>{`${startTime} ⎯ ${endTime}`}</Typography>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
)}
|
||||
{renderItems(
|
||||
'Timezone',
|
||||
<Typography>{schedule?.timezone || '-'}</Typography>,
|
||||
)}
|
||||
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
|
||||
{renderItems(
|
||||
'Alerts silenced',
|
||||
alertOptions?.length ? (
|
||||
<AlertRuleTags
|
||||
closable={false}
|
||||
classname="alert-rule-collapse-list"
|
||||
selectedTags={alertOptions}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="solid" color="secondary">
|
||||
All alert rules
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomCollapseList(
|
||||
props: DowntimeSchedulesTableData & {
|
||||
setInitialValues: React.Dispatch<
|
||||
React.SetStateAction<Partial<AlertmanagertypesPlannedMaintenanceDTO>>
|
||||
>;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleDeleteDowntime: (id: string, name: string) => void;
|
||||
setEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
},
|
||||
): JSX.Element {
|
||||
const {
|
||||
createdAt,
|
||||
createdBy,
|
||||
schedule,
|
||||
updatedAt,
|
||||
updatedBy,
|
||||
name,
|
||||
id,
|
||||
alertOptions,
|
||||
setInitialValues,
|
||||
setModalOpen,
|
||||
handleDeleteDowntime,
|
||||
setEditMode,
|
||||
} = props;
|
||||
|
||||
const scheduleTime = schedule?.startTime
|
||||
? dayjs(schedule.startTime).tz(schedule.timezone)
|
||||
: createdAt || '';
|
||||
const formattedDateAndTime = `Start time ⎯ ${formatDateTime(scheduleTime)} ${schedule?.timezone}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse accordion className="collapse-list">
|
||||
<Panel
|
||||
header={
|
||||
<HeaderComponent
|
||||
duration={
|
||||
schedule?.recurrence?.duration
|
||||
? schedule.recurrence.duration
|
||||
: getDuration(schedule?.startTime || '', schedule?.endTime || '')
|
||||
}
|
||||
name={defaultTo(name, '')}
|
||||
handleEdit={() => {
|
||||
setInitialValues({ ...props });
|
||||
setModalOpen(true);
|
||||
setEditMode(true);
|
||||
}}
|
||||
handleDelete={() => handleDeleteDowntime(id ?? '', name || '')}
|
||||
/>
|
||||
}
|
||||
key={id ?? ''}
|
||||
>
|
||||
<CollapseListContent
|
||||
created_at={createdAt ? dayjs(createdAt).toISOString() : ''}
|
||||
created_by_name={defaultTo(createdBy, '')}
|
||||
schedule={schedule}
|
||||
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
|
||||
updated_by_name={defaultTo(updatedBy, '')}
|
||||
alertOptions={alertOptions}
|
||||
/>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
<div className="schedule-created-at">
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>{formattedDateAndTime}</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type DowntimeSchedulesTableData =
|
||||
AlertmanagertypesPlannedMaintenanceDTO & {
|
||||
alertOptions: DefaultOptionType[];
|
||||
};
|
||||
|
||||
export function PlannedDowntimeList({
|
||||
downtimeSchedules,
|
||||
alertOptions,
|
||||
setInitialValues,
|
||||
setModalOpen,
|
||||
handleDeleteDowntime,
|
||||
setEditMode,
|
||||
searchValue,
|
||||
}: {
|
||||
downtimeSchedules: UseQueryResult<
|
||||
ListDowntimeSchedules200,
|
||||
ErrorType<RenderErrorResponseDTO>
|
||||
>;
|
||||
alertOptions: DefaultOptionType[];
|
||||
setInitialValues: React.Dispatch<
|
||||
React.SetStateAction<Partial<AlertmanagertypesPlannedMaintenanceDTO>>
|
||||
>;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleDeleteDowntime: (id: string, name: string) => void;
|
||||
setEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
searchValue: string | number;
|
||||
}): JSX.Element {
|
||||
const columns: TableProps<DowntimeSchedulesTableData>['columns'] = [
|
||||
{
|
||||
title: 'Downtime',
|
||||
key: 'downtime',
|
||||
render: (data: DowntimeSchedulesTableData): JSX.Element =>
|
||||
CustomCollapseList({
|
||||
...data,
|
||||
setInitialValues,
|
||||
setModalOpen,
|
||||
handleDeleteDowntime,
|
||||
setEditMode,
|
||||
}),
|
||||
},
|
||||
];
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const tableData = [...(downtimeSchedules.data?.data || [])]
|
||||
.sort((a, b): number => {
|
||||
if (a?.updatedAt && b?.updatedAt) {
|
||||
return dayjs(b.updatedAt).diff(dayjs(a.updatedAt));
|
||||
function PlannedDowntimeList({
|
||||
columns,
|
||||
downtimes,
|
||||
pagedDowntimes,
|
||||
calculatedPageSize,
|
||||
onLimitChange,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
hasSearchTerm,
|
||||
onClearSearch,
|
||||
}: PlannedDowntimeListProps): JSX.Element {
|
||||
const columnsWithActions =
|
||||
useMemo((): TableColumnDef<DowntimeSchedulesTableData>[] => {
|
||||
if (!canEdit) {
|
||||
return columns;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.filter(
|
||||
(data) =>
|
||||
data.name.includes(searchValue.toLocaleString()) ||
|
||||
data.id === searchValue.toLocaleString(),
|
||||
)
|
||||
.map((data) => {
|
||||
const specificAlertOptions = getAlertOptionsFromIds(
|
||||
data.alertIds || [],
|
||||
alertOptions,
|
||||
);
|
||||
|
||||
return { ...data, alertOptions: specificAlertOptions };
|
||||
});
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
id: 'actions',
|
||||
header: (): JSX.Element => (
|
||||
<span className={styles.actionsHeader}>Actions</span>
|
||||
),
|
||||
accessorKey: 'id',
|
||||
width: { fixed: '96px', ignoreLastColumnFill: true },
|
||||
enableSort: false,
|
||||
enableResize: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'right',
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<PlannedDowntimeActionsMenu
|
||||
downtime={row}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [canEdit, columns, onEdit, onDelete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (downtimeSchedules.isError) {
|
||||
showErrorNotification(notifications, downtimeSchedules.error);
|
||||
}
|
||||
}, [downtimeSchedules.error, downtimeSchedules.isError, notifications]);
|
||||
const isEmpty = !isFetching && downtimes.length === 0;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorEmptyState
|
||||
title="Failed to load planned downtime"
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty && hasSearchTerm) {
|
||||
return (
|
||||
<NoResultsEmptyState
|
||||
title="No matching planned downtime"
|
||||
subtitle="No planned downtime matches your search. Try adjusting your search criteria."
|
||||
onClear={onClearSearch}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <PlannedDowntimeEmptyState canCreate={canEdit} onCreate={onCreate} />;
|
||||
}
|
||||
|
||||
const paginationConfig = {
|
||||
pageSize: 5,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
return (
|
||||
<Table<DowntimeSchedulesTableData>
|
||||
columns={columns}
|
||||
className="planned-downtime-table"
|
||||
bordered={false}
|
||||
dataSource={tableData || []}
|
||||
loading={downtimeSchedules.isLoading || downtimeSchedules.isFetching}
|
||||
showHeader={false}
|
||||
pagination={paginationConfig}
|
||||
<TanStackTable<DowntimeSchedulesTableData>
|
||||
data={pagedDowntimes}
|
||||
columns={columnsWithActions}
|
||||
isLoading={isFetching}
|
||||
getRowKey={(row): string => row.id}
|
||||
getItemKey={(row): string => row.id}
|
||||
getRowTestId={(row): string => `downtime-row-${row.id}`}
|
||||
columnStorageKey="planned-downtime-columns"
|
||||
enableQueryParams={PLANNED_DOWNTIME_QUERY_PARAMS}
|
||||
onRowClick={canEdit ? onEdit : undefined}
|
||||
pagination={{
|
||||
total: downtimes.length,
|
||||
calculatedPageSize,
|
||||
onLimitChange,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
plainTextCellLineClamp={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlannedDowntimeList;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { UseMutateAsyncFunction } from 'react-query';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type {
|
||||
DeleteDowntimeScheduleByIDPathParameters,
|
||||
@@ -13,6 +12,8 @@ import { AxiosError } from 'axios';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import type { DowntimeAlertOption } from './types';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
type DateTimeString = string | null | undefined;
|
||||
@@ -56,8 +57,8 @@ export const formatDateTime = (
|
||||
|
||||
export const getAlertOptionsFromIds = (
|
||||
alertIds: string[],
|
||||
alertOptions: DefaultOptionType[],
|
||||
): DefaultOptionType[] =>
|
||||
alertOptions: DowntimeAlertOption[],
|
||||
): DowntimeAlertOption[] =>
|
||||
alertOptions.filter(
|
||||
(alert) =>
|
||||
alert !== undefined &&
|
||||
@@ -221,3 +222,48 @@ export const recurrenceOptionWithSubmenu: Option[] = [
|
||||
export const isScheduleRecurring = (
|
||||
schedule?: AlertmanagertypesPlannedMaintenanceDTO['schedule'] | null,
|
||||
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
|
||||
|
||||
const REPEAT_TYPE_LABEL: Record<string, string> = {
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
};
|
||||
|
||||
export const formatRepeats = (
|
||||
schedule: AlertmanagertypesScheduleDTO,
|
||||
): string => {
|
||||
const recurrence = schedule.recurrence;
|
||||
if (!recurrence) {
|
||||
return 'Does not repeat';
|
||||
}
|
||||
|
||||
const label =
|
||||
REPEAT_TYPE_LABEL[recurrence.repeatType] ?? recurrence.repeatType;
|
||||
const days = recurrence.repeatOn?.length
|
||||
? ` on ${recurrence.repeatOn
|
||||
.map((day) => day.charAt(0).toUpperCase() + day.slice(1, 3))
|
||||
.join(', ')}`
|
||||
: '';
|
||||
const duration = recurrence.duration ? ` · ${recurrence.duration}` : '';
|
||||
return `${label}${days}${duration}`;
|
||||
};
|
||||
|
||||
export const formatWindow = (
|
||||
schedule: AlertmanagertypesScheduleDTO,
|
||||
): string => {
|
||||
if (!schedule.startTime) {
|
||||
return '-';
|
||||
}
|
||||
const start = formatDateTime(schedule.startTime, schedule.timezone);
|
||||
const timezone = schedule.timezone ? ` (${schedule.timezone})` : '';
|
||||
if (!schedule.endTime) {
|
||||
return `From ${start}${timezone}`;
|
||||
}
|
||||
return `${start} ⎯ ${formatDateTime(schedule.endTime, schedule.timezone)}${timezone}`;
|
||||
};
|
||||
|
||||
export const formatScheduleDuration = (
|
||||
schedule: AlertmanagertypesScheduleDTO,
|
||||
): string =>
|
||||
schedule.recurrence?.duration ||
|
||||
getDuration(schedule.startTime, schedule.endTime);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { AlertRuleTags } from '../PlannedDowntimeList';
|
||||
import { AlertRuleTags } from '../AlertRuleTags';
|
||||
|
||||
const RULES = [
|
||||
{ label: 'Postgres connections near limit', value: 'rule-5' },
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import {
|
||||
mockLocation,
|
||||
mockQueryParams,
|
||||
} from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import { render } from 'tests/test-utils';
|
||||
AlertmanagertypesRepeatOnDTO,
|
||||
AlertmanagertypesRepeatTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { mockLocation } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import { render, userEvent } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { PlannedDowntime } from '../PlannedDowntime';
|
||||
import { buildSchedule, createMockDowntime } from './testUtils';
|
||||
|
||||
const SEARCH_PLACEHOLDER = 'Search for a planned downtime...';
|
||||
const SEARCH_TEST_ID = 'planned-downtime-search';
|
||||
const CREATE_TEST_ID = 'planned-downtime-create';
|
||||
|
||||
function renderPage(role: string = USER_ROLES.ADMIN): void {
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 800, itemHeight: 46 }}>
|
||||
<PlannedDowntime />
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{ role },
|
||||
);
|
||||
}
|
||||
|
||||
const MOCK_DOWNTIME_1_NAME = 'Mock Downtime 1';
|
||||
const MOCK_DOWNTIME_2_NAME = 'Mock Downtime 2';
|
||||
@@ -27,11 +40,19 @@ const MOCK_DATE_3 = '2024-01-03';
|
||||
const MOCK_DOWNTIME_1 = createMockDowntime({
|
||||
id: '1',
|
||||
name: MOCK_DOWNTIME_1_NAME,
|
||||
description: 'Database maintenance',
|
||||
createdAt: MOCK_DATE_1,
|
||||
createdBy: 'creator@signoz.io',
|
||||
updatedAt: MOCK_DATE_1,
|
||||
updatedBy: 'editor@signoz.io',
|
||||
schedule: buildSchedule({
|
||||
startTime: MOCK_DATE_1,
|
||||
timezone: 'UTC',
|
||||
timezone: 'Asia/Kolkata',
|
||||
recurrence: {
|
||||
duration: '2h',
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO.weekly,
|
||||
repeatOn: [AlertmanagertypesRepeatOnDTO.monday],
|
||||
},
|
||||
}),
|
||||
alertIds: [],
|
||||
});
|
||||
@@ -126,113 +147,152 @@ jest.mock('api/generated/services/rules', () => ({
|
||||
describe('PlannedDowntime Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlQuery = mockQueryParams({});
|
||||
mockUrlQuery = new URLSearchParams();
|
||||
mockLocation('/alerts');
|
||||
});
|
||||
|
||||
it('renders the PlannedDowntime component properly', () => {
|
||||
render(<PlannedDowntime />, {}, { role: 'ADMIN' });
|
||||
it('renders the header, search and create action', () => {
|
||||
renderPage();
|
||||
|
||||
// Check if title is rendered
|
||||
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
||||
|
||||
// Check if subtitle is rendered
|
||||
expect(
|
||||
screen.getByText('Create and manage planned downtimes.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check if search input is rendered
|
||||
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
|
||||
|
||||
// Check if "New downtime" button is enabled for ADMIN
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
name: /new downtime/i,
|
||||
});
|
||||
expect(newDowntimeButton).toBeInTheDocument();
|
||||
expect(newDowntimeButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('disables the "New downtime" button for users with VIEWER role', () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.VIEWER });
|
||||
renderPage(USER_ROLES.VIEWER);
|
||||
|
||||
// Check if "New downtime" button is disabled for VIEWER
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
name: /new downtime/i,
|
||||
});
|
||||
expect(newDowntimeButton).toBeInTheDocument();
|
||||
expect(newDowntimeButton).toBeDisabled();
|
||||
|
||||
expect(newDowntimeButton).toHaveAttribute('disabled');
|
||||
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('should load with search term from URL query params', () => {
|
||||
const searchTerm = 'existing search';
|
||||
mockUrlQuery = mockQueryParams({ search: searchTerm });
|
||||
mockUrlQuery = new URLSearchParams({ search: searchTerm });
|
||||
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
renderPage();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe(searchTerm);
|
||||
});
|
||||
|
||||
it('should initialize with empty search when no search param is in URL', () => {
|
||||
mockUrlQuery = mockQueryParams({});
|
||||
renderPage();
|
||||
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('should display all downtime schedules when no search term is entered', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(MOCK_DOWNTIME_1_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DOWNTIME_2_NAME)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DOWNTIME_3_NAME)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the key schedule details and hides secondary columns by default', async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText(MOCK_DOWNTIME_1_NAME);
|
||||
expect(screen.getByText(/\(Asia\/Kolkata\)$/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Weekly on Mon · 2h')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Asia/Kolkata')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('2h')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Database maintenance')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('creator@signoz.io')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('editor@signoz.io')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('All alert rules')).toHaveLength(3);
|
||||
expect(screen.getAllByText('Active')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('lets every data column be resized', async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText(MOCK_DOWNTIME_1_NAME);
|
||||
const resizable = Array.from(document.querySelectorAll('th'))
|
||||
.filter((th) => th.querySelector('[title="Drag to resize column"]'))
|
||||
.map((th) => th.textContent?.trim());
|
||||
expect(resizable).toStrictEqual([
|
||||
'Downtime',
|
||||
'Status',
|
||||
'Schedule',
|
||||
'Repeats',
|
||||
'Alerts Silenced',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter downtime schedules by name when searching', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(MOCK_DOWNTIME_1_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
|
||||
target: { value: MOCK_DOWNTIME_1_NAME },
|
||||
});
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: MOCK_DOWNTIME_1_NAME } });
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(MOCK_DOWNTIME_1_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_2_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_3_NAME)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter downtime schedules with partial name match', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(MOCK_DOWNTIME_1_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '2' } });
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_2_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(MOCK_DOWNTIME_2_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_1_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_3_NAME)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show no results when search term matches nothing', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
renderPage();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'NonExistentDowntime' } });
|
||||
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
|
||||
target: { value: 'NonExistentDowntime' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('no-results-empty-state'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_1_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_2_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_3_NAME)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the delete confirmation from the row actions', async () => {
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('downtime-actions-1'));
|
||||
await userEvent.click(await screen.findByText('Delete'));
|
||||
|
||||
await expect(
|
||||
screen.findByRole('button', { name: 'Delete Schedule' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides row actions for viewers', async () => {
|
||||
renderPage(USER_ROLES.VIEWER);
|
||||
|
||||
await screen.findByText(MOCK_DOWNTIME_1_NAME);
|
||||
expect(screen.queryByTestId('downtime-actions-1')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
236
frontend/src/container/PlannedDowntime/table.config.tsx
Normal file
236
frontend/src/container/PlannedDowntime/table.config.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { AlertmanagertypesMaintenanceStatusDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relativeTime';
|
||||
|
||||
import styles from './PlannedDowntime.module.scss';
|
||||
import {
|
||||
formatRepeats,
|
||||
formatScheduleDuration,
|
||||
formatWindow,
|
||||
} from './PlannedDowntimeutils';
|
||||
import { DowntimeSchedulesTableData } from './types';
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
AlertmanagertypesMaintenanceStatusDTO,
|
||||
{ label: string; color: 'success' | 'info' | 'secondary' }
|
||||
> = {
|
||||
[AlertmanagertypesMaintenanceStatusDTO.active]: {
|
||||
label: 'Active',
|
||||
color: 'success',
|
||||
},
|
||||
[AlertmanagertypesMaintenanceStatusDTO.upcoming]: {
|
||||
label: 'Upcoming',
|
||||
color: 'info',
|
||||
},
|
||||
[AlertmanagertypesMaintenanceStatusDTO.expired]: {
|
||||
label: 'Expired',
|
||||
color: 'secondary',
|
||||
},
|
||||
};
|
||||
|
||||
export function getPlannedDowntimeColumns(
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
): TableColumnDef<DowntimeSchedulesTableData>[] {
|
||||
const textCell = (value: string | undefined): JSX.Element => (
|
||||
<TanStackTable.Text title={value || undefined}>
|
||||
{value || '-'}
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
|
||||
const timestampCell = (
|
||||
row: DowntimeSchedulesTableData,
|
||||
value: string | undefined,
|
||||
): JSX.Element => {
|
||||
if (!value) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable.HoverTooltip
|
||||
rowId={row.id}
|
||||
title={formatTimezoneAdjustedTimestamp(
|
||||
value,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
)}
|
||||
>
|
||||
<span className={styles.timeText}>{toRelativeTime(value)}</span>
|
||||
</TanStackTable.HoverTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Downtime',
|
||||
accessorKey: 'name',
|
||||
width: { default: '100%', min: '200px' },
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={row.name}
|
||||
data-testid={`downtime-row-${row.id}-name`}
|
||||
>
|
||||
{row.name}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
width: { default: '110px', min: '100px' },
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
const status = STATUS_BADGE[row.status];
|
||||
return status ? (
|
||||
<Badge textTransform="none" variant="outlined" color={status.color}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
) : (
|
||||
textCell(row.status)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'window',
|
||||
header: 'Schedule',
|
||||
accessorFn: (row) => row.schedule?.startTime,
|
||||
width: { default: '340px', min: '220px' },
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(formatWindow(row.schedule)),
|
||||
},
|
||||
{
|
||||
id: 'timezone',
|
||||
header: 'Timezone',
|
||||
accessorFn: (row) => row.schedule?.timezone,
|
||||
width: { default: '130px', min: '110px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(row.schedule?.timezone),
|
||||
},
|
||||
{
|
||||
id: 'repeats',
|
||||
header: 'Repeats',
|
||||
accessorFn: (row) => row.schedule?.recurrence?.repeatType,
|
||||
width: { default: '180px', min: '120px' },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(formatRepeats(row.schedule)),
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
accessorFn: (row) => row.schedule?.recurrence?.duration,
|
||||
width: { default: '110px', min: '90px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element =>
|
||||
textCell(formatScheduleDuration(row.schedule)),
|
||||
},
|
||||
{
|
||||
id: 'alertsSilenced',
|
||||
header: 'Alerts Silenced',
|
||||
accessorKey: 'alertIds',
|
||||
width: { default: '200px', min: '140px' },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element =>
|
||||
row.alertOptions.length ? (
|
||||
<div
|
||||
className={styles.badges}
|
||||
title={row.alertOptions
|
||||
.flatMap((alert) =>
|
||||
typeof alert.label === 'string' ? [alert.label] : [],
|
||||
)
|
||||
.join(', ')}
|
||||
>
|
||||
{row.alertOptions.map((alert) => (
|
||||
<Badge
|
||||
key={alert.value}
|
||||
textTransform="none"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
{alert.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
All alert rules
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: 'Description',
|
||||
accessorKey: 'description',
|
||||
width: { default: '200px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(row.description),
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
header: 'Created By',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(row.createdBy),
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => timestampCell(row, row.createdAt),
|
||||
},
|
||||
{
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(row.updatedBy),
|
||||
},
|
||||
{
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => timestampCell(row, row.updatedAt),
|
||||
},
|
||||
];
|
||||
}
|
||||
33
frontend/src/container/PlannedDowntime/types.ts
Normal file
33
frontend/src/container/PlannedDowntime/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import type { SelectOption } from 'types/common/select';
|
||||
|
||||
export type DowntimeAlertOption = Omit<
|
||||
SelectOption<string | number | null, ReactNode>,
|
||||
'value'
|
||||
> & {
|
||||
value?: string | number | null;
|
||||
};
|
||||
|
||||
export type DowntimeSchedulesTableData =
|
||||
AlertmanagertypesPlannedMaintenanceDTO & {
|
||||
alertOptions: DowntimeAlertOption[];
|
||||
};
|
||||
|
||||
export interface PlannedDowntimeListProps {
|
||||
columns: TableColumnDef<DowntimeSchedulesTableData>[];
|
||||
downtimes: DowntimeSchedulesTableData[];
|
||||
pagedDowntimes: DowntimeSchedulesTableData[];
|
||||
calculatedPageSize: number | null;
|
||||
onLimitChange: (limit: number) => void;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
canEdit: boolean;
|
||||
onEdit: (downtime: DowntimeSchedulesTableData) => void;
|
||||
onDelete: (downtime: DowntimeSchedulesTableData) => void;
|
||||
onCreate: () => void;
|
||||
hasSearchTerm: boolean;
|
||||
onClearSearch: () => void;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortByColumn } from 'components/Alerts/utils';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
|
||||
import { getAlertOptionsFromIds } from './PlannedDowntimeutils';
|
||||
import { DowntimeAlertOption, DowntimeSchedulesTableData } from './types';
|
||||
|
||||
export const PLANNED_DOWNTIME_QUERY_PARAMS = {
|
||||
orderBy: 'orderBy',
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const DEFAULT_SORT: SortState = { columnName: 'updatedAt', order: 'desc' };
|
||||
|
||||
const toTime = (value: string | undefined): number =>
|
||||
value ? new Date(value).getTime() : 0;
|
||||
|
||||
function getSortValue(
|
||||
downtime: DowntimeSchedulesTableData,
|
||||
columnName: string,
|
||||
): string | number {
|
||||
switch (columnName) {
|
||||
case 'name':
|
||||
return downtime.name.toLowerCase();
|
||||
case 'status':
|
||||
return downtime.status;
|
||||
case 'window':
|
||||
return toTime(downtime.schedule?.startTime);
|
||||
case 'createdAt':
|
||||
return toTime(downtime.createdAt);
|
||||
case 'updatedAt':
|
||||
return toTime(downtime.updatedAt);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface UsePlannedDowntimeTableReturn {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
calculatedPageSize: number | null;
|
||||
filteredDowntimes: DowntimeSchedulesTableData[];
|
||||
pagedDowntimes: DowntimeSchedulesTableData[];
|
||||
setPage: (page: number) => void;
|
||||
setLimit: (limit: number) => void;
|
||||
}
|
||||
|
||||
function usePlannedDowntimeTable(
|
||||
downtimes: AlertmanagertypesPlannedMaintenanceDTO[],
|
||||
alertOptions: DowntimeAlertOption[],
|
||||
searchValue: string,
|
||||
): UsePlannedDowntimeTableReturn {
|
||||
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
|
||||
rowHeight: 46,
|
||||
});
|
||||
|
||||
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
|
||||
PLANNED_DOWNTIME_QUERY_PARAMS,
|
||||
{
|
||||
page: DEFAULT_PAGE,
|
||||
limit: DEFAULT_LIMIT,
|
||||
storageKey: 'planned-downtime',
|
||||
calculatedPageSize,
|
||||
cleanupOnUnmount: true,
|
||||
},
|
||||
);
|
||||
|
||||
const filteredDowntimes = useMemo(() => {
|
||||
const search = searchValue.toLowerCase();
|
||||
return downtimes
|
||||
.filter(
|
||||
(downtime) =>
|
||||
downtime.name.toLowerCase().includes(search) ||
|
||||
downtime.id === searchValue,
|
||||
)
|
||||
.map((downtime) => ({
|
||||
...downtime,
|
||||
alertOptions: getAlertOptionsFromIds(downtime.alertIds || [], alertOptions),
|
||||
}));
|
||||
}, [downtimes, alertOptions, searchValue]);
|
||||
|
||||
const pagedDowntimes = useMemo(() => {
|
||||
const sorted = sortByColumn(
|
||||
filteredDowntimes,
|
||||
orderBy,
|
||||
getSortValue,
|
||||
DEFAULT_SORT,
|
||||
);
|
||||
const start = (page - 1) * limit;
|
||||
return sorted.slice(start, start + limit);
|
||||
}, [filteredDowntimes, orderBy, page, limit]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
calculatedPageSize,
|
||||
filteredDowntimes,
|
||||
pagedDowntimes,
|
||||
setPage,
|
||||
setLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePlannedDowntimeTable;
|
||||
@@ -112,7 +112,7 @@ export function Formula({
|
||||
);
|
||||
|
||||
return (
|
||||
<Row gutter={[0, 15]}>
|
||||
<Row gutter={[0, 15]} data-testid={`formula-${formula.queryName}`}>
|
||||
<QBEntityOptions
|
||||
isCollapsed={isCollapse}
|
||||
showFunctions={false}
|
||||
@@ -130,6 +130,7 @@ export function Formula({
|
||||
<Input.TextArea
|
||||
name="expression"
|
||||
className="formula-expression"
|
||||
data-testid="formula-expression"
|
||||
onChange={handleChange}
|
||||
size="middle"
|
||||
value={formula.expression}
|
||||
@@ -141,6 +142,7 @@ export function Formula({
|
||||
<Input
|
||||
name="legend"
|
||||
className="formula-legend"
|
||||
data-testid="formula-legend"
|
||||
onChange={handleChange}
|
||||
size="middle"
|
||||
value={formula.legend}
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function QBEntityOptions({
|
||||
value="search"
|
||||
className="periscope-btn collapse"
|
||||
onClick={onCollapseEntity}
|
||||
data-testid="qb-entity-collapse"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
</Button>
|
||||
@@ -108,6 +109,7 @@ export default function QBEntityOptions({
|
||||
className="periscope-btn visibility-toggle"
|
||||
onClick={onToggleVisibility}
|
||||
disabled={isRawQuery && !showTraceOperator}
|
||||
data-testid="qb-entity-visibility"
|
||||
>
|
||||
{entityData.disabled ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</Button>
|
||||
@@ -115,7 +117,11 @@ export default function QBEntityOptions({
|
||||
|
||||
{entityType === 'query' && showCloneOption && (
|
||||
<Tooltip title={`Clone Query ${entityData.queryName}`}>
|
||||
<Button className={cx('periscope-btn')} onClick={handleCloneEntity}>
|
||||
<Button
|
||||
className={cx('periscope-btn')}
|
||||
onClick={handleCloneEntity}
|
||||
data-testid="qb-entity-clone"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -131,6 +137,7 @@ export default function QBEntityOptions({
|
||||
: '',
|
||||
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
|
||||
)}
|
||||
data-testid="qb-entity-name"
|
||||
>
|
||||
{entityData.queryName}
|
||||
</Button>
|
||||
@@ -185,7 +192,11 @@ export default function QBEntityOptions({
|
||||
</div>
|
||||
|
||||
{showDeleteButton && !isRawQuery && (
|
||||
<Button className="periscope-btn ghost" onClick={onDelete}>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
onClick={onDelete}
|
||||
data-testid="qb-entity-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.headerBand {
|
||||
--control-height: 2rem;
|
||||
--input-height: var(--control-height);
|
||||
--input-wrapper-height: var(--control-height);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1 1 0;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.actionsHeader {
|
||||
display: block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l2-background-active);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
--tanstack-table-row-odd-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 2%,
|
||||
transparent
|
||||
);
|
||||
--tanstack-table-row-even-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 1%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding-right: var(--spacing-12);
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.expression {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeText {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.emptySubtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { ChangeEvent, useCallback, useMemo } from 'react';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Flex, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import DeleteRoutingPolicy from './DeleteRoutingPolicy';
|
||||
import styles from './RoutingPolicies.module.scss';
|
||||
import RoutingPolicyDetails from './RoutingPolicyDetails';
|
||||
import RoutingPolicyList from './RoutingPolicyList';
|
||||
import { getRoutingPolicyColumns } from './table.config';
|
||||
import { RoutingPolicy } from './types';
|
||||
import useRoutingPolicies from './useRoutingPolicies';
|
||||
import useRoutingPoliciesTable from './useRoutingPoliciesTable';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
const COLUMNS_STORAGE_KEY = 'routing-policies-columns';
|
||||
|
||||
function RoutingPolicies(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const {
|
||||
// Routing Policies
|
||||
selectedRoutingPolicy,
|
||||
@@ -46,79 +55,136 @@ function RoutingPolicies(): JSX.Element {
|
||||
isPolicyDetailsModalActionLoading,
|
||||
} = useRoutingPolicies();
|
||||
|
||||
const disableCreateButton = user?.role === USER_ROLES.VIEWER;
|
||||
const { containerRef, calculatedPageSize, pagedPolicies, setPage, setLimit } =
|
||||
useRoutingPoliciesTable(routingPoliciesData);
|
||||
|
||||
const tooltipTitle = useMemo(() => {
|
||||
if (user?.role === USER_ROLES.VIEWER) {
|
||||
return 'You need edit permissions to create a routing policy';
|
||||
}
|
||||
return '';
|
||||
}, [user?.role]);
|
||||
const canEdit = user?.role !== USER_ROLES.VIEWER;
|
||||
|
||||
const columns = useMemo(
|
||||
() => getRoutingPolicyColumns(formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchTerm(e.target.value || '');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearSearch = useCallback((): void => {
|
||||
setSearchTerm('');
|
||||
setPage(1);
|
||||
}, [setSearchTerm, setPage]);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
(): void => handlePolicyDetailsModalOpen('create', null),
|
||||
[handlePolicyDetailsModalOpen],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(routingPolicy: RoutingPolicy): void =>
|
||||
handlePolicyDetailsModalOpen('edit', routingPolicy),
|
||||
[handlePolicyDetailsModalOpen],
|
||||
);
|
||||
|
||||
const isFetching = useMemo(
|
||||
() => isLoadingRoutingPolicies || isFetchingRoutingPolicies,
|
||||
[isLoadingRoutingPolicies, isFetchingRoutingPolicies],
|
||||
);
|
||||
const hasSearchTerm = useMemo(() => searchTerm.length > 0, [searchTerm]);
|
||||
const isEmpty = useMemo(
|
||||
() => !isFetching && !hasSearchTerm && routingPoliciesData.length === 0,
|
||||
[isFetching, hasSearchTerm, routingPoliciesData.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="routing-policies-container">
|
||||
<div className="routing-policies-content">
|
||||
<Typography.Title className="title">Routing Policies</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage routing policies.
|
||||
</Typography.Text>
|
||||
<Flex className="toolbar">
|
||||
<div className={styles.container}>
|
||||
<header className={styles.headerBand}>
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>
|
||||
Routing Policies
|
||||
</Typography.Title>
|
||||
<span className={styles.countPill}>{routingPoliciesData.length}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
{!isEmpty && (
|
||||
<ColumnSelector
|
||||
columns={columns}
|
||||
storageKey={COLUMNS_STORAGE_KEY}
|
||||
testId="routing-policies-columns"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleCreate}
|
||||
disabled={!canEdit}
|
||||
disabledTooltip="You need edit permissions to create a routing policy"
|
||||
testId="routing-policies-create"
|
||||
>
|
||||
New routing policy
|
||||
</Button>
|
||||
<TextToolTip
|
||||
text="More details on how to configure routing policies"
|
||||
url="https://signoz.io/docs/alerts-management/routing-policy"
|
||||
urlText="Learn More"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.filtersRow}>
|
||||
<Input
|
||||
placeholder="Search for a routing policy..."
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
containerClassName={styles.searchInput}
|
||||
placeholder="Search by name or description"
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="routing-policies-search"
|
||||
/>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={(): void => handlePolicyDetailsModalOpen('create', null)}
|
||||
disabled={disableCreateButton}
|
||||
>
|
||||
<Flex gap={4} align="center">
|
||||
<Plus size={16} />
|
||||
New routing policy
|
||||
</Flex>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<br />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
<RoutingPolicyList
|
||||
columns={columns}
|
||||
routingPolicies={routingPoliciesData}
|
||||
refetchRoutingPolicies={refetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={isFetchingRoutingPolicies}
|
||||
isRoutingPoliciesLoading={isLoadingRoutingPolicies}
|
||||
isRoutingPoliciesError={isErrorRoutingPolicies}
|
||||
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={handleDeleteModalOpen}
|
||||
hasSearchTerm={(searchTerm?.length ?? 0) > 0}
|
||||
pagedPolicies={pagedPolicies}
|
||||
calculatedPageSize={calculatedPageSize}
|
||||
onLimitChange={setLimit}
|
||||
isFetching={isFetching}
|
||||
isError={isErrorRoutingPolicies}
|
||||
refetch={refetchRoutingPolicies}
|
||||
canEdit={canEdit}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDeleteModalOpen}
|
||||
onCreate={handleCreate}
|
||||
hasSearchTerm={hasSearchTerm}
|
||||
onClearSearch={handleClearSearch}
|
||||
/>
|
||||
{policyDetailsModalState.isOpen && (
|
||||
<RoutingPolicyDetails
|
||||
routingPolicy={selectedRoutingPolicy}
|
||||
closeModal={handlePolicyDetailsModalClose}
|
||||
mode={policyDetailsModalState.mode}
|
||||
channels={channels}
|
||||
isErrorChannels={isErrorChannels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
handlePolicyDetailsModalAction={handlePolicyDetailsModalAction}
|
||||
isPolicyDetailsModalActionLoading={isPolicyDetailsModalActionLoading}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
)}
|
||||
{isDeleteModalOpen && (
|
||||
<DeleteRoutingPolicy
|
||||
isDeletingRoutingPolicy={isDeletingRoutingPolicy}
|
||||
handleDelete={handleDeleteRoutingPolicy}
|
||||
handleClose={handleDeleteModalClose}
|
||||
routingPolicy={selectedRoutingPolicy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{policyDetailsModalState.isOpen && (
|
||||
<RoutingPolicyDetails
|
||||
routingPolicy={selectedRoutingPolicy}
|
||||
closeModal={handlePolicyDetailsModalClose}
|
||||
mode={policyDetailsModalState.mode}
|
||||
channels={channels}
|
||||
isErrorChannels={isErrorChannels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
handlePolicyDetailsModalAction={handlePolicyDetailsModalAction}
|
||||
isPolicyDetailsModalActionLoading={isPolicyDetailsModalActionLoading}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
)}
|
||||
{isDeleteModalOpen && (
|
||||
<DeleteRoutingPolicy
|
||||
isDeletingRoutingPolicy={isDeletingRoutingPolicy}
|
||||
handleDelete={handleDeleteRoutingPolicy}
|
||||
handleClose={handleDeleteModalClose}
|
||||
routingPolicy={selectedRoutingPolicy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Plus, Route } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './RoutingPolicies.module.scss';
|
||||
|
||||
const DOCS_URL = 'https://signoz.io/docs/alerts-management/routing-policy';
|
||||
|
||||
interface RoutingPoliciesEmptyStateProps {
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function RoutingPoliciesEmptyState({
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: RoutingPoliciesEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="routing-policies-empty-state">
|
||||
<Route className={styles.emptyIcon} size={16} />
|
||||
<Typography.Text className={styles.emptyTitle}>
|
||||
No routing policies yet
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.emptySubtitle}>
|
||||
A routing policy sends alerts matching an expression to the channels you
|
||||
pick, without editing each alert rule.
|
||||
</Typography.Text>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onCreate}
|
||||
testId="routing-policies-empty-create"
|
||||
>
|
||||
New routing policy
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the routing policy guide
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoutingPoliciesEmptyState;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
|
||||
import styles from './RoutingPolicies.module.scss';
|
||||
import { RoutingPolicy } from './types';
|
||||
|
||||
interface RoutingPolicyActionsMenuProps {
|
||||
routingPolicy: RoutingPolicy;
|
||||
onEdit: (routingPolicy: RoutingPolicy) => void;
|
||||
onDelete: (routingPolicy: RoutingPolicy) => void;
|
||||
}
|
||||
|
||||
function RoutingPolicyActionsMenu({
|
||||
routingPolicy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: RoutingPolicyActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<DropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'edit',
|
||||
label: 'Edit',
|
||||
prefix: <PenLine size={14} />,
|
||||
onClick: (): void => onEdit(routingPolicy),
|
||||
},
|
||||
{ type: 'separator', value: 'before-delete' },
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete',
|
||||
label: 'Delete',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
onClick: (): void => onDelete(routingPolicy),
|
||||
},
|
||||
],
|
||||
[routingPolicy, onEdit, onDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
// the row itself opens the policy, so the actions keep their clicks
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.actions}
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
testId={`routing-policy-actions-${routingPolicy.id}`}
|
||||
aria-label={`Actions for ${routingPolicy.name}`}
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoutingPolicyActionsMenu;
|
||||
@@ -1,98 +1,106 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Table, TableProps } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { RotateCw } from '@signozhq/icons';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
import RoutingPolicyListItem from './RoutingPolicyListItem';
|
||||
import RoutingPoliciesEmptyState from './RoutingPoliciesEmptyState';
|
||||
import RoutingPolicyActionsMenu from './RoutingPolicyActionsMenu';
|
||||
import styles from './RoutingPolicies.module.scss';
|
||||
import { RoutingPolicy, RoutingPolicyListProps } from './types';
|
||||
import { ROUTING_POLICIES_QUERY_PARAMS } from './useRoutingPoliciesTable';
|
||||
|
||||
function RoutingPolicyList({
|
||||
columns,
|
||||
routingPolicies,
|
||||
refetchRoutingPolicies,
|
||||
isRoutingPoliciesFetching,
|
||||
isRoutingPoliciesLoading,
|
||||
isRoutingPoliciesError,
|
||||
handlePolicyDetailsModalOpen,
|
||||
handleDeleteModalOpen,
|
||||
pagedPolicies,
|
||||
calculatedPageSize,
|
||||
onLimitChange,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
hasSearchTerm,
|
||||
onClearSearch,
|
||||
}: RoutingPolicyListProps): JSX.Element {
|
||||
const columns: TableProps<RoutingPolicy>['columns'] = [
|
||||
{
|
||||
title: 'Routing Policy',
|
||||
key: 'routingPolicy',
|
||||
render: (data: RoutingPolicy): JSX.Element => (
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={data}
|
||||
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={handleDeleteModalOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
const columnsWithActions = useMemo((): TableColumnDef<RoutingPolicy>[] => {
|
||||
if (!canEdit) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const showLoading = isRoutingPoliciesLoading || isRoutingPoliciesFetching;
|
||||
const showError = !showLoading && isRoutingPoliciesError;
|
||||
|
||||
const localeEmptyState = useMemo(
|
||||
() => (
|
||||
<div className="no-routing-policies-message-container">
|
||||
{showError ? (
|
||||
<img src={awwSnapUrl} alt="aww-snap" className="error-state-svg" />
|
||||
) : (
|
||||
<img
|
||||
src={emptyStateUrl}
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
id: 'actions',
|
||||
header: (): JSX.Element => (
|
||||
<span className={styles.actionsHeader}>Actions</span>
|
||||
),
|
||||
accessorKey: 'id',
|
||||
width: { fixed: '96px', ignoreLastColumnFill: true },
|
||||
enableSort: false,
|
||||
enableResize: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'right',
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<RoutingPolicyActionsMenu
|
||||
routingPolicy={row}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
{showError ? (
|
||||
<div className="error-state">
|
||||
<Typography.Text>
|
||||
Something went wrong while fetching routing policies.
|
||||
</Typography.Text>
|
||||
<Button icon={<RotateCw size={14} />} onClick={refetchRoutingPolicies}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : hasSearchTerm ? (
|
||||
<Typography.Text>No matching routing policies found.</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text>
|
||||
No routing policies yet,{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/alerts-management/routing-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more here
|
||||
</a>
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[showError, hasSearchTerm, refetchRoutingPolicies],
|
||||
);
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [canEdit, columns, onEdit, onDelete]);
|
||||
|
||||
const isEmpty = !isFetching && routingPolicies.length === 0;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorEmptyState
|
||||
title="Failed to load routing policies"
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty && hasSearchTerm) {
|
||||
return (
|
||||
<NoResultsEmptyState
|
||||
title="No matching routing policies"
|
||||
subtitle="No routing policies match your search. Try adjusting your search criteria."
|
||||
onClear={onClearSearch}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <RoutingPoliciesEmptyState canCreate={canEdit} onCreate={onCreate} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<RoutingPolicy>
|
||||
columns={columns}
|
||||
className="routing-policies-table"
|
||||
bordered={false}
|
||||
dataSource={routingPolicies}
|
||||
loading={showLoading}
|
||||
showHeader={false}
|
||||
rowKey="id"
|
||||
<TanStackTable<RoutingPolicy>
|
||||
data={pagedPolicies}
|
||||
columns={columnsWithActions}
|
||||
isLoading={isFetching}
|
||||
getRowKey={(row): string => row.id}
|
||||
getItemKey={(row): string => row.id}
|
||||
getRowTestId={(row): string => `routing-policy-row-${row.id}`}
|
||||
columnStorageKey="routing-policies-columns"
|
||||
enableQueryParams={ROUTING_POLICIES_QUERY_PARAMS}
|
||||
onRowClick={canEdit ? onEdit : undefined}
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: showLoading ? null : localeEmptyState,
|
||||
total: routingPolicies.length,
|
||||
calculatedPageSize,
|
||||
onLimitChange,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
plainTextCellLineClamp={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Collapse, Flex } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import {
|
||||
PolicyListItemContentProps,
|
||||
PolicyListItemHeaderProps,
|
||||
RoutingPolicyListItemProps,
|
||||
} from './types';
|
||||
|
||||
function PolicyListItemHeader({
|
||||
name,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}: PolicyListItemHeaderProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
const isEditEnabled = user?.role !== USER_ROLES.VIEWER;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className="policy-list-item-header"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Typography.Text className="policy-list-item-header-title" truncate={1}>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
{isEditEnabled && (
|
||||
<div className="action-btn">
|
||||
<Button
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<PenLine size={14} data-testid="edit-routing-policy" />}
|
||||
/>
|
||||
<Button
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={
|
||||
<Trash2
|
||||
size={14}
|
||||
color={Color.BG_CHERRY_500}
|
||||
data-testid="delete-routing-policy"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyListItemContent({
|
||||
routingPolicy,
|
||||
}: PolicyListItemContentProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return (
|
||||
<div className="policy-list-item-content">
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Created by</Typography>
|
||||
<Typography>{routingPolicy.createdBy}</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Created on</Typography>
|
||||
<Typography>
|
||||
{routingPolicy.createdAt
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
routingPolicy.createdAt,
|
||||
DATE_TIME_FORMATS.MONTH_DATETIME,
|
||||
)
|
||||
: '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Updated by</Typography>
|
||||
<Typography>{routingPolicy.updatedBy || '-'}</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Updated on</Typography>
|
||||
<Typography>
|
||||
{routingPolicy.updatedAt
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
routingPolicy.updatedAt,
|
||||
DATE_TIME_FORMATS.MONTH_DATETIME,
|
||||
)
|
||||
: '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Expression</Typography>
|
||||
<Typography.Text truncate={1}>
|
||||
{routingPolicy.expression || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Description</Typography>
|
||||
<Typography.Text truncate={1}>
|
||||
{routingPolicy.description || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Channels</Typography>
|
||||
<div>
|
||||
{routingPolicy.channels.map((channel) => (
|
||||
<Badge
|
||||
textTransform="none"
|
||||
variant="solid"
|
||||
key={channel}
|
||||
color="secondary"
|
||||
>
|
||||
{channel}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutingPolicyListItem({
|
||||
routingPolicy,
|
||||
handlePolicyDetailsModalOpen,
|
||||
handleDeleteModalOpen,
|
||||
}: RoutingPolicyListItemProps): JSX.Element {
|
||||
return (
|
||||
<Collapse accordion className="policy-list-item">
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<PolicyListItemHeader
|
||||
name={routingPolicy.name}
|
||||
handleEdit={(): void =>
|
||||
handlePolicyDetailsModalOpen('edit', routingPolicy)
|
||||
}
|
||||
handleDelete={(): void => handleDeleteModalOpen(routingPolicy)}
|
||||
/>
|
||||
}
|
||||
key={routingPolicy.id}
|
||||
>
|
||||
<PolicyListItemContent routingPolicy={routingPolicy} />
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoutingPolicyListItem;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import RoutingPolicies from '../RoutingPolicies';
|
||||
import * as routingPoliciesHooks from '../useRoutingPolicies';
|
||||
@@ -7,34 +7,14 @@ import {
|
||||
getAppContextMockState,
|
||||
getUseRoutingPoliciesMockData,
|
||||
MOCK_ROUTING_POLICY_1,
|
||||
mockLocation,
|
||||
mockQueryParams,
|
||||
} from './testUtils';
|
||||
|
||||
const ROUTING_POLICY_DETAILS_TEST_ID = 'routing-policy-details';
|
||||
const SEARCH_PLACEHOLDER = 'Search for a routing policy...';
|
||||
const SEARCH_TEST_ID = 'routing-policies-search';
|
||||
const CREATE_TEST_ID = 'routing-policies-create';
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockQueryParams({}),
|
||||
}));
|
||||
|
||||
const mockHistoryReplace = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: (): any => ({
|
||||
replace: mockHistoryReplace,
|
||||
}),
|
||||
useLocation: (): any => ({
|
||||
pathname: '/alerts',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../RoutingPolicyList', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => (
|
||||
@@ -66,20 +46,13 @@ jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
describe('RoutingPolicies', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockQueryParams({});
|
||||
mockLocation('/alerts');
|
||||
});
|
||||
|
||||
it('should render components properly', () => {
|
||||
render(<RoutingPolicies />);
|
||||
expect(screen.getByText('Routing Policies')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create and manage routing policies.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New routing policy/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(CREATE_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('routing-policy-list')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
|
||||
@@ -89,9 +62,10 @@ describe('RoutingPolicies', () => {
|
||||
|
||||
it('should enable the "New routing policy" button for users with ADMIN role', () => {
|
||||
render(<RoutingPolicies />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New routing policy/ }),
|
||||
).toBeEnabled();
|
||||
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable the "New routing policy" button for users with VIEWER role', () => {
|
||||
@@ -99,14 +73,15 @@ describe('RoutingPolicies', () => {
|
||||
.spyOn(appHooks, 'useAppContext')
|
||||
.mockReturnValueOnce(getAppContextMockState({ role: 'VIEWER' }));
|
||||
render(<RoutingPolicies />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New routing policy/ }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('filters routing policies by search term', () => {
|
||||
render(<RoutingPolicies />);
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
const searchInput = screen.getByTestId(SEARCH_TEST_ID);
|
||||
fireEvent.change(searchInput, {
|
||||
target: { value: MOCK_ROUTING_POLICY_1.name },
|
||||
});
|
||||
@@ -116,10 +91,7 @@ describe('RoutingPolicies', () => {
|
||||
|
||||
it('clicking on the "New routing policy" button opens the policy details modal', () => {
|
||||
render(<RoutingPolicies />);
|
||||
const newRoutingPolicyButton = screen.getByRole('button', {
|
||||
name: /New routing policy/,
|
||||
});
|
||||
fireEvent.click(newRoutingPolicyButton);
|
||||
fireEvent.click(screen.getByTestId(CREATE_TEST_ID));
|
||||
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith('create', null);
|
||||
});
|
||||
|
||||
@@ -148,9 +120,8 @@ describe('RoutingPolicies', () => {
|
||||
expect(screen.getByTestId('delete-routing-policy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should load with search term from URL query params', () => {
|
||||
it('should show the current search term', () => {
|
||||
const searchTerm = 'existing search';
|
||||
mockQueryParams({ search: searchTerm });
|
||||
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
getUseRoutingPoliciesMockData({
|
||||
searchTerm,
|
||||
@@ -159,14 +130,11 @@ describe('RoutingPolicies', () => {
|
||||
|
||||
render(<RoutingPolicies />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe(searchTerm);
|
||||
});
|
||||
|
||||
it('should initialize with empty search when no search param is in URL', () => {
|
||||
mockQueryParams({});
|
||||
it('should show an empty search when there is no search term', () => {
|
||||
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
getUseRoutingPoliciesMockData({
|
||||
searchTerm: '',
|
||||
@@ -175,9 +143,7 @@ describe('RoutingPolicies', () => {
|
||||
|
||||
render(<RoutingPolicies />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,126 +1,159 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import RoutingPoliciesList from '../RoutingPolicyList';
|
||||
import { RoutingPolicyListItemProps } from '../types';
|
||||
import { getUseRoutingPoliciesMockData } from './testUtils';
|
||||
import RoutingPolicyList from '../RoutingPolicyList';
|
||||
import { getRoutingPolicyColumns } from '../table.config';
|
||||
import { RoutingPolicyListProps } from '../types';
|
||||
import { MOCK_ROUTING_POLICY_1, MOCK_ROUTING_POLICY_2 } from './testUtils';
|
||||
|
||||
const useRoutingPolicesMockData = getUseRoutingPoliciesMockData();
|
||||
const mockHandlePolicyDetailsModalOpen = jest.fn();
|
||||
const mockHandleDeleteModalOpen = jest.fn();
|
||||
const mockRefetchRoutingPolicies = jest.fn();
|
||||
const POLICIES = [MOCK_ROUTING_POLICY_1, MOCK_ROUTING_POLICY_2];
|
||||
|
||||
jest.mock('../RoutingPolicyListItem', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(({ routingPolicy }: RoutingPolicyListItemProps) => (
|
||||
<div data-testid="routing-policy-list-item">{routingPolicy.name}</div>
|
||||
)),
|
||||
}));
|
||||
function renderList(
|
||||
overrides: Partial<RoutingPolicyListProps> = {},
|
||||
): RoutingPolicyListProps {
|
||||
const props: RoutingPolicyListProps = {
|
||||
columns: getRoutingPolicyColumns((date) => date),
|
||||
routingPolicies: POLICIES,
|
||||
pagedPolicies: POLICIES,
|
||||
calculatedPageSize: null,
|
||||
onLimitChange: jest.fn(),
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
canEdit: true,
|
||||
onEdit: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
onCreate: jest.fn(),
|
||||
hasSearchTerm: false,
|
||||
onClearSearch: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 800, itemHeight: 46 }}>
|
||||
<RoutingPolicyList {...props} />
|
||||
</VirtuosoMockContext.Provider>,
|
||||
);
|
||||
return props;
|
||||
}
|
||||
|
||||
const ROUTING_POLICY_LIST_ITEM_TEST_ID = 'routing-policy-list-item';
|
||||
describe('RoutingPolicyList', () => {
|
||||
it('renders the key policy fields and hides audit columns by default', async () => {
|
||||
renderList();
|
||||
|
||||
describe('RoutingPoliciesList', () => {
|
||||
it('renders base layout with routing policies', () => {
|
||||
render(
|
||||
<RoutingPoliciesList
|
||||
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
|
||||
isRoutingPoliciesLoading={
|
||||
useRoutingPolicesMockData.isLoadingRoutingPolicies
|
||||
}
|
||||
isRoutingPoliciesError={useRoutingPolicesMockData.isErrorRoutingPolicies}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const routingPolicyItems = screen.getAllByTestId(
|
||||
ROUTING_POLICY_LIST_ITEM_TEST_ID,
|
||||
);
|
||||
expect(routingPolicyItems).toHaveLength(2);
|
||||
expect(routingPolicyItems[0]).toHaveTextContent(
|
||||
useRoutingPolicesMockData.routingPoliciesData[0].name,
|
||||
);
|
||||
expect(routingPolicyItems[1]).toHaveTextContent(
|
||||
useRoutingPolicesMockData.routingPoliciesData[1].name,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(
|
||||
<RoutingPoliciesList
|
||||
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
|
||||
isRoutingPoliciesLoading
|
||||
isRoutingPoliciesError={false}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
);
|
||||
// Check for loading spinner by class name
|
||||
expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument();
|
||||
// Check that the table is in loading state (blurred)
|
||||
expect(document.querySelector('.ant-spin-blur')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state when data is being fetched', () => {
|
||||
render(
|
||||
<RoutingPoliciesList
|
||||
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
|
||||
isRoutingPoliciesLoading={false}
|
||||
isRoutingPoliciesError={false}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching
|
||||
/>,
|
||||
);
|
||||
// Check for loading spinner by class name
|
||||
expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument();
|
||||
// Check that the table is in loading state (blurred)
|
||||
expect(document.querySelector('.ant-spin-blur')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state', () => {
|
||||
render(
|
||||
<RoutingPoliciesList
|
||||
routingPolicies={[]}
|
||||
isRoutingPoliciesLoading={false}
|
||||
isRoutingPoliciesError
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId(`routing-policy-row-${MOCK_ROUTING_POLICY_1.id}-name`),
|
||||
).resolves.toHaveTextContent(MOCK_ROUTING_POLICY_1.name);
|
||||
expect(
|
||||
screen.getByText('Something went wrong while fetching routing policies.'),
|
||||
).toBeInTheDocument();
|
||||
screen.getByTestId(`routing-policy-row-${MOCK_ROUTING_POLICY_2.id}-name`),
|
||||
).toHaveTextContent(MOCK_ROUTING_POLICY_2.name);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: 'Retry' });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
fireEvent.click(retryButton);
|
||||
expect(mockRefetchRoutingPolicies).toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText(MOCK_ROUTING_POLICY_1.expression),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MOCK_ROUTING_POLICY_1.description as string),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(MOCK_ROUTING_POLICY_1.channels?.[0] || ''),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MOCK_ROUTING_POLICY_1.createdBy as string),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MOCK_ROUTING_POLICY_2.updatedBy as string),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(
|
||||
<RoutingPoliciesList
|
||||
routingPolicies={[]}
|
||||
isRoutingPoliciesLoading={false}
|
||||
isRoutingPoliciesError={false}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
it('lets every data column be resized', async () => {
|
||||
renderList();
|
||||
|
||||
await screen.findByText(MOCK_ROUTING_POLICY_1.name);
|
||||
const resizable = Array.from(document.querySelectorAll('th'))
|
||||
.filter((th) => th.querySelector('[title="Drag to resize column"]'))
|
||||
.map((th) => th.textContent?.trim());
|
||||
expect(resizable).toStrictEqual(['Routing Policy', 'Expression', 'Channels']);
|
||||
});
|
||||
|
||||
it('opens the policy on row click', async () => {
|
||||
const { onEdit } = renderList();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
`routing-policy-row-${MOCK_ROUTING_POLICY_1.id}-name`,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('No routing policies yet,')).toBeInTheDocument();
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(
|
||||
MOCK_ROUTING_POLICY_1,
|
||||
MOCK_ROUTING_POLICY_1.id,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('edits and deletes from the actions menu', async () => {
|
||||
const { onEdit, onDelete } = renderList();
|
||||
const actions = `routing-policy-actions-${MOCK_ROUTING_POLICY_1.id}`;
|
||||
|
||||
await userEvent.click(await screen.findByTestId(actions));
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
expect(onEdit).toHaveBeenCalledWith(MOCK_ROUTING_POLICY_1);
|
||||
|
||||
await userEvent.click(screen.getByTestId(actions));
|
||||
await userEvent.click(await screen.findByText('Delete'));
|
||||
expect(onDelete).toHaveBeenCalledWith(MOCK_ROUTING_POLICY_1);
|
||||
});
|
||||
|
||||
it('hides actions and row click for viewers', async () => {
|
||||
const { onEdit } = renderList({ canEdit: false });
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
`routing-policy-row-${MOCK_ROUTING_POLICY_1.id}-name`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId(`routing-policy-actions-${MOCK_ROUTING_POLICY_1.id}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the error state with a refresh', async () => {
|
||||
const { refetch } = renderList({ isError: true, routingPolicies: [] });
|
||||
|
||||
expect(screen.getByTestId('error-title')).toHaveTextContent(
|
||||
'Failed to load routing policies',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('error-refresh-button'));
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the no-results state when a search matches nothing', async () => {
|
||||
const { onClearSearch } = renderList({
|
||||
routingPolicies: [],
|
||||
pagedPolicies: [],
|
||||
hasSearchTerm: true,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId('no-results-clear-button'));
|
||||
expect(onClearSearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the empty state with a create action', async () => {
|
||||
const { onCreate } = renderList({ routingPolicies: [], pagedPolicies: [] });
|
||||
|
||||
expect(
|
||||
screen.getByTestId('routing-policies-empty-state'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('routing-policies-empty-create'));
|
||||
expect(onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides the empty-state create action for viewers', () => {
|
||||
renderList({ routingPolicies: [], pagedPolicies: [], canEdit: false });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('routing-policies-empty-create'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import RoutingPolicyListItem from '../RoutingPolicyListItem';
|
||||
import { getAppContextMockState, MOCK_ROUTING_POLICY_1 } from './testUtils';
|
||||
|
||||
const mockFormatTimezoneAdjustedTimestamp = jest.fn();
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): any => ({
|
||||
formatTimezoneAdjustedTimestamp: mockFormatTimezoneAdjustedTimestamp,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
|
||||
const mockHandlePolicyDetailsModalOpen = jest.fn();
|
||||
const mockHandleDeleteModalOpen = jest.fn();
|
||||
|
||||
const EDIT_ROUTING_POLICY_TEST_ID = 'edit-routing-policy';
|
||||
const DELETE_ROUTING_POLICY_TEST_ID = 'delete-routing-policy';
|
||||
|
||||
describe('RoutingPolicyListItem', () => {
|
||||
it('should render properly in collapsed state', () => {
|
||||
render(
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(mockRoutingPolicy.name)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(EDIT_ROUTING_POLICY_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(DELETE_ROUTING_POLICY_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render properly in expanded state', () => {
|
||||
render(
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(mockRoutingPolicy.name)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText(mockRoutingPolicy.name));
|
||||
|
||||
expect(screen.getByText(mockRoutingPolicy.expression)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockRoutingPolicy.channels[0])).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(mockRoutingPolicy.createdBy || 'user1@signoz.io'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(mockRoutingPolicy.description || 'description 1'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handlePolicyDetailsModalOpen when edit button is clicked', () => {
|
||||
render(
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(EDIT_ROUTING_POLICY_TEST_ID));
|
||||
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith(
|
||||
'edit',
|
||||
mockRoutingPolicy,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call handleDeleteModalOpen when delete button is clicked', () => {
|
||||
render(
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(DELETE_ROUTING_POLICY_TEST_ID));
|
||||
expect(mockHandleDeleteModalOpen).toHaveBeenCalledWith(mockRoutingPolicy);
|
||||
});
|
||||
|
||||
it('edit and delete buttons should not be rendered for viewer role', () => {
|
||||
jest
|
||||
.spyOn(appHooks, 'useAppContext')
|
||||
.mockReturnValue(
|
||||
getAppContextMockState({ role: USER_ROLES.VIEWER as ROLES }),
|
||||
);
|
||||
render(
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId(EDIT_ROUTING_POLICY_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(DELETE_ROUTING_POLICY_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('in details panel, show "-" for undefined values', () => {
|
||||
render(
|
||||
<RoutingPolicyListItem
|
||||
routingPolicy={mockRoutingPolicy}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Expand the details panel
|
||||
fireEvent.click(screen.getByText(mockRoutingPolicy.name));
|
||||
|
||||
const updatedByRow = screen.getByText('Updated by').parentElement;
|
||||
expect(updatedByRow).toHaveTextContent('-');
|
||||
|
||||
const updatedOnRow = screen.getByText('Updated on').parentElement;
|
||||
expect(updatedOnRow).toHaveTextContent('-');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ export const MOCK_ROUTING_POLICY_1: RoutingPolicy = {
|
||||
description: 'description 1',
|
||||
channels: ['Channel 1'],
|
||||
createdAt: '2021-01-04',
|
||||
updatedAt: undefined,
|
||||
updatedAt: '2021-01-04',
|
||||
createdBy: 'user1@signoz.io',
|
||||
updatedBy: undefined,
|
||||
};
|
||||
@@ -149,7 +149,7 @@ export function convertRoutingPolicyToApiResponse(
|
||||
id: routingPolicy.id,
|
||||
name: routingPolicy.name,
|
||||
expression: routingPolicy.expression,
|
||||
channels: routingPolicy.channels,
|
||||
channels: routingPolicy.channels ?? [],
|
||||
description: routingPolicy.description || '',
|
||||
createdAt: routingPolicy.createdAt || '',
|
||||
updatedAt: routingPolicy.updatedAt || '',
|
||||
|
||||
@@ -1,173 +1,3 @@
|
||||
.routing-policies-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
|
||||
.routing-policies-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 736px;
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.routing-policies-table {
|
||||
.no-routing-policies-message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state-svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.routing-policies-table {
|
||||
.ant-table {
|
||||
background: none !important;
|
||||
}
|
||||
.ant-table-cell {
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
width: 736px;
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.policy-list-item {
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
border-radius: 6px;
|
||||
|
||||
.ant-collapse-header {
|
||||
height: 56px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.policy-list-item-header {
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.policy-list-item-header-title {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
flex: 0 1 500px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.policy-list-item-content {
|
||||
.policy-list-item-content-row {
|
||||
display: grid;
|
||||
grid-template-columns: 128px 1fr;
|
||||
margin-bottom: 13px;
|
||||
|
||||
.ant-typography:first-child {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.ant-typography:last-child,
|
||||
div .ant-typography {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 20px 12px 38px;
|
||||
}
|
||||
|
||||
.ant-collapse-content-active {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-policy-modal {
|
||||
.ant-modal-content {
|
||||
padding: 16px;
|
||||
|
||||
144
frontend/src/container/RoutingPolicies/table.config.tsx
Normal file
144
frontend/src/container/RoutingPolicies/table.config.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relativeTime';
|
||||
|
||||
import styles from './RoutingPolicies.module.scss';
|
||||
import { RoutingPolicy } from './types';
|
||||
|
||||
export function getRoutingPolicyColumns(
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
): TableColumnDef<RoutingPolicy>[] {
|
||||
const textCell = (value: string | null | undefined): JSX.Element => (
|
||||
<TanStackTable.Text title={value ?? undefined}>
|
||||
{value || '-'}
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
|
||||
const timestampCell = (
|
||||
row: RoutingPolicy,
|
||||
value: string | null | undefined,
|
||||
): JSX.Element => {
|
||||
if (!value) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable.HoverTooltip
|
||||
rowId={row.id}
|
||||
title={formatTimezoneAdjustedTimestamp(
|
||||
value,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
)}
|
||||
>
|
||||
<span className={styles.timeText}>{toRelativeTime(value)}</span>
|
||||
</TanStackTable.HoverTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Routing Policy',
|
||||
accessorKey: 'name',
|
||||
width: { default: '100%', min: '220px' },
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={row.name}
|
||||
data-testid={`routing-policy-row-${row.id}-name`}
|
||||
>
|
||||
{row.name}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expression',
|
||||
header: 'Expression',
|
||||
accessorKey: 'expression',
|
||||
width: { default: '260px', min: '160px' },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<TanStackTable.Text className={styles.expression} title={row.expression}>
|
||||
{row.expression}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channels',
|
||||
header: 'Channels',
|
||||
accessorKey: 'channels',
|
||||
width: { default: '220px', min: '140px' },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element =>
|
||||
row.channels?.length ? (
|
||||
<div className={styles.badges} title={row.channels.join(', ')}>
|
||||
{row.channels.map((channel) => (
|
||||
<Badge
|
||||
key={channel}
|
||||
textTransform="none"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
{channel}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TanStackTable.Text>-</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
header: 'Created By',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(row.createdBy),
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => timestampCell(row, row.createdAt),
|
||||
},
|
||||
{
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => textCell(row.updatedBy),
|
||||
},
|
||||
{
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => timestampCell(row, row.updatedAt),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import type { AlertmanagertypesGettableRoutePolicyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import type { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
export interface RoutingPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
expression: string;
|
||||
channels: string[];
|
||||
description: string | undefined;
|
||||
createdAt: string | undefined;
|
||||
updatedAt: string | undefined;
|
||||
createdBy: string | undefined;
|
||||
updatedBy: string | undefined;
|
||||
}
|
||||
export type RoutingPolicy = AlertmanagertypesGettableRoutePolicyDTO;
|
||||
|
||||
type HandlePolicyDetailsModalOpen = (
|
||||
mode: PolicyDetailsModalMode,
|
||||
@@ -32,30 +24,20 @@ type HandleDeleteModalOpen = (routingPolicy: RoutingPolicy) => void;
|
||||
export type PolicyDetailsModalMode = 'create' | 'edit' | null;
|
||||
|
||||
export interface RoutingPolicyListProps {
|
||||
columns: TableColumnDef<RoutingPolicy>[];
|
||||
routingPolicies: RoutingPolicy[];
|
||||
refetchRoutingPolicies: () => void;
|
||||
isRoutingPoliciesLoading: boolean;
|
||||
isRoutingPoliciesFetching: boolean;
|
||||
isRoutingPoliciesError: boolean;
|
||||
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
|
||||
handleDeleteModalOpen: HandleDeleteModalOpen;
|
||||
pagedPolicies: RoutingPolicy[];
|
||||
calculatedPageSize: number | null;
|
||||
onLimitChange: (limit: number) => void;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
canEdit: boolean;
|
||||
onEdit: (routingPolicy: RoutingPolicy) => void;
|
||||
onDelete: HandleDeleteModalOpen;
|
||||
onCreate: () => void;
|
||||
hasSearchTerm: boolean;
|
||||
}
|
||||
|
||||
export interface RoutingPolicyListItemProps {
|
||||
routingPolicy: RoutingPolicy;
|
||||
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
|
||||
handleDeleteModalOpen: HandleDeleteModalOpen;
|
||||
}
|
||||
|
||||
export interface PolicyListItemHeaderProps {
|
||||
name: string;
|
||||
handleEdit: () => void;
|
||||
handleDelete: () => void;
|
||||
}
|
||||
|
||||
export interface PolicyListItemContentProps {
|
||||
routingPolicy: RoutingPolicy;
|
||||
onClearSearch: () => void;
|
||||
}
|
||||
|
||||
export interface RoutingPolicyDetailsProps {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
@@ -88,22 +88,22 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
} = useChannelOptions();
|
||||
|
||||
const refreshChannels = (): void => {
|
||||
refetchChannels();
|
||||
void refetchChannels();
|
||||
};
|
||||
|
||||
// Handlers
|
||||
const handlePolicyDetailsModalOpen = (
|
||||
mode: PolicyDetailsModalMode,
|
||||
routingPolicy: RoutingPolicy | null,
|
||||
): void => {
|
||||
if (routingPolicy) {
|
||||
setSelectedRoutingPolicy(routingPolicy);
|
||||
}
|
||||
setPolicyDetailsModalState({
|
||||
isOpen: true,
|
||||
mode,
|
||||
});
|
||||
};
|
||||
const handlePolicyDetailsModalOpen = useCallback(
|
||||
(mode: PolicyDetailsModalMode, routingPolicy: RoutingPolicy | null): void => {
|
||||
if (routingPolicy) {
|
||||
setSelectedRoutingPolicy(routingPolicy);
|
||||
}
|
||||
setPolicyDetailsModalState({
|
||||
isOpen: true,
|
||||
mode,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePolicyDetailsModalClose = (): void => {
|
||||
setSelectedRoutingPolicy(null);
|
||||
@@ -113,10 +113,13 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteModalOpen = (routingPolicy: RoutingPolicy): void => {
|
||||
setSelectedRoutingPolicy(routingPolicy);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
const handleDeleteModalOpen = useCallback(
|
||||
(routingPolicy: RoutingPolicy): void => {
|
||||
setSelectedRoutingPolicy(routingPolicy);
|
||||
setIsDeleteModalOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteModalClose = (): void => {
|
||||
setSelectedRoutingPolicy(null);
|
||||
@@ -154,7 +157,7 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Routing policy created successfully');
|
||||
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
|
||||
void queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
|
||||
handlePolicyDetailsModalClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -176,7 +179,7 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Routing policy updated successfully');
|
||||
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
|
||||
void queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
|
||||
handlePolicyDetailsModalClose();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -209,7 +212,7 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
deleteRoutingPolicy(selectedRoutingPolicy.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Routing policy deleted successfully');
|
||||
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
|
||||
void queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
|
||||
handleDeleteModalClose();
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import { sortByColumn } from 'components/Alerts/utils';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
|
||||
import { RoutingPolicy } from './types';
|
||||
|
||||
export const ROUTING_POLICIES_QUERY_PARAMS = {
|
||||
orderBy: 'orderBy',
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
function getSortValue(
|
||||
policy: RoutingPolicy,
|
||||
columnName: string,
|
||||
): string | number {
|
||||
switch (columnName) {
|
||||
case 'name':
|
||||
return policy.name.toLowerCase();
|
||||
case 'createdAt':
|
||||
return policy.createdAt ? new Date(policy.createdAt).getTime() : 0;
|
||||
case 'updatedAt':
|
||||
return policy.updatedAt ? new Date(policy.updatedAt).getTime() : 0;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface UseRoutingPoliciesTableReturn {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
calculatedPageSize: number | null;
|
||||
pagedPolicies: RoutingPolicy[];
|
||||
setPage: (page: number) => void;
|
||||
setLimit: (limit: number) => void;
|
||||
}
|
||||
|
||||
function useRoutingPoliciesTable(
|
||||
routingPolicies: RoutingPolicy[],
|
||||
): UseRoutingPoliciesTableReturn {
|
||||
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
|
||||
rowHeight: 46,
|
||||
});
|
||||
|
||||
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
|
||||
ROUTING_POLICIES_QUERY_PARAMS,
|
||||
{
|
||||
page: DEFAULT_PAGE,
|
||||
limit: DEFAULT_LIMIT,
|
||||
storageKey: 'routing-policies',
|
||||
calculatedPageSize,
|
||||
cleanupOnUnmount: true,
|
||||
},
|
||||
);
|
||||
|
||||
const pagedPolicies = useMemo(() => {
|
||||
const sorted = sortByColumn(routingPolicies, orderBy, getSortValue);
|
||||
const start = (page - 1) * limit;
|
||||
return sorted.slice(start, start + limit);
|
||||
}, [routingPolicies, orderBy, page, limit]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
calculatedPageSize,
|
||||
pagedPolicies,
|
||||
setPage,
|
||||
setLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export default useRoutingPoliciesTable;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { act, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -27,7 +28,7 @@ const errors = {
|
||||
};
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const traces = usePreferenceContext()[SavedviewtypesSourceDTO.traces];
|
||||
const { traces } = usePreferenceContext();
|
||||
const [headerKey, setHeaderKey] = useState(0);
|
||||
const columns = traces.preferences?.columns ?? [];
|
||||
|
||||
@@ -36,7 +37,7 @@ function Harness(): JSX.Element {
|
||||
<SavedViewsHeader
|
||||
key={headerKey}
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn() as jest.MockedFunction<() => void>}
|
||||
onOpenViews={jest.fn()}
|
||||
/>
|
||||
<span data-testid="columns">
|
||||
{columns.map((column) => column.name).join(',')}
|
||||
@@ -83,14 +84,13 @@ describe('SavedViewsHeader columns', () => {
|
||||
});
|
||||
|
||||
it('keeps edited columns when the view is fetched again', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSavedViewsApi([errors]);
|
||||
await openView();
|
||||
|
||||
await user.click(screen.getByTestId('add-column'));
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
await user.click(screen.getByTestId('remount-header'));
|
||||
await userEvent.click(screen.getByTestId('remount-header'));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(columnsText()).toBe('service.name,name,extra-2');
|
||||
@@ -98,37 +98,35 @@ describe('SavedViewsHeader columns', () => {
|
||||
});
|
||||
|
||||
it('discard puts the saved columns back', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSavedViewsApi([errors]);
|
||||
await openView();
|
||||
|
||||
await user.click(screen.getByTestId('add-column'));
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-discard'));
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('discard after an update goes back to the updated columns', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSavedViewsApi([errors]);
|
||||
await openView();
|
||||
|
||||
await user.click(screen.getByTestId('add-column'));
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
await user.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
const update = await screen.findByTestId(
|
||||
'saved-views-save-changes-item-update',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
|
||||
await user.click(update);
|
||||
await userEvent.click(update);
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
|
||||
await user.click(screen.getByTestId('add-column'));
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
await user.click(screen.getByTestId('saved-views-discard'));
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
await waitFor(() => expect(columnsText()).toBe('service.name,name,extra-2'));
|
||||
expect(isDirty()).toBe(false);
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { SavedviewtypesSchemaVersionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
buildSavedViewDeletePermission,
|
||||
buildSavedViewUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
|
||||
|
||||
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
|
||||
|
||||
// Only the logs and traces explorers have columns and formatting to save.
|
||||
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
|
||||
Record<SavedviewtypesSourceDTO, DataSource>
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
|
||||
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
// What selecting a view writes to the url, so clearing it removes exactly these.
|
||||
export const SAVED_VIEW_URL_PARAMS = [
|
||||
QueryParams.viewKey,
|
||||
|
||||
@@ -8,9 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
|
||||
|
||||
@@ -20,9 +21,9 @@ export function useActiveSavedView(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
|
||||
@@ -62,7 +63,7 @@ export function useActiveSavedView(
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
options: hasOptions ? options : undefined,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
@@ -70,7 +71,7 @@ export function useActiveSavedView(
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
hasOptions,
|
||||
optionsDataSource,
|
||||
options,
|
||||
]);
|
||||
|
||||
|
||||
@@ -20,12 +20,13 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
|
||||
import { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_OPTIONS_DATA_SOURCE,
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
SAVED_VIEW_URL_PARAMS,
|
||||
@@ -59,11 +60,15 @@ export function useSavedViewActions(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
const preferenceSlice = usePreferenceSlice(source);
|
||||
const preferences = usePreferenceContext();
|
||||
const preferencesSlice =
|
||||
optionsDataSource === DataSource.TRACES
|
||||
? preferences.traces
|
||||
: preferences.logs;
|
||||
|
||||
const { mutateAsync: createSavedView, isLoading: isCreating } =
|
||||
useCreateSavedView();
|
||||
@@ -81,10 +86,10 @@ export function useSavedViewActions(
|
||||
query: stagedQuery ?? currentQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
options: hasOptions ? options : undefined,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
color,
|
||||
}),
|
||||
[stagedQuery, currentQuery, panelType, hasOptions, options],
|
||||
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
@@ -100,14 +105,14 @@ export function useSavedViewActions(
|
||||
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
if (preferenceSlice) {
|
||||
if (optionsDataSource) {
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
source,
|
||||
optionsDataSource,
|
||||
);
|
||||
preferenceSlice.updateColumns(columns);
|
||||
preferencesSlice.updateColumns(columns);
|
||||
if (formatting) {
|
||||
preferenceSlice.updateFormatting(formatting);
|
||||
preferencesSlice.updateFormatting(formatting);
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
@@ -115,7 +120,7 @@ export function useSavedViewActions(
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[source, preferenceSlice, selectView, logSavedViewEvent],
|
||||
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
@@ -10,6 +7,7 @@ import {
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { Preferences } from 'providers/preferences/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function withColumnNames(
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
@@ -27,11 +25,11 @@ function withColumnNames(
|
||||
// defaults filled in where the view has none.
|
||||
export function getViewColumnsAndFormatting(
|
||||
spec: SavedviewtypesSavedViewSpecDTO | undefined,
|
||||
sourcePage: SavedviewtypesSourceDTO,
|
||||
dataSource: DataSource,
|
||||
): Preferences {
|
||||
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
|
||||
|
||||
if (sourcePage === SavedviewtypesSourceDTO.logs) {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
return {
|
||||
columns: ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
@@ -44,7 +42,7 @@ export function getViewColumnsAndFormatting(
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sourcePage === SavedviewtypesSourceDTO.traces) {
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return { columns: selectedFields || defaultTraceSelectedColumns };
|
||||
}
|
||||
return { columns: [] };
|
||||
|
||||
@@ -7,7 +7,6 @@ import { HasUnsavedViewChangesArgs } from '../types';
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
import { toSavedViewPanelType } from './toSavedViewSpec';
|
||||
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
|
||||
import { toSavedViewSource } from './toSavedViewSource';
|
||||
|
||||
// Legacy context names the server stores under their v5 name.
|
||||
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
|
||||
@@ -80,7 +79,7 @@ export function hasUnsavedViewChanges({
|
||||
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
toSavedViewSource(dataSource),
|
||||
dataSource,
|
||||
);
|
||||
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
|
||||
fields.map((field) => field.name ?? field.key);
|
||||
|
||||
@@ -3,8 +3,11 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewSourcePage } from '../types';
|
||||
|
||||
// Queries and the AI assistant name a source by DataSource (the signal), the
|
||||
// api keys views by source page.
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
@@ -87,7 +86,7 @@ function ListView({
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
|
||||
@@ -21,7 +21,15 @@ export default {
|
||||
{
|
||||
kind: 'notification-channel',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
allowedVerbs: [
|
||||
'attach',
|
||||
'create',
|
||||
'delete',
|
||||
'detach',
|
||||
'list',
|
||||
'read',
|
||||
'update',
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'quick-filter',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesEvaluationRollingDTOKind,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleDTOSchemaVersion,
|
||||
RuletypesRuleTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
@@ -38,6 +40,12 @@ const make = (
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m0s',
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
|
||||
evaluation: {
|
||||
kind: RuletypesEvaluationRollingDTOKind.rolling,
|
||||
spec: { evalWindow: '5m0s', frequency: '1m0s' },
|
||||
},
|
||||
notificationSettings: { usePolicy: false, renotify: { enabled: false } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.alerts-container {
|
||||
// Hands the page height down to the active tab so its content can bound itself
|
||||
// instead of guessing with 100vh. Child combinators only, nested Tabs
|
||||
// (Configuration) must not be caught.
|
||||
// (e.g. the alert history timeline) must not be caught.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -55,14 +55,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.configuration-tabs {
|
||||
.ant-tabs-nav {
|
||||
.ant-tabs-nav-wrap {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
@@ -70,4 +62,82 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alert-configuration-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
|
||||
.alert-configuration-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
|
||||
.sidebar-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-2, 4px);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal, 400);
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-background);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sidebar-nav-item-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-configuration-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
> .alert-rules-container {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -111,20 +111,14 @@ export const clickFirstRowAction = async (
|
||||
canvasElement: HTMLElement,
|
||||
action: 'edit' | 'delete',
|
||||
): Promise<void> => {
|
||||
const icon = (
|
||||
await within(canvasElement).findByText(
|
||||
FIRST_DOWNTIME_NAME,
|
||||
undefined,
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: `Actions for ${FIRST_DOWNTIME_NAME}` },
|
||||
untilLoaded,
|
||||
)
|
||||
)
|
||||
.closest('.header-content')
|
||||
// The row action holds edit then delete, neither of them labelled.
|
||||
?.querySelectorAll('.action-btn svg')[action === 'edit' ? 0 : 1];
|
||||
|
||||
if (!icon) {
|
||||
throw new Error(`Downtime ${action} action did not render`);
|
||||
}
|
||||
|
||||
await userEvent.click(icon);
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByText(action === 'edit' ? 'Edit' : 'Delete'),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ const pageStory = storyMocks(plannedDowntimeMocks, { layout: 'app' });
|
||||
* Windows that silence rules on a schedule, one off or recurring, with the rules
|
||||
* each window covers.
|
||||
*
|
||||
* Route: `/alerts?tab=Configuration&subTab=PlannedDowntime`.
|
||||
* Route: `/alerts?tab=Configuration&subTab=planned-downtime`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Planned Downtime',
|
||||
@@ -47,22 +47,22 @@ export const NoDowntimes: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the edit and delete actions on a row and the New downtime button
|
||||
* are gone.
|
||||
* A viewer: the edit and delete actions on a row are gone and New downtime is
|
||||
* disabled.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** A downtime opened up: who scheduled it, the window, and what it silences. */
|
||||
export const Expanded: Story = {
|
||||
/** A downtime opened from its row: the form prefilled with its window and rules. */
|
||||
export const OpenFromRow: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByText(FIRST_DOWNTIME_NAME, undefined, untilLoaded),
|
||||
);
|
||||
await canvas.findByText(/alerts silenced/i);
|
||||
await screen.findByText(/edit planned downtime/i);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,7 +97,9 @@ export const EditDowntime: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirstRowAction(canvasElement, 'edit');
|
||||
await screen.findByText(/edit planned downtime/i);
|
||||
await screen.findByText(/^Postgres connections/);
|
||||
await within(await screen.findByRole('dialog')).findByText(
|
||||
/^Postgres connections/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -118,13 +120,13 @@ export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const search = await canvas.findByPlaceholderText(
|
||||
'Search for a planned downtime...',
|
||||
'Search by name',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.type(search, 'no matching downtime');
|
||||
await canvas.findByRole('table');
|
||||
await canvas.findByText('No matching planned downtime');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
FIRST_POLICY_NAME,
|
||||
ROUTING_POLICY_MAX,
|
||||
routingPoliciesResponse,
|
||||
} from './__story_mockdata__/routingPolicies';
|
||||
@@ -142,7 +143,8 @@ export const fillNewPolicy = async (): Promise<void> => {
|
||||
'severity = "critical"',
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('combobox'));
|
||||
await userEvent.click(await screen.findByTitle('ops-slack'));
|
||||
const options = await screen.findAllByTitle('ops-slack');
|
||||
await userEvent.click(options[options.length - 1]);
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Routing Policy' }),
|
||||
@@ -153,13 +155,11 @@ export const openEditPolicy = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
(
|
||||
await within(canvasElement).findAllByTestId(
|
||||
'edit-routing-policy',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
await within(canvasElement).findByText(
|
||||
FIRST_POLICY_NAME,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Routing Policy' }),
|
||||
|
||||
@@ -22,7 +22,7 @@ const pageStory = storyMocks(routingPoliciesMocks, { layout: 'app' });
|
||||
* Policies that route a firing alert to channels by expression, in the order they
|
||||
* are evaluated.
|
||||
*
|
||||
* Route: `/alerts?tab=Configuration&subTab=RoutingPolicies`.
|
||||
* Route: `/alerts?tab=Configuration&subTab=routing-policies`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Routing Policies',
|
||||
@@ -47,20 +47,20 @@ export const NoPolicies: Story = {
|
||||
args: { policies: 0 },
|
||||
};
|
||||
|
||||
/** A viewer: the row actions and the New routing policy button are gone. */
|
||||
/** A viewer: the row actions are gone and New routing policy is disabled. */
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** A policy opened up: the expression it matches on and where it sends. */
|
||||
export const Expanded: Story = {
|
||||
/** A policy opened from its row: the form prefilled with its expression and channels. */
|
||||
export const EditPolicy: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByText(FIRST_POLICY_NAME, undefined, untilLoaded),
|
||||
);
|
||||
await canvas.findByText(/expression/i);
|
||||
await screen.findByText(/edit routing policy/i);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,15 +83,13 @@ export const DeletePolicyConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
(
|
||||
await canvas.findAllByTestId(
|
||||
'delete-routing-policy',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
const [firstRowActions] = await canvas.findAllByRole(
|
||||
'button',
|
||||
{ name: /^Actions for / },
|
||||
untilLoaded,
|
||||
);
|
||||
await userEvent.click(firstRowActions);
|
||||
await userEvent.click(await screen.findByText('Delete'));
|
||||
// The modal titles itself and its confirm button the same.
|
||||
await screen.findByRole('button', { name: 'Delete Routing Policy' });
|
||||
},
|
||||
@@ -102,13 +100,13 @@ export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const search = await canvas.findByPlaceholderText(
|
||||
'Search for a routing policy...',
|
||||
'Search by name or description',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.type(search, 'no matching policy');
|
||||
await canvas.findByText('No matching routing policies found.');
|
||||
await canvas.findByText('No matching routing policies');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -140,6 +138,10 @@ export const FormValidationError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const expectToast = async (text: RegExp): Promise<void> => {
|
||||
await waitFor(() => expect(screen.getByText(text)).toBeVisible());
|
||||
};
|
||||
|
||||
const confirmDelete = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await DeletePolicyConfirm.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
@@ -152,13 +154,11 @@ export const CreatePolicyToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await NewPolicy.play?.({ canvasElement } as never);
|
||||
await fillNewPolicy();
|
||||
if (args.createState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/routing policy created successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
await expectToast(
|
||||
args.createState === 'success'
|
||||
? /routing policy created successfully/i
|
||||
: /Policy name already exists/i,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -173,13 +173,11 @@ export const CreatePolicyErrorToast: Story = {
|
||||
export const UpdatePolicyToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openEditPolicy(canvasElement);
|
||||
if (args.updateState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/routing policy updated successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
await expectToast(
|
||||
args.updateState === 'success'
|
||||
? /routing policy updated successfully/i
|
||||
: /failed to update routing policy/i,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -194,13 +192,11 @@ export const UpdatePolicyErrorToast: Story = {
|
||||
export const DeletePolicyToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await confirmDelete(canvasElement);
|
||||
if (args.deleteState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/routing policy deleted successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
await expectToast(
|
||||
args.deleteState === 'success'
|
||||
? /routing policy deleted successfully/i
|
||||
: /failed to delete routing policy/i,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ const LIST_ALERT_RULES_TEXT = 'List Alert Rules Component';
|
||||
const TRIGGERED_ALERTS_TEXT = 'Triggered Alerts';
|
||||
const ALERT_RULES_TEXT = 'Alert Rules';
|
||||
const CONFIGURATION_TEXT = 'Configuration';
|
||||
const NOTIFICATION_CHANNELS_TEXT = 'Notification Channels';
|
||||
const PLANNED_DOWNTIME_TEXT = 'Planned Downtime';
|
||||
const ROUTING_POLICIES_TEXT = 'Routing Policies';
|
||||
const CHANNELS_SUB_TAB = 'channels';
|
||||
const PLANNED_DOWNTIME_SUB_TAB = 'planned-downtime';
|
||||
const ROUTING_POLICIES_SUB_TAB = 'routing-policies';
|
||||
|
||||
@@ -45,6 +47,12 @@ jest.mock('pages/AlertDetails', () => ({
|
||||
return <div>Alert Details Component</div>;
|
||||
},
|
||||
}));
|
||||
jest.mock('../NotificationChannels/NotificationChannels', () => ({
|
||||
__esModule: true,
|
||||
default: function MockNotificationChannels(): JSX.Element {
|
||||
return <div>Notification Channels Component</div>;
|
||||
},
|
||||
}));
|
||||
jest.mock('container/PlannedDowntime/PlannedDowntime', () => ({
|
||||
PlannedDowntime: function MockPlannedDowntime(): JSX.Element {
|
||||
return <div>Planned Downtime Component</div>;
|
||||
@@ -137,15 +145,6 @@ describe('AlertList', () => {
|
||||
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Configuration tab with default Planned Downtime sub-tab when tab query param is Configuration', () => {
|
||||
mockQueryParams({ tab: 'Configuration' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to TriggeredAlerts tab when clicked', () => {
|
||||
mockQueryParams({ tab: 'AlertRules' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
@@ -167,74 +166,109 @@ describe('AlertList', () => {
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
|
||||
});
|
||||
|
||||
it('should navigate to Configuration tab with default channels subTab when clicked', () => {
|
||||
mockQueryParams({ tab: 'AlertRules' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
clickTab(CONFIGURATION_TEXT);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/alerts?tab=Configuration&subTab=${CHANNELS_SUB_TAB}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Tab', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render Configuration tab with default Planned Downtime sub-tab', () => {
|
||||
mockQueryParams({ tab: CONFIGURATION_TEXT });
|
||||
mockLocation(ALERTS_PATH);
|
||||
describe('Configuration Tab with Secondary Sidebar', () => {
|
||||
it('should render Configuration tab with secondary sidebar containing channels, routing policies, and planned downtime', () => {
|
||||
mockQueryParams({ tab: 'Configuration' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Routing Policies sub-tab when subTab query param is routing-policies', () => {
|
||||
mockQueryParams({
|
||||
tab: CONFIGURATION_TEXT,
|
||||
subTab: ROUTING_POLICIES_SUB_TAB,
|
||||
});
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('alert-configuration-sidebar'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(NOTIFICATION_CHANNELS_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Notification Channels Component'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate to Configuration tab with default subTab when clicked', () => {
|
||||
mockQueryParams({ tab: 'AlertRules' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
clickTab(CONFIGURATION_TEXT);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/alerts?tab=Configuration&subTab=${PLANNED_DOWNTIME_SUB_TAB}`,
|
||||
);
|
||||
it('should render Routing Policies when subTab is routing-policies', () => {
|
||||
mockQueryParams({
|
||||
tab: 'Configuration',
|
||||
subTab: ROUTING_POLICIES_SUB_TAB,
|
||||
});
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
it('should preserve existing subTab when navigating to Configuration tab', () => {
|
||||
mockQueryParams({ tab: 'AlertRules', subTab: ROUTING_POLICIES_SUB_TAB });
|
||||
mockLocation(ALERTS_PATH);
|
||||
render(<AlertList />);
|
||||
|
||||
render(<AlertList />);
|
||||
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
clickTab(CONFIGURATION_TEXT);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/alerts?tab=Configuration&subTab=${ROUTING_POLICIES_SUB_TAB}`,
|
||||
);
|
||||
it('should render Planned Downtime when subTab is planned-downtime', () => {
|
||||
mockQueryParams({
|
||||
tab: 'Configuration',
|
||||
subTab: PLANNED_DOWNTIME_SUB_TAB,
|
||||
});
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
it('should clear subTab when navigating away from Configuration tab', () => {
|
||||
mockQueryParams({
|
||||
tab: CONFIGURATION_TEXT,
|
||||
subTab: PLANNED_DOWNTIME_SUB_TAB,
|
||||
});
|
||||
mockLocation(ALERTS_PATH);
|
||||
render(<AlertList />);
|
||||
|
||||
render(<AlertList />);
|
||||
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
clickTab(ALERT_RULES_TEXT);
|
||||
it('should navigate to Routing Policies subtab when clicked in secondary sidebar', () => {
|
||||
mockQueryParams({ tab: 'Configuration', subTab: CHANNELS_SUB_TAB });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
|
||||
});
|
||||
render(<AlertList />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByTestId('configuration-sidebar-routing-policies'),
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/alerts?tab=Configuration&subTab=${ROUTING_POLICIES_SUB_TAB}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should navigate to Planned Downtime subtab when clicked in secondary sidebar', () => {
|
||||
mockQueryParams({ tab: 'Configuration', subTab: CHANNELS_SUB_TAB });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByTestId('configuration-sidebar-planned-downtime'),
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/alerts?tab=Configuration&subTab=${PLANNED_DOWNTIME_SUB_TAB}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support legacy RoutingPolicies query param as Configuration tab', () => {
|
||||
mockQueryParams({ tab: 'RoutingPolicies' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support legacy PlannedDowntime query param as Configuration tab', () => {
|
||||
mockQueryParams({ tab: 'PlannedDowntime' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@ import RoutingPolicies from 'container/RoutingPolicies';
|
||||
import TriggeredAlerts from 'container/TriggeredAlerts';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { Cable, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import AlertDetails from 'pages/AlertDetails';
|
||||
import ChannelsEdit from 'pages/ChannelsEdit';
|
||||
import ChannelsNew from 'pages/ChannelsNew';
|
||||
@@ -21,6 +22,30 @@ import { AlertListSubTabs, AlertListTabs } from './types';
|
||||
|
||||
import './AlertList.styles.scss';
|
||||
|
||||
interface ConfigurationSidebarItem {
|
||||
key: AlertListSubTabs;
|
||||
label: string;
|
||||
itemKey: string;
|
||||
}
|
||||
|
||||
const CONFIGURATION_SIDEBAR_ITEMS: ConfigurationSidebarItem[] = [
|
||||
{
|
||||
key: AlertListSubTabs.CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
itemKey: 'configuration-sidebar-channels',
|
||||
},
|
||||
{
|
||||
key: AlertListSubTabs.ROUTING_POLICIES,
|
||||
label: 'Routing Policies',
|
||||
itemKey: 'configuration-sidebar-routing-policies',
|
||||
},
|
||||
{
|
||||
key: AlertListSubTabs.PLANNED_DOWNTIME,
|
||||
label: 'Planned Downtime',
|
||||
itemKey: 'configuration-sidebar-planned-downtime',
|
||||
},
|
||||
];
|
||||
|
||||
function AllAlertList(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
@@ -34,39 +59,126 @@ function AllAlertList(): JSX.Element {
|
||||
const isChannelsEdit = location.pathname.startsWith('/alerts/channels/edit/');
|
||||
const isChannelDetails = isChannelsNew || isChannelsEdit;
|
||||
|
||||
const handleConfigurationTabChange = useCallback(
|
||||
(subTab: string): void => {
|
||||
const queryParams = new URLSearchParams();
|
||||
const getActiveTab = useCallback((): string => {
|
||||
if (isAlertHistory || isAlertOverview) {
|
||||
return AlertListTabs.ALERT_RULES;
|
||||
}
|
||||
if (isChannelDetails) {
|
||||
return AlertListTabs.CONFIGURATION;
|
||||
}
|
||||
if (
|
||||
tab === AlertListTabs.CHANNELS ||
|
||||
tab === AlertListTabs.ROUTING_POLICIES ||
|
||||
tab === AlertListTabs.PLANNED_DOWNTIME
|
||||
) {
|
||||
return AlertListTabs.CONFIGURATION;
|
||||
}
|
||||
return tab || AlertListTabs.ALERT_RULES;
|
||||
}, [isAlertHistory, isAlertOverview, isChannelDetails, tab]);
|
||||
|
||||
const getActiveSubTab = useCallback((): AlertListSubTabs => {
|
||||
if (isChannelDetails) {
|
||||
return AlertListSubTabs.CHANNELS;
|
||||
}
|
||||
if (tab === AlertListTabs.CHANNELS) {
|
||||
return AlertListSubTabs.CHANNELS;
|
||||
}
|
||||
if (tab === AlertListTabs.ROUTING_POLICIES) {
|
||||
return AlertListSubTabs.ROUTING_POLICIES;
|
||||
}
|
||||
if (tab === AlertListTabs.PLANNED_DOWNTIME) {
|
||||
return AlertListSubTabs.PLANNED_DOWNTIME;
|
||||
}
|
||||
if (
|
||||
subTab === AlertListSubTabs.CHANNELS ||
|
||||
subTab === AlertListSubTabs.ROUTING_POLICIES ||
|
||||
subTab === AlertListSubTabs.PLANNED_DOWNTIME
|
||||
) {
|
||||
return subTab;
|
||||
}
|
||||
return AlertListSubTabs.CHANNELS;
|
||||
}, [isChannelDetails, tab, subTab]);
|
||||
|
||||
const handleTopTabChange = useCallback(
|
||||
(newTab: string): void => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('tab', newTab);
|
||||
|
||||
if (newTab === AlertListTabs.CONFIGURATION) {
|
||||
const currentSubTab = getActiveSubTab();
|
||||
queryParams.set('subTab', currentSubTab);
|
||||
}
|
||||
|
||||
safeNavigate(`/alerts?${queryParams.toString()}`);
|
||||
},
|
||||
[getActiveSubTab, safeNavigate],
|
||||
);
|
||||
|
||||
const handleSubTabChange = useCallback(
|
||||
(newSubTab: AlertListSubTabs): void => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('tab', AlertListTabs.CONFIGURATION);
|
||||
queryParams.set('subTab', subTab);
|
||||
queryParams.set('subTab', newSubTab);
|
||||
safeNavigate(`/alerts?${queryParams.toString()}`);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const configurationTab = useMemo(() => {
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Planned Downtime',
|
||||
key: AlertListSubTabs.PLANNED_DOWNTIME,
|
||||
children: <PlannedDowntime />,
|
||||
},
|
||||
{
|
||||
label: 'Routing Policies',
|
||||
key: AlertListSubTabs.ROUTING_POLICIES,
|
||||
children: <RoutingPolicies />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Tabs
|
||||
className="configuration-tabs"
|
||||
activeKey={subTab || AlertListSubTabs.PLANNED_DOWNTIME}
|
||||
items={tabs}
|
||||
onChange={handleConfigurationTabChange}
|
||||
/>
|
||||
);
|
||||
}, [subTab, handleConfigurationTabChange]);
|
||||
const activeSubTab = getActiveSubTab();
|
||||
|
||||
const configurationTab = useMemo(
|
||||
() => (
|
||||
<div className="alert-configuration-container">
|
||||
<div
|
||||
className="alert-configuration-sidebar"
|
||||
data-testid="alert-configuration-sidebar"
|
||||
>
|
||||
{CONFIGURATION_SIDEBAR_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={cx('sidebar-nav-item', {
|
||||
active: activeSubTab === item.key,
|
||||
})}
|
||||
onClick={(): void => handleSubTabChange(item.key)}
|
||||
data-testid={item.itemKey}
|
||||
>
|
||||
<span className="sidebar-nav-item-label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="alert-configuration-content"
|
||||
data-testid="alert-configuration-content"
|
||||
>
|
||||
{activeSubTab === AlertListSubTabs.CHANNELS && (
|
||||
<div className="alert-rules-container">
|
||||
{isChannelsNew && <ChannelsNew />}
|
||||
{isChannelsEdit && <ChannelsEdit />}
|
||||
{!isChannelDetails && <NotificationChannels />}
|
||||
</div>
|
||||
)}
|
||||
{activeSubTab === AlertListSubTabs.ROUTING_POLICIES && (
|
||||
<div className="alert-rules-container">
|
||||
<RoutingPolicies />
|
||||
</div>
|
||||
)}
|
||||
{activeSubTab === AlertListSubTabs.PLANNED_DOWNTIME && (
|
||||
<div className="alert-rules-container">
|
||||
<PlannedDowntime />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
activeSubTab,
|
||||
handleSubTabChange,
|
||||
isChannelsNew,
|
||||
isChannelsEdit,
|
||||
isChannelDetails,
|
||||
],
|
||||
);
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
@@ -93,22 +205,6 @@ function AllAlertList(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="periscope-tab top-level-tab">
|
||||
<Cable size={14} />
|
||||
Notification Channels
|
||||
</div>
|
||||
),
|
||||
key: AlertListTabs.CHANNELS,
|
||||
children: (
|
||||
<div className="alert-rules-container">
|
||||
{isChannelsNew && <ChannelsNew />}
|
||||
{isChannelsEdit && <ChannelsEdit />}
|
||||
{!isChannelDetails && <NotificationChannels />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="periscope-tab top-level-tab">
|
||||
@@ -121,37 +217,12 @@ function AllAlertList(): JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
const getActiveKey = (): string => {
|
||||
if (isAlertHistory || isAlertOverview) {
|
||||
return AlertListTabs.ALERT_RULES;
|
||||
}
|
||||
if (isChannelDetails) {
|
||||
return AlertListTabs.CHANNELS;
|
||||
}
|
||||
return tab || AlertListTabs.ALERT_RULES;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
destroyInactiveTabPane
|
||||
items={items}
|
||||
activeKey={getActiveKey()}
|
||||
onChange={(tab): void => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
queryParams.set('tab', tab);
|
||||
|
||||
// If navigating to Configuration tab, set default subTab
|
||||
if (tab === AlertListTabs.CONFIGURATION) {
|
||||
const currentSubTab = subTab || AlertListSubTabs.PLANNED_DOWNTIME;
|
||||
queryParams.set('subTab', currentSubTab);
|
||||
} else {
|
||||
// Clear subTab when navigating out of Configuration tab
|
||||
queryParams.delete('subTab');
|
||||
}
|
||||
|
||||
safeNavigate(`/alerts?${queryParams.toString()}`);
|
||||
}}
|
||||
activeKey={getActiveTab()}
|
||||
onChange={handleTopTabChange}
|
||||
className={`alerts-container ${
|
||||
isAlertHistory || isAlertOverview || isChannelDetails
|
||||
? 'alert-details-tabs'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RuletypesMatchTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleDTOSchemaVersion,
|
||||
RuletypesRuleTypeDTO,
|
||||
RuletypesSeasonalityDTO,
|
||||
RuletypesThresholdBasicDTOKind,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
type AlertmanagertypesGettableNotificationChannelDTO,
|
||||
type AlertmanagertypesListedNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
@@ -388,18 +388,18 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
|
||||
},
|
||||
evalWindow: isAnomaly ? '1h0m0s' : '5m0s',
|
||||
frequency: '1m0s',
|
||||
...(isAnomaly && {
|
||||
evaluation: {
|
||||
kind: RuletypesEvaluationRollingDTOKind.rolling,
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m0s' },
|
||||
},
|
||||
}),
|
||||
evaluation: {
|
||||
kind: RuletypesEvaluationRollingDTOKind.rolling,
|
||||
spec: { evalWindow: isAnomaly ? '1h0m0s' : '5m0s', frequency: '1m0s' },
|
||||
},
|
||||
createdAt: ago((index + 3) * DAY),
|
||||
updatedAt: ago((index + 1) * HOUR),
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedBy: 'grace@signoz.io',
|
||||
schemaVersion:
|
||||
shape.schema === 'classic' ? undefined : NEW_ALERT_SCHEMA_VERSION,
|
||||
// classic rows predate schemaVersion, which the spec now requires
|
||||
schemaVersion: (shape.schema === 'classic'
|
||||
? undefined
|
||||
: RuletypesRuleDTOSchemaVersion.v2alpha1) as RuletypesRuleDTOSchemaVersion,
|
||||
version: 'v5',
|
||||
source: 'http://localhost/alerts',
|
||||
preferredChannels: ['ops-slack'],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum AlertListSubTabs {
|
||||
PLANNED_DOWNTIME = 'planned-downtime',
|
||||
CHANNELS = 'channels',
|
||||
ROUTING_POLICIES = 'routing-policies',
|
||||
PLANNED_DOWNTIME = 'planned-downtime',
|
||||
}
|
||||
|
||||
export enum AlertListTabs {
|
||||
@@ -8,4 +9,6 @@ export enum AlertListTabs {
|
||||
ALERT_RULES = 'AlertRules',
|
||||
CONFIGURATION = 'Configuration',
|
||||
CHANNELS = 'Channels',
|
||||
ROUTING_POLICIES = 'RoutingPolicies',
|
||||
PLANNED_DOWNTIME = 'PlannedDowntime',
|
||||
}
|
||||
|
||||
@@ -166,7 +166,10 @@ function PanelEditorQueryBuilder({
|
||||
return modes.map((queryType) => ({
|
||||
key: queryType,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
<div
|
||||
className={styles.queryTypeTab}
|
||||
data-testid={`query-type-tab-${queryType}`}
|
||||
>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
import { LogViewMode } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceContext } from '../context/PreferenceContextProvider';
|
||||
import { PreferenceSourcePage } from '../types';
|
||||
|
||||
const ROUTE_LOGS = '/logs';
|
||||
const ROUTE_TRACES = '/traces';
|
||||
@@ -38,13 +38,14 @@ function getLocalStorageJSON<T>(key: string): T | null {
|
||||
}
|
||||
|
||||
function Consumer({
|
||||
sourcePage,
|
||||
dataSource,
|
||||
testIdPrefix,
|
||||
}: {
|
||||
sourcePage: PreferenceSourcePage;
|
||||
dataSource: DataSource;
|
||||
testIdPrefix: string;
|
||||
}): JSX.Element {
|
||||
const slice = usePreferenceContext()[sourcePage];
|
||||
const ctx = usePreferenceContext();
|
||||
const slice = dataSource === DataSource.TRACES ? ctx.traces : ctx.logs;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -57,7 +58,7 @@ function Consumer({
|
||||
type="button"
|
||||
onClick={(): void => {
|
||||
const newCols: TelemetryFieldKey[] =
|
||||
sourcePage === SavedviewtypesSourceDTO.traces
|
||||
dataSource === DataSource.TRACES
|
||||
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
slice.updateColumns(newCols);
|
||||
@@ -77,10 +78,7 @@ describe('PreferencesProvider integration', () => {
|
||||
describe('Logs', () => {
|
||||
it('loads defaults when no localStorage or url provided', () => {
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.logs}
|
||||
testIdPrefix={TESTID_LOGS}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
@@ -103,10 +101,7 @@ describe('PreferencesProvider integration', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.logs}
|
||||
testIdPrefix={TESTID_LOGS}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
@@ -121,10 +116,7 @@ describe('PreferencesProvider integration', () => {
|
||||
it('direct mode updateColumns persists to localStorage', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.logs}
|
||||
testIdPrefix={TESTID_LOGS}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
@@ -150,7 +142,7 @@ describe('PreferencesProvider integration', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<Consumer sourcePage={SavedviewtypesSourceDTO.logs} testIdPrefix="logs" />,
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix="logs" />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: initialEntry,
|
||||
@@ -183,10 +175,7 @@ describe('PreferencesProvider integration', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.logs}
|
||||
testIdPrefix={TESTID_LOGS}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
@@ -208,7 +197,7 @@ describe('PreferencesProvider integration', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
function FormattingConsumer(): JSX.Element {
|
||||
const logs = usePreferenceContext()[SavedviewtypesSourceDTO.logs];
|
||||
const { logs } = usePreferenceContext();
|
||||
return (
|
||||
<button
|
||||
data-testid="logs-update-formatting"
|
||||
@@ -246,10 +235,7 @@ describe('PreferencesProvider integration', () => {
|
||||
const initialEntry = `/logs?viewKey=${encodeURIComponent(viewKey)}`;
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.logs}
|
||||
testIdPrefix={TESTID_LOGS}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{ initialRoute: initialEntry },
|
||||
);
|
||||
@@ -268,10 +254,7 @@ describe('PreferencesProvider integration', () => {
|
||||
describe('Traces', () => {
|
||||
it('loads defaults when no localStorage or url provided', () => {
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix={TESTID_TRACES}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_TRACES,
|
||||
@@ -290,10 +273,7 @@ describe('PreferencesProvider integration', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix={TESTID_TRACES}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_TRACES,
|
||||
@@ -306,10 +286,7 @@ describe('PreferencesProvider integration', () => {
|
||||
it('direct mode updateColumns persists to localStorage', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix={TESTID_TRACES}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_TRACES,
|
||||
@@ -332,10 +309,7 @@ describe('PreferencesProvider integration', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix="traces"
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix="traces" />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: initialEntry,
|
||||
@@ -364,10 +338,7 @@ describe('PreferencesProvider integration', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix={TESTID_TRACES}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{ initialRoute: ROUTE_TRACES },
|
||||
);
|
||||
@@ -384,7 +355,7 @@ describe('PreferencesProvider integration', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
function TracesFormattingConsumer(): JSX.Element {
|
||||
const traces = usePreferenceContext()[SavedviewtypesSourceDTO.traces];
|
||||
const { traces } = usePreferenceContext();
|
||||
return (
|
||||
<button
|
||||
data-testid="traces-update-formatting"
|
||||
@@ -419,10 +390,7 @@ describe('PreferencesProvider integration', () => {
|
||||
const initialEntry = `/traces?viewKey=${encodeURIComponent(viewKey)}`;
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix={TESTID_TRACES}
|
||||
/>,
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{ initialRoute: initialEntry },
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { renderHook, waitFor } from 'tests/test-utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import logsLoaderConfig from '../configs/logsLoaderConfig';
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
@@ -45,18 +42,15 @@ jest.mock('../configs/tracesLoaderConfig', () => ({
|
||||
}));
|
||||
|
||||
describe('usePreferenceLoader', () => {
|
||||
const setReSync = jest.fn() as jest.MockedFunction<
|
||||
Dispatch<SetStateAction<boolean>>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should load logs preferences based on priority order', async () => {
|
||||
const setReSync = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceLoader({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
reSync: false,
|
||||
setReSync,
|
||||
}),
|
||||
@@ -77,9 +71,10 @@ describe('usePreferenceLoader', () => {
|
||||
});
|
||||
|
||||
it('should load traces preferences', async () => {
|
||||
const setReSync = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceLoader({
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
reSync: false,
|
||||
setReSync,
|
||||
}),
|
||||
@@ -98,11 +93,13 @@ describe('usePreferenceLoader', () => {
|
||||
});
|
||||
|
||||
it('should call setReSync when reSync is true', async () => {
|
||||
const setReSync = jest.fn();
|
||||
|
||||
// Test that the hook calls setReSync(false) when reSync is true
|
||||
// We'll unmount quickly to avoid the infinite loop
|
||||
const { unmount } = renderHook(() =>
|
||||
usePreferenceLoader({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
reSync: true,
|
||||
setReSync,
|
||||
}),
|
||||
@@ -121,25 +118,19 @@ describe('usePreferenceLoader', () => {
|
||||
|
||||
it('should handle errors during loading', async () => {
|
||||
// Make first call succeed (initial state), second call throw in reSync effect
|
||||
const localSpy = jest.spyOn(logsLoaderConfig, 'local');
|
||||
// The loader reads stored columns by name, though `local()` types them as
|
||||
// the older key-based shape.
|
||||
const localSpy: jest.SpyInstance = jest.spyOn(logsLoaderConfig, 'local');
|
||||
localSpy.mockImplementationOnce(() => ({
|
||||
columns: [{ name: 'local-column' }] as unknown as BaseAutocompleteData[],
|
||||
formatting: {
|
||||
maxLines: 5,
|
||||
format: 'table',
|
||||
fontSize: FontSize.MEDIUM,
|
||||
version: 1,
|
||||
},
|
||||
columns: [{ name: 'local-column' }],
|
||||
formatting: { maxLines: 5, format: 'table', fontSize: 'medium', version: 1 },
|
||||
}));
|
||||
localSpy.mockImplementationOnce(() => {
|
||||
throw new Error('Loading failed');
|
||||
});
|
||||
|
||||
const setReSync = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceLoader({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
reSync: true,
|
||||
setReSync,
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
@@ -8,21 +9,32 @@ import {
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
import { usePreferenceSync } from '../sync/usePreferenceSync';
|
||||
import { PreferenceMode, Preferences } from '../types';
|
||||
import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
|
||||
import { PreferenceMode } from '../types';
|
||||
|
||||
jest.mock('api/generated/services/saved-view');
|
||||
|
||||
jest.mock('../loader/usePreferenceLoader');
|
||||
jest.mock('../updater/usePreferenceUpdater');
|
||||
const loaderPreferences = { columns: [{ name: 'from-loader' }] };
|
||||
jest.mock('../loader/usePreferenceLoader', () => ({
|
||||
usePreferenceLoader: jest.fn(() => ({
|
||||
preferences: loaderPreferences,
|
||||
loading: false,
|
||||
error: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
const loaderPreferences: Preferences = { columns: [{ name: 'from-loader' }] };
|
||||
jest.mock('../updater/usePreferenceUpdater', () => ({
|
||||
usePreferenceUpdater: jest.fn(() => ({
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseGetSavedView = jest.mocked(useGetSavedView);
|
||||
const mockedUseGetSavedView = useGetSavedView as jest.MockedFunction<
|
||||
typeof useGetSavedView
|
||||
>;
|
||||
|
||||
function makeView(
|
||||
id: string,
|
||||
@@ -64,19 +76,6 @@ function mockViews(views: SavedviewtypesSavedViewDTO[]): void {
|
||||
describe('usePreferenceSync in saved view mode', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseGetSavedView.mockReset();
|
||||
jest.mocked(usePreferenceLoader).mockReturnValue({
|
||||
preferences: loaderPreferences,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
jest.mocked(usePreferenceUpdater).mockReturnValue({
|
||||
updateColumns: jest.fn() as jest.MockedFunction<
|
||||
ReturnType<typeof usePreferenceUpdater>['updateColumns']
|
||||
>,
|
||||
updateFormatting: jest.fn() as jest.MockedFunction<
|
||||
ReturnType<typeof usePreferenceUpdater>['updateFormatting']
|
||||
>,
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches the view only in saved view mode with a view id', () => {
|
||||
@@ -85,7 +84,7 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.DIRECT,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: undefined,
|
||||
}),
|
||||
);
|
||||
@@ -102,7 +101,7 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.DIRECT,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: undefined,
|
||||
}),
|
||||
);
|
||||
@@ -121,7 +120,7 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: 'view-1',
|
||||
}),
|
||||
);
|
||||
@@ -148,7 +147,7 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: 'view-1',
|
||||
}),
|
||||
);
|
||||
@@ -175,14 +174,14 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
const withFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
savedViewId: 'with-fields',
|
||||
}),
|
||||
);
|
||||
const withoutFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
savedViewId: 'without-fields',
|
||||
}),
|
||||
);
|
||||
@@ -203,7 +202,7 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: 'missing',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
FormattingOptions,
|
||||
PreferenceMode,
|
||||
Preferences,
|
||||
} from 'providers/preferences/types';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import getLogsUpdaterConfig from '../configs/logsUpdaterConfig';
|
||||
import getTracesUpdaterConfig from '../configs/tracesUpdaterConfig';
|
||||
import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
|
||||
|
||||
jest.mock('../configs/logsUpdaterConfig');
|
||||
jest.mock('../configs/tracesUpdaterConfig');
|
||||
jest.mock('hooks/useUrlQueryData');
|
||||
// Mock the config updaters
|
||||
const mockUpdateColumns = jest.fn();
|
||||
const mockUpdateFormatting = jest.fn();
|
||||
|
||||
type LogsUpdater = ReturnType<typeof getLogsUpdaterConfig>;
|
||||
type TracesUpdater = ReturnType<typeof getTracesUpdaterConfig>;
|
||||
jest.mock('../configs/logsUpdaterConfig', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockLogsUpdateColumns = jest.fn() as jest.MockedFunction<
|
||||
LogsUpdater['updateColumns']
|
||||
>;
|
||||
const mockLogsUpdateFormatting = jest.fn() as jest.MockedFunction<
|
||||
LogsUpdater['updateFormatting']
|
||||
>;
|
||||
const mockTracesUpdateColumns = jest.fn() as jest.MockedFunction<
|
||||
TracesUpdater['updateColumns']
|
||||
>;
|
||||
const mockTracesUpdateFormatting = jest.fn() as jest.MockedFunction<
|
||||
TracesUpdater['updateFormatting']
|
||||
>;
|
||||
jest.mock('../configs/tracesUpdaterConfig', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the URL query hook
|
||||
jest.mock('hooks/useUrlQueryData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue({
|
||||
redirectWithQuery: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('usePreferenceUpdater', () => {
|
||||
const mockPreferences: Preferences = {
|
||||
@@ -45,36 +51,18 @@ describe('usePreferenceUpdater', () => {
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
const setReSync = jest.fn() as jest.MockedFunction<
|
||||
Dispatch<SetStateAction<boolean>>
|
||||
>;
|
||||
const setSavedViewPreferences = jest.fn() as jest.MockedFunction<
|
||||
Dispatch<SetStateAction<Preferences | null>>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getLogsUpdaterConfig).mockReturnValue({
|
||||
updateColumns: mockLogsUpdateColumns,
|
||||
updateFormatting: mockLogsUpdateFormatting,
|
||||
});
|
||||
jest.mocked(getTracesUpdaterConfig).mockReturnValue({
|
||||
updateColumns: mockTracesUpdateColumns,
|
||||
updateFormatting: mockTracesUpdateFormatting,
|
||||
});
|
||||
jest.mocked(useUrlQueryData).mockReturnValue({
|
||||
query: null,
|
||||
queryData: undefined,
|
||||
redirectWithQuery: jest.fn() as jest.MockedFunction<
|
||||
(newQueryData: unknown) => void
|
||||
>,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return updateColumns and updateFormatting functions', () => {
|
||||
const setReSync = jest.fn();
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceUpdater({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
mode: PreferenceMode.DIRECT,
|
||||
preferences: mockPreferences,
|
||||
setReSync,
|
||||
@@ -87,7 +75,9 @@ describe('usePreferenceUpdater', () => {
|
||||
expect(typeof result.current.updateFormatting).toBe('function');
|
||||
});
|
||||
|
||||
it('should call the logs updater for updateColumns with the logs list key', () => {
|
||||
it('should call the logs updater for updateColumns with logs dataSource', () => {
|
||||
const setReSync = jest.fn();
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const newColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'new-column',
|
||||
@@ -98,7 +88,7 @@ describe('usePreferenceUpdater', () => {
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceUpdater({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
mode: PreferenceMode.DIRECT,
|
||||
preferences: mockPreferences,
|
||||
setReSync,
|
||||
@@ -111,15 +101,16 @@ describe('usePreferenceUpdater', () => {
|
||||
});
|
||||
|
||||
// Should call the logs updater
|
||||
expect(mockLogsUpdateColumns).toHaveBeenCalledWith(
|
||||
expect(mockUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
);
|
||||
expect(mockTracesUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call the logs updater for updateFormatting with the logs list key', () => {
|
||||
it('should call the logs updater for updateFormatting with logs dataSource', () => {
|
||||
const setReSync = jest.fn();
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const newFormatting: FormattingOptions = {
|
||||
maxLines: 10,
|
||||
format: 'table' as LogViewMode,
|
||||
@@ -129,7 +120,7 @@ describe('usePreferenceUpdater', () => {
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceUpdater({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
mode: PreferenceMode.DIRECT,
|
||||
preferences: mockPreferences,
|
||||
setReSync,
|
||||
@@ -142,15 +133,16 @@ describe('usePreferenceUpdater', () => {
|
||||
});
|
||||
|
||||
// Should call the logs updater
|
||||
expect(mockLogsUpdateFormatting).toHaveBeenCalledWith(
|
||||
expect(mockUpdateFormatting).toHaveBeenCalledWith(
|
||||
newFormatting,
|
||||
PreferenceMode.DIRECT,
|
||||
);
|
||||
expect(mockTracesUpdateFormatting).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call the traces updater for updateColumns with the traces list key', () => {
|
||||
it('should call the traces updater for updateColumns with traces dataSource', () => {
|
||||
const setReSync = jest.fn();
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const newColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'new-trace-column',
|
||||
@@ -161,7 +153,7 @@ describe('usePreferenceUpdater', () => {
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceUpdater({
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
mode: PreferenceMode.DIRECT,
|
||||
preferences: mockPreferences,
|
||||
setReSync,
|
||||
@@ -174,15 +166,16 @@ describe('usePreferenceUpdater', () => {
|
||||
});
|
||||
|
||||
// Should call the traces updater
|
||||
expect(mockTracesUpdateColumns).toHaveBeenCalledWith(
|
||||
expect(mockUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
);
|
||||
expect(mockLogsUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call the traces updater for updateFormatting with the traces list key', () => {
|
||||
it('should call the traces updater for updateFormatting with traces dataSource', () => {
|
||||
const setReSync = jest.fn();
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const newFormatting: FormattingOptions = {
|
||||
maxLines: 10,
|
||||
format: 'table' as LogViewMode,
|
||||
@@ -192,7 +185,7 @@ describe('usePreferenceUpdater', () => {
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceUpdater({
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
mode: PreferenceMode.DIRECT,
|
||||
preferences: mockPreferences,
|
||||
setReSync,
|
||||
@@ -205,18 +198,20 @@ describe('usePreferenceUpdater', () => {
|
||||
});
|
||||
|
||||
// Should call the traces updater
|
||||
expect(mockTracesUpdateFormatting).toHaveBeenCalledWith(
|
||||
expect(mockUpdateFormatting).toHaveBeenCalledWith(
|
||||
newFormatting,
|
||||
PreferenceMode.DIRECT,
|
||||
);
|
||||
expect(mockLogsUpdateFormatting).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should increment reSync counter when updates are called', () => {
|
||||
const setReSync = jest.fn();
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceUpdater({
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
mode: PreferenceMode.DIRECT,
|
||||
preferences: mockPreferences,
|
||||
setReSync,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { isPreferenceSourcePage } from '../utils';
|
||||
|
||||
describe('isPreferenceSourcePage', () => {
|
||||
it.each([SavedviewtypesSourceDTO.logs, SavedviewtypesSourceDTO.traces])(
|
||||
'is true for %s',
|
||||
(source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
SavedviewtypesSourceDTO.meter,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
])('is false for %s', (source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Only the logs and traces explorers keep column and formatting preferences.
|
||||
export const PREFERENCE_SOURCE_PAGES = [
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
] as const;
|
||||
@@ -5,12 +5,12 @@ import React, {
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import {
|
||||
PreferenceContextValue,
|
||||
PreferenceMode,
|
||||
} from 'providers/preferences/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceSync } from '../sync/usePreferenceSync';
|
||||
|
||||
@@ -38,19 +38,19 @@ export function PreferenceContextProvider({
|
||||
const logsSlice = usePreferenceSync({
|
||||
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
|
||||
savedViewId: savedViewId || undefined,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
|
||||
const tracesSlice = usePreferenceSync({
|
||||
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
|
||||
savedViewId: savedViewId || undefined,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource.TRACES,
|
||||
});
|
||||
|
||||
const value = useMemo<PreferenceContextValue>(
|
||||
() => ({
|
||||
[SavedviewtypesSourceDTO.logs]: logsSlice,
|
||||
[SavedviewtypesSourceDTO.traces]: tracesSlice,
|
||||
logs: logsSlice,
|
||||
traces: tracesSlice,
|
||||
}),
|
||||
[logsSlice, tracesSlice],
|
||||
);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { PreferenceSlice } from '../types';
|
||||
import { isPreferenceSourcePage } from '../utils';
|
||||
import { usePreferenceContext } from './PreferenceContextProvider';
|
||||
|
||||
export function usePreferenceSlice(
|
||||
sourcePage: SavedviewtypesSourceDTO,
|
||||
): PreferenceSlice | undefined {
|
||||
const context = usePreferenceContext();
|
||||
return isPreferenceSourcePage(sourcePage) ? context[sourcePage] : undefined;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { ensureLogsRequiredColumns } from 'container/OptionsMenu/constants';
|
||||
import { has } from 'lodash-es';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import logsLoaderConfig from '../configs/logsLoaderConfig';
|
||||
import tracesLoaderConfig from '../configs/tracesLoaderConfig';
|
||||
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
|
||||
import { FormattingOptions, Preferences } from '../types';
|
||||
|
||||
const migrateColumns = (columns: any): any =>
|
||||
columns.map((column: any) => {
|
||||
@@ -62,21 +62,15 @@ function logsPreferencesLoader(): {
|
||||
function tracesPreferencesLoader(): {
|
||||
columns: TelemetryFieldKey[];
|
||||
} {
|
||||
const { columns } = preferencesLoader<Preferences>(tracesLoaderConfig);
|
||||
return { columns };
|
||||
return preferencesLoader(tracesLoaderConfig);
|
||||
}
|
||||
|
||||
const PREFERENCE_LOADERS: Record<PreferenceSourcePage, () => Preferences> = {
|
||||
[SavedviewtypesSourceDTO.logs]: logsPreferencesLoader,
|
||||
[SavedviewtypesSourceDTO.traces]: tracesPreferencesLoader,
|
||||
};
|
||||
|
||||
export function usePreferenceLoader({
|
||||
sourcePage,
|
||||
dataSource,
|
||||
reSync,
|
||||
setReSync,
|
||||
}: {
|
||||
sourcePage: PreferenceSourcePage;
|
||||
dataSource: DataSource;
|
||||
reSync: boolean;
|
||||
setReSync: (value: boolean) => void;
|
||||
}): {
|
||||
@@ -84,10 +78,17 @@ export function usePreferenceLoader({
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
} {
|
||||
const loadSlicePreferences = PREFERENCE_LOADERS[sourcePage];
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(
|
||||
loadSlicePreferences,
|
||||
);
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(() => {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
const { columns, formatting } = logsPreferencesLoader();
|
||||
return { columns, formatting };
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
const { columns } = tracesPreferencesLoader();
|
||||
return { columns };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
@@ -95,7 +96,15 @@ export function usePreferenceLoader({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setPreferences(loadSlicePreferences());
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
const { columns, formatting } = logsPreferencesLoader();
|
||||
setPreferences({ columns, formatting });
|
||||
}
|
||||
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
const { columns } = tracesPreferencesLoader();
|
||||
setPreferences({ columns });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e as Error);
|
||||
} finally {
|
||||
@@ -110,7 +119,7 @@ export function usePreferenceLoader({
|
||||
if (reSync) {
|
||||
loadPreferences();
|
||||
}
|
||||
}, [loadSlicePreferences, reSync, setReSync]);
|
||||
}, [dataSource, reSync, setReSync]);
|
||||
|
||||
return { preferences, loading: false, error };
|
||||
}
|
||||
|
||||
@@ -3,23 +3,19 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
import {
|
||||
FormattingOptions,
|
||||
PreferenceMode,
|
||||
Preferences,
|
||||
PreferenceSourcePage,
|
||||
} from '../types';
|
||||
import { FormattingOptions, PreferenceMode, Preferences } from '../types';
|
||||
import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
|
||||
|
||||
export function usePreferenceSync({
|
||||
mode,
|
||||
sourcePage,
|
||||
dataSource,
|
||||
savedViewId,
|
||||
}: {
|
||||
mode: PreferenceMode;
|
||||
sourcePage: PreferenceSourcePage;
|
||||
dataSource: DataSource;
|
||||
savedViewId: string | undefined;
|
||||
}): {
|
||||
preferences: Preferences | null;
|
||||
@@ -45,25 +41,25 @@ export function usePreferenceSync({
|
||||
if (view && view.id === savedViewId) {
|
||||
if (seededViewId.current !== savedViewId) {
|
||||
seededViewId.current = savedViewId;
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(view.spec, sourcePage));
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(view.spec, dataSource));
|
||||
}
|
||||
return;
|
||||
}
|
||||
seededViewId.current = undefined;
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(undefined, sourcePage));
|
||||
}, [viewData, sourcePage, savedViewId]);
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(undefined, dataSource));
|
||||
}, [viewData, dataSource, savedViewId]);
|
||||
|
||||
// We are using a reSync state because we have URL updates as well as local storage updates
|
||||
// and we want to make sure we are always using the latest preferences
|
||||
const [reSync, setReSync] = useState(false);
|
||||
const { preferences, loading, error } = usePreferenceLoader({
|
||||
sourcePage,
|
||||
dataSource,
|
||||
reSync,
|
||||
setReSync,
|
||||
});
|
||||
|
||||
const { updateColumns, updateFormatting } = usePreferenceUpdater({
|
||||
sourcePage,
|
||||
dataSource,
|
||||
mode,
|
||||
preferences,
|
||||
setReSync,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
import { PREFERENCE_SOURCE_PAGES } from '../constants';
|
||||
import { LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
export enum PreferenceMode {
|
||||
SAVED_VIEW = 'savedView',
|
||||
DIRECT = 'direct',
|
||||
}
|
||||
|
||||
export type PreferenceSourcePage = (typeof PREFERENCE_SOURCE_PAGES)[number];
|
||||
|
||||
export interface PreferenceSlice {
|
||||
preferences: Preferences | null;
|
||||
loading: boolean;
|
||||
@@ -18,10 +15,10 @@ export interface PreferenceSlice {
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
}
|
||||
|
||||
export type PreferenceContextValue = Record<
|
||||
PreferenceSourcePage,
|
||||
PreferenceSlice
|
||||
>;
|
||||
export interface PreferenceContextValue {
|
||||
logs: PreferenceSlice;
|
||||
traces: PreferenceSlice;
|
||||
}
|
||||
|
||||
export interface FormattingOptions {
|
||||
maxLines?: number;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultOptionsQuery,
|
||||
@@ -7,41 +6,48 @@ import {
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import getLogsUpdaterConfig from '../configs/logsUpdaterConfig';
|
||||
import getTracesUpdaterConfig from '../configs/tracesUpdaterConfig';
|
||||
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
|
||||
import { FormattingOptions, Preferences } from '../types';
|
||||
|
||||
const metricsUpdater = {
|
||||
updateColumns: (): void => {}, // no-op for metrics
|
||||
updateFormatting: (): void => {}, // no-op for metrics
|
||||
};
|
||||
|
||||
const getUpdaterConfig = (
|
||||
preferences: Preferences | null,
|
||||
redirectWithOptionsData: (options: OptionsQuery) => void,
|
||||
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
|
||||
): Record<
|
||||
PreferenceSourcePage,
|
||||
DataSource,
|
||||
{
|
||||
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions, mode: string) => void;
|
||||
}
|
||||
> => ({
|
||||
[SavedviewtypesSourceDTO.logs]: getLogsUpdaterConfig(
|
||||
[DataSource.LOGS]: getLogsUpdaterConfig(
|
||||
preferences,
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
),
|
||||
[SavedviewtypesSourceDTO.traces]: getTracesUpdaterConfig(
|
||||
[DataSource.TRACES]: getTracesUpdaterConfig(
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
),
|
||||
[DataSource.METRICS]: metricsUpdater,
|
||||
});
|
||||
|
||||
export function usePreferenceUpdater({
|
||||
sourcePage,
|
||||
dataSource,
|
||||
mode,
|
||||
preferences,
|
||||
setReSync,
|
||||
setSavedViewPreferences,
|
||||
}: {
|
||||
sourcePage: PreferenceSourcePage;
|
||||
dataSource: DataSource;
|
||||
mode: string;
|
||||
preferences: Preferences | null;
|
||||
setReSync: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -56,7 +62,7 @@ export function usePreferenceUpdater({
|
||||
preferences,
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
)[sourcePage];
|
||||
)[dataSource];
|
||||
|
||||
return {
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]): void => {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { PREFERENCE_SOURCE_PAGES } from './constants';
|
||||
import { PreferenceSourcePage } from './types';
|
||||
|
||||
export function isPreferenceSourcePage(
|
||||
sourcePage: SavedviewtypesSourceDTO,
|
||||
): sourcePage is PreferenceSourcePage {
|
||||
return (
|
||||
PREFERENCE_SOURCE_PAGES as readonly SavedviewtypesSourceDTO[]
|
||||
).includes(sourcePage);
|
||||
}
|
||||
@@ -34,6 +34,9 @@ type Alertmanager interface {
|
||||
// ListChannels lists all channels for the organization.
|
||||
ListChannels(context.Context, string) ([]*alertmanagertypes.Channel, error)
|
||||
|
||||
// GetChannelIDsByNames resolves display names to channel ids and rejects unknown names.
|
||||
GetChannelIDsByNames(context.Context, string, []string) ([]string, error)
|
||||
|
||||
// ListAllChannels lists all channels for all organizations. It is used by the legacy alertmanager only.
|
||||
ListAllChannels(context.Context) ([]*alertmanagertypes.Channel, error)
|
||||
|
||||
|
||||
@@ -1023,6 +1023,80 @@ func (_c *MockAlertmanager_GetChannelByID_Call) RunAndReturn(run func(context1 c
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetChannelIDsByNames provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) GetChannelIDsByNames(context1 context.Context, s string, strings []string) ([]string, error) {
|
||||
ret := _mock.Called(context1, s, strings)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetChannelIDsByNames")
|
||||
}
|
||||
|
||||
var r0 []string
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) ([]string, error)); ok {
|
||||
return returnFunc(context1, s, strings)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) []string); ok {
|
||||
r0 = returnFunc(context1, s, strings)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]string)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string) error); ok {
|
||||
r1 = returnFunc(context1, s, strings)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockAlertmanager_GetChannelIDsByNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetChannelIDsByNames'
|
||||
type MockAlertmanager_GetChannelIDsByNames_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetChannelIDsByNames is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - strings []string
|
||||
func (_e *MockAlertmanager_Expecter) GetChannelIDsByNames(context1 any, s any, strings any) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
return &MockAlertmanager_GetChannelIDsByNames_Call{Call: _e.mock.On("GetChannelIDsByNames", context1, s, strings)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) Run(run func(context1 context.Context, s string, strings []string)) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 []string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].([]string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) Return(strings1 []string, err error) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
_c.Call.Return(strings1, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) RunAndReturn(run func(context1 context.Context, s string, strings []string) ([]string, error)) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetConfig provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) GetConfig(context1 context.Context, s string) (*alertmanagertypes.Config, error) {
|
||||
ret := _mock.Called(context1, s)
|
||||
|
||||
@@ -147,6 +147,19 @@ func (provider *provider) ListChannels(ctx context.Context, orgID string) ([]*al
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func (provider *provider) GetChannelIDsByNames(ctx context.Context, orgID string, names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
channels, _, err := provider.configStore.ListChannels(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return alertmanagertypes.MapChannelNamesToIDs(channels, names)
|
||||
}
|
||||
|
||||
func (provider *provider) ListNotificationChannels(ctx context.Context, orgID string, params *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error) {
|
||||
channels, total, err := provider.configStore.ListChannels(ctx, orgID, params)
|
||||
if err != nil {
|
||||
@@ -528,6 +541,10 @@ func (provider *provider) CreateRoutePolicy(ctx context.Context, routeRequest *a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := provider.GetChannelIDsByNames(ctx, orgID.String(), routeRequest.Channels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
route := alertmanagertypes.RoutePolicy{
|
||||
Expression: routeRequest.Expression,
|
||||
ExpressionKind: routeRequest.ExpressionKind,
|
||||
@@ -720,6 +737,10 @@ func (provider *provider) UpdateRoutePolicyByID(ctx context.Context, routeID str
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid route: %v", err)
|
||||
}
|
||||
|
||||
if _, err := provider.GetChannelIDsByNames(ctx, orgID.String(), route.Channels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingRoute, err := provider.notificationManager.GetRoutePolicyByID(ctx, claims.OrgID, routeID)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeNotFound, "route not found: %v", err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -330,88 +331,167 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetAllRoutePolicies), handler.OpenAPIDef{
|
||||
ID: "GetAllRoutePolicies",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "List route policies",
|
||||
Description: "This endpoint lists all route policies for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetAllRoutePolicies, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetAllRoutePolicies",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "List route policies",
|
||||
Description: "This endpoint lists all route policies for the organization",
|
||||
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetRoutePolicyByID), handler.OpenAPIDef{
|
||||
ID: "GetRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Get route policy by ID",
|
||||
Description: "This endpoint returns a route policy by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetRoutePolicyByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Get route policy by ID",
|
||||
Description: "This endpoint returns a route policy by ID",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.CreateRoutePolicy), handler.OpenAPIDef{
|
||||
ID: "CreateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Create route policy",
|
||||
Description: "This endpoint creates a route policy",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.CreateRoutePolicy, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Create route policy",
|
||||
Description: "This endpoint creates a route policy",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCreateRoutePolicyChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.UpdateRoutePolicy), handler.OpenAPIDef{
|
||||
ID: "UpdateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Update route policy",
|
||||
Description: "This endpoint updates a route policy by ID",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.UpdateRoutePolicy, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Update route policy",
|
||||
Description: "This endpoint updates a route policy by ID",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewUpdateRoutePolicyChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewUpdateRoutePolicyChannelDetachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.DeleteRoutePolicyByID), handler.OpenAPIDef{
|
||||
ID: "DeleteRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Delete route policy",
|
||||
Description: "This endpoint deletes a route policy by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.DeleteRoutePolicyByID, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Delete route policy",
|
||||
Description: "This endpoint deletes a route policy by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ type provider struct {
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
spanMapperHandler spanmapper.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
alertmanagerService alertmanager.Alertmanager
|
||||
prometheusHandler prometheus.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
@@ -136,6 +137,7 @@ func NewFactory(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
alertmanagerService alertmanager.Alertmanager,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
@@ -189,6 +191,7 @@ func NewFactory(
|
||||
ruleStateHistoryHandler,
|
||||
spanMapperHandler,
|
||||
alertmanagerHandler,
|
||||
alertmanagerService,
|
||||
prometheusHandler,
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
@@ -244,6 +247,7 @@ func newProvider(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
alertmanagerService alertmanager.Alertmanager,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
@@ -299,6 +303,7 @@ func newProvider(
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
spanMapperHandler: spanMapperHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
alertmanagerService: alertmanagerService,
|
||||
prometheusHandler: prometheusHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
|
||||
@@ -221,16 +221,15 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
{
|
||||
Name: "metric_anomaly",
|
||||
Summary: "Metric anomaly rule (v1 only)",
|
||||
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
|
||||
Summary: "Metric anomaly rule",
|
||||
Description: "Wraps a builder query in the `anomaly` function with daily seasonality, so SigNoz compares each point against the forecast for that time of day. The threshold is compared against the signed anomaly score, so `below` with a target of -2 fires when an abrupt drop holds for the entire window; `requireMinPoints` guards against noisy intervals.",
|
||||
Value: map[string]any{
|
||||
"alert": "Anomalous drop in ingested spans",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"evalWindow": "24h",
|
||||
"frequency": "3h",
|
||||
"alert": "Anomalous drop in ingested spans",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
@@ -256,17 +255,29 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
},
|
||||
},
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": 2,
|
||||
"algorithm": "standard",
|
||||
"seasonality": "daily",
|
||||
"selectedQueryName": "A",
|
||||
"requireMinPoints": true,
|
||||
"requiredNumPoints": 3,
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "warning",
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": -2,
|
||||
"channels": []any{"slack-ingestion"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"preferredChannels": []any{"slack-ingestion"},
|
||||
"evaluation": rolling("24h", "3h"),
|
||||
"notificationSettings": map[string]any{
|
||||
"renotify": renotify("4h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
|
||||
"summary": "Span ingestion anomaly",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user