Compare commits

..

61 Commits

Author SHA1 Message Date
nikhilmantri0902
9fe4d03bc2 Merge branch 'feat/fga-rule-history' into feat/fga-rules
# Conflicts:
#	pkg/types/ruletypes/api_params.go
2026-10-29 18:36:43 +05:30
nikhilmantri0902
db9da15559 Merge branch 'feat/fga-rule-views' into feat/fga-rule-history 2026-10-29 18:35:35 +05:30
nikhilmantri0902
b46e536f3e Merge branch 'feat/fga-downtime' into feat/fga-rule-views 2026-10-29 18:35:34 +05:30
nikhilmantri0902
a07271dd6c Merge branch 'main' into feat/fga-downtime
# Conflicts:
#	pkg/ruler/authz.go
#	pkg/ruler/authz_test.go
#	pkg/signoz/provider.go
#	pkg/signoz/provider_test.go
#	pkg/signoz/signoz.go
#	tests/integration/tests/ruler/08_authz.py
2026-10-29 18:35:34 +05:30
nikhilmantri0902
7eff030d2c Merge branch 'feat/fga-rule-history' into feat/fga-rules 2026-10-29 10:21:07 +05:30
nikhilmantri0902
1433a034ce Merge branch 'feat/fga-rule-views' into feat/fga-rule-history 2026-10-29 10:20:55 +05:30
nikhilmantri0902
7b3a63e11c chore(sqlmigration): renumber rule tuples migration to 142 2026-10-29 10:20:43 +05:30
nikhilmantri0902
d2e3c4a76f Merge branch 'feat/fga-downtime' into feat/fga-rule-views 2026-10-29 10:20:43 +05:30
nikhilmantri0902
3bac33d373 chore(sqlmigration): renumber downtime tuples migration to 141 2026-10-29 10:20:15 +05:30
nikhilmantri0902
c2069e6b56 Merge branch 'feat/fga-route-policies' into feat/fga-downtime 2026-10-29 10:20:15 +05:30
nikhilmantri0902
2047c24632 Merge branch 'main' into feat/fga-route-policies
# Conflicts:
#	pkg/signoz/provider.go
2026-10-29 10:20:15 +05:30
nikhilmantri0902
d759493d5d Merge branch 'feat/fga-rule-history' into feat/fga-rules 2026-10-26 15:55:30 +05:30
nikhilmantri0902
c7311faae2 Merge branch 'feat/fga-rule-views' into feat/fga-rule-history 2026-10-26 15:55:15 +05:30
nikhilmantri0902
5a96a90071 chore(sqlmigration): renumber rule tuples migration to 141 2026-10-26 15:54:46 +05:30
nikhilmantri0902
816ea25eb9 Merge branch 'feat/fga-downtime' into feat/fga-rule-views 2026-10-26 15:54:45 +05:30
nikhilmantri0902
94bf33b1a5 chore(sqlmigration): renumber downtime tuples migration to 140 2026-10-26 15:54:24 +05:30
nikhilmantri0902
b03e1894da Merge branch 'feat/fga-route-policies' into feat/fga-downtime 2026-10-26 15:54:24 +05:30
nikhilmantri0902
a6b769cf89 Merge remote-tracking branch 'origin/main' into feat/fga-route-policies
# Conflicts:
#	pkg/signoz/provider.go
2026-10-26 15:53:46 +05:30
nikhilmantri0902
75dc1a44f6 Merge branch 'feat/fga-rule-history' into feat/fga-rules
# Conflicts:
#	pkg/ruler/authz.go
#	pkg/ruler/authz_test.go
2026-10-26 15:31:11 +05:30
nikhilmantri0902
37ec2582fc Merge branch 'feat/fga-rule-views' into feat/fga-rule-history
# Conflicts:
#	pkg/ruler/authz.go
2026-10-26 15:29:30 +05:30
nikhilmantri0902
3542484942 test(authz): name the licensed rule view test by what it asserts 2026-10-26 15:28:46 +05:30
nikhilmantri0902
124504546f Merge branch 'feat/fga-downtime' into feat/fga-rule-views 2026-10-26 15:28:19 +05:30
nikhilmantri0902
42baa2bd1e test(authz): name the licensed downtime test by what it asserts 2026-10-26 15:28:18 +05:30
nikhilmantri0902
2e3239074b Merge branch 'feat/fga-route-policies' into feat/fga-downtime
# Conflicts:
#	pkg/ruler/authz_test.go
2026-10-26 15:28:01 +05:30
nikhilmantri0902
367b50d31e test(authz): name the licensed route policy test by what it asserts 2026-10-26 15:26:58 +05:30
nikhilmantri0902
b9577c2d60 refactor(alertmanager): resolve route policy channel names in the module
Route policy create and update now reject unknown channel names like rules
already do. The attach extractors call the same module method.
2026-10-26 15:26:58 +05:30
nikhilmantri0902
0126cb4bdf refactor(authz): verb-first names for rule channel and query helpers 2026-10-25 12:28:52 +05:30
nikhilmantri0902
5356586a6a Merge branch 'feat/fga-rule-history' into feat/fga-rules
# Conflicts:
#	pkg/ruler/authz.go
2026-10-25 12:28:18 +05:30
nikhilmantri0902
a0d0e7a572 chore(openapi): regenerate spec for rule history filter scopes 2026-10-25 12:27:15 +05:30
nikhilmantri0902
e49482c9ed fix(authz): require rule read on history filter routes and derive field resources from query range resources 2026-10-25 12:27:15 +05:30
nikhilmantri0902
82a8dad4a3 test(authz): cover viewer rule views after license 2026-10-25 12:25:35 +05:30
nikhilmantri0902
24bcf035e5 Merge branch 'feat/fga-rule-views' into feat/fga-rule-history 2026-10-25 12:25:35 +05:30
nikhilmantri0902
4f3a87e1e5 refactor(sqlmigration): drop redundant managed role resync from rule tuple migration 2026-10-25 12:25:35 +05:30
nikhilmantri0902
bae259b730 Merge branch 'feat/fga-downtime' into feat/fga-rule-views 2026-10-25 12:24:54 +05:30
nikhilmantri0902
9ff51b89a1 test(authz): cover managed roles on downtime schedules after license 2026-10-25 12:24:43 +05:30
nikhilmantri0902
d995235bac refactor(authz): normalise downtime rule links once and use verb-first names 2026-10-25 12:24:43 +05:30
nikhilmantri0902
ca2545860d Merge branch 'feat/fga-route-policies' into feat/fga-downtime 2026-10-25 12:23:53 +05:30
nikhilmantri0902
ffc580265e test(authz): cover managed roles on route policies after license 2026-10-25 12:23:37 +05:30
nikhilmantri0902
9ddd1d000e refactor(authz): verb-first names for route policy helpers 2026-10-25 12:23:37 +05:30
nikhilmantri0902
047168e889 Merge branch 'feat/fga-rule-history' into feat/fga-rules
# Conflicts:
#	pkg/ruler/authz.go
#	tests/integration/tests/ruler/08_authz.py
2026-10-24 23:46:52 +05:30
nikhilmantri0902
b291cb6a9c Merge branch 'feat/fga-rule-views' into feat/fga-rule-history
# Conflicts:
#	pkg/ruler/authz_test.go
#	tests/integration/tests/ruler/08_authz.py
2026-10-24 23:45:47 +05:30
nikhilmantri0902
c4f2b0a2ff Merge branch 'feat/fga-downtime' into feat/fga-rule-views 2026-10-24 23:44:50 +05:30
nikhilmantri0902
4857d9a7e4 fix(authz): skip stale channels on detach and gate rule patch telemetry on the body query
Patch merge moved to ruletypes so the manager and the extractor share it.
A patch that does not carry condition.compositeQuery needs no telemetry read,
which needs an opt-in Optional flag on TelemetryResourceDef.
2026-10-24 23:30:18 +05:30
nikhilmantri0902
60befdff3a fix(authz): treat empty downtime rule set as a link to all rules 2026-10-24 22:33:12 +05:30
nikhilmantri0902
671c3219a9 chore(openapi): regenerate spec for rule scopes 2026-10-24 20:04:29 +05:30
nikhilmantri0902
40de87db50 feat(authz): rule FGA with channel links and telemetry read on rule queries 2026-10-24 20:04:29 +05:30
nikhilmantri0902
59471808c8 Merge branch 'feat/fga-rule-views' into feat/fga-rule-history 2026-10-24 19:01:48 +05:30
nikhilmantri0902
03ca64ee11 chore(openapi): regenerate spec for rule state history scopes 2026-10-24 19:01:44 +05:30
nikhilmantri0902
d266018f5c feat(authz): rule state history FGA 2026-10-24 19:01:44 +05:30
nikhilmantri0902
4f348519e6 Merge branch 'feat/fga-downtime' into feat/fga-rule-views 2026-10-24 18:53:15 +05:30
nikhilmantri0902
1d1a38309e test(authz): update managed role golden file for downtime links 2026-10-24 18:53:11 +05:30
nikhilmantri0902
34c2c20448 Merge branch 'feat/fga-route-policies' into feat/fga-downtime 2026-10-24 18:52:30 +05:30
nikhilmantri0902
2d2cd42320 test(authz): update managed role golden file for channel links 2026-10-24 18:52:21 +05:30
nikhilmantri0902
3bb1c9decd chore(openapi): regenerate spec for rule view scopes 2026-10-24 17:05:07 +05:30
nikhilmantri0902
8fc203934c feat(authz): rule view FGA
Rule view routes move from role gates to rule:list resource checks,
same as dashboard views.
2026-10-24 17:05:07 +05:30
nikhilmantri0902
253380995a chore(openapi): regenerate spec for downtime schedule scopes 2026-10-24 16:52:15 +05:30
nikhilmantri0902
82f57fd286 feat(authz): downtime schedule FGA
Downtime schedule routes move from role gates to resource checks.
Create and update check attach and detach on the rules in alertIds,
diffed against the stored schedule on update.
2026-10-24 16:52:14 +05:30
nikhilmantri0902
6f6f5abaaf fix(authz): reject unknown channel names on route policy links
A name that resolves to no channel was skipped, so the attach check
passed without covering it and the stored policy would route to a
channel created later under that name.
2026-10-24 16:33:25 +05:30
nikhilmantri0902
ac0f78eaf3 chore(authz): regenerate permissions for notification channel links 2026-10-24 16:17:29 +05:30
nikhilmantri0902
b32c4acc1f chore(openapi): regenerate spec for route policy scopes 2026-10-24 16:09:57 +05:30
nikhilmantri0902
694d8f6e53 feat(authz): route policy FGA
Route policy reads and writes move from role gates to resource checks.
Create and update also check attach and detach on the notification
channels the policy names, diffed against the stored policy on update.
2026-10-24 16:09:56 +05:30
89 changed files with 5129 additions and 3361 deletions

View File

@@ -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>/`, 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
description: Use this agent to convert a SigNoz E2E test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Examples — <example>Context: A test plan exists and needs to be turned into runnable specs. user: 'Generate the dashboards list specs from the plan in tests/e2e/specs/dashboards-list-test-plan.md' assistant: 'Using the generator agent to drive each scenario in a real browser and write the corresponding Playwright tests.'</example>
tools: Glob, Grep, Read, Bash, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
model: sonnet
color: blue
---
@@ -10,23 +10,17 @@ 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/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.
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file. Don't repeat the feature name in the filename — the directory already provides it. `dashboards/list.spec.ts`, not `dashboards/dashboards-list.spec.ts`.
- **Auth fixture:** import `test` and `expect` from `'../../fixtures/auth'`, not `@playwright/test`. Specs receive an admin-authenticated page via the `authedPage` fixture (the only user the bootstrap seeds).
```ts
import { expect, PATH, test } from '../../drivers/routing';
import { test, expect } from '../../fixtures/auth';
test('TC-09 RouteTab switches the url', async ({ routing }) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
});
```
- 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).
- **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.
- `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
@@ -37,18 +31,15 @@ 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 resources. Cleanup shapes, in order of preference:
- **A driver's `dispose`** — the driver tracks what it seeded and deletes it after the test (`dashboards.seed(...)` in `drivers/dashboards.ts`). The spec has no cleanup code.
- **A worker-scoped `seeded(...)` layer** — read-only data shared by a worker's tests.
- **Per-test `try / finally`** or **suite-level `afterAll` with an id registry** — specs without a driver. Suite hooks can't see `authedPage`; use `newAdminContext(browser)` from `helpers/auth.ts`.
- **Golden telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads logs, traces and metrics over the last 6h (constants in `GOLDEN`, `helpers/dashboard-spec.ts`). Query within ≤ 6h and assert structure (series, rows, request payload), never exact counts: per-test seeds from parallel workers land in the same tenant. Seed missing shapes through the seeder with unique service names (`helpers/alerts/seeding.ts`).
- **Reuse helpers from `tests/e2e/helpers/`** inside drivers. Don't reinvent:
- [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) — `authToken`, `watchConsole`, `collectRequests`, `requestUrl`, `seederUrl`.
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)`.
- [`helpers/query-range-mock.ts`](../../tests/e2e/helpers/query-range-mock.ts) — `query_range` mocks for error / no-data / warning / loading states only; install before `goto`.
- **Seed via API when the UI flow is multi-step or brittle**, through the driver (`dashboards.seed`, `seedAndOpen`, `seedAndEdit`). `page.request.*` does **not** auto-attach `Authorization`; drivers add `authToken(page)`.
- **Static payloads live in [tests/e2e/testdata/](../../tests/e2e/testdata/)** (`dashboards/panels.ts`, `traces/large-trace.json`).
- **Resource names:** short and descriptive, no timestamps. Where parallel workers share a list, add a random suffix as `uniqueName` in `drivers/dashboard-list.ts` does.
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
- **Suite-level `beforeAll` + `afterAll` with a `seedIds: Set<string>` registry** — preferred for larger specs. Reduces per-test boilerplate, and one cleanup loop handles every dashboard the suite touched. See [tests/e2e/tests/dashboards/list.spec.ts](../../tests/e2e/tests/dashboards/list.spec.ts) for the canonical shape.
- **Reuse helpers from `tests/e2e/helpers/`.** Don't reinvent. The current set:
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)` for `beforeAll` / `afterAll` (the `authedPage` fixture is test-scoped and not visible to suite hooks).
- [`helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) — `authToken`, `gotoDashboardsList`, `createDashboardViaApi`, `importApmMetricsDashboardViaUI`, `deleteDashboardViaApi`, `findDashboardIdByTitle`, `openDashboardActionMenu`, plus the constants used by both helpers and specs (`SEARCH_PLACEHOLDER`, `LIST_HEADING`, `APM_METRICS_TITLE`, `DEFAULT_DASHBOARD_TITLE`).
- **Seed via API when the UI flow is multi-step or brittle.** Implementation lives in `createDashboardViaApi` — use it. `page.request.*` does **not** auto-attach `Authorization`; the helpers handle that for you. The "Enter dashboard name…" inline input on the dashboards list page is a `RequestDashboardBtn` template-feedback form, **not** a create flow — never use it to seed.
- **Reusable JSON fixtures live in [tests/e2e/fixtures/](../../tests/e2e/fixtures/).** `apm-metrics.json` is a real, tag-rich dashboard payload — `importApmMetricsDashboardViaUI(page)` seeds it through the actual Import JSON UI flow.
- **Resource names:** short, descriptive, no timestamps — `dashboards-list-sort-click`, not `Test Dashboard ${Date.now()}`. Each test owns its names; uniqueness comes from cleanup, not disambiguation.
- **Serial mode** when tests in a file mutate the same list page:
```ts
test.describe.configure({ mode: 'serial' });
@@ -73,14 +64,13 @@ 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/search.spec.ts`, not `dashboards/list/dashboards-list-search.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.spec.ts`, not `dashboards/dashboards-list.spec.ts`).
- **Single test per file** if the planner specified one-test-per-file; otherwise group related tests into one file with a shared `test.describe('<Feature>', () => { … })`.
- **`describe` block** matches the top-level plan section.
- **Title** matches `TC-NN <description>` exactly.
- **Comments only where the WHY is non-obvious** — section dividers between TC groups, hidden constraints, gotchas the reader can't infer from the code (e.g. "Monaco swallows Escape — click the title to blur first"). **Do not narrate steps** by pasting the plan's bullets back as `// 1. Navigate…` `// 2. Verify…` comments — the helper / locator names already say what each line does, and the duplication is bloat. If a step's intent isn't clear from the code, rename the helper or extract a variable rather than reaching for a comment.
- **Imports** from 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.
- **Imports** from `../../fixtures/auth`. **Do not** import from `@playwright/test` directly.
- **Try / finally** cleanup using the API (delete the resources you seeded).
# Quality bar — what to write, what to skip
@@ -98,57 +88,81 @@ When you cut, merge, or renumber TCs vs the plan, note it in your final summary.
For a plan section:
```markdown
### 1. Search
### 1. Page Load
#### 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).
#### TC-01 page chrome and core controls render
**Steps:**
1. Open the dashboards list.
2. Search `name CONTAINS '<name>'`.
**Expected:** the `query` URL param carries the search; only the seeded dashboard is listed.
1. Navigate to `/dashboard`
2. Verify the page title is "SigNoz | All Dashboards"
3. Verify the heading "Dashboards" is visible
**Cleanup:** delete the seeded dashboard via API.
```
You produce:
You produce (suite-level shape, preferred for files with multiple scenarios):
```ts
// tests/e2e/tests/dashboards/list/search.spec.ts
// tests/e2e/tests/dashboards/list.spec.ts
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
expect,
listDashboard,
nameContains,
test,
uniqueName,
} from '../../../drivers/dashboard-list';
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
gotoDashboardsList,
} from '../../helpers/dashboards';
test.describe('Dashboards — list search', () => {
test('TC-01 searching by name writes the query to the URL and lists the match', async ({
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboards List Page', () => {
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
dashboards,
dashboardList,
}) => {
const name = uniqueName('search');
await dashboards.seed(listDashboard(name));
await dashboardList.open();
await seed(page, 'list-chrome');
await dashboardList.runSearch(nameContains(name));
await gotoDashboardsList(page);
expect(new URL(page.url()).searchParams.get('query')).toBe(
nameContains(name),
);
await expect(dashboardList.rows()).toHaveText([name]);
await expect(page).toHaveTitle('SigNoz | All Dashboards');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
});
```
Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locators (they live in `drivers/dashboard-list.ts`), and no `// 1. …` step narration. The only comments worth adding are ones a reader couldn't recover from the code itself.
Note how the example carries no `// 1. …` `// 2. …` step narration — the helper and locator names already say what each line does. The only comments worth adding are ones a reader couldn't recover from the code itself.
# Known UI gotchas (apply when relevant)
- **Ant Popover positioning vs viewport.** Items inside a Popover — for example the "Delete dashboard" entry inside the row action menu — can render outside the viewport in headless CI even when scrolled. `click({ force: true })` skips actionability checks but Playwright still requires the click coordinates to land inside the viewport. Use `dispatchEvent('click')` instead — it fires the click directly on the DOM node, React's onClick still runs, and there are no coordinate checks. Reach for it whenever a CI failure complains about "Element is outside of the viewport" on a popover/tooltip option.
- **Sticky-header rows below the fold.** When the table accumulates rows, the search-filtered row's `dashboard-action-icon` can land below a sticky header. Always `await actionIcon.scrollIntoViewIfNeeded()` before clicking, inside the driver action.
- **Sticky-header rows below the fold.** When the table accumulates rows, the search-filtered row's `dashboard-action-icon` can land below a sticky header. Always `await actionIcon.scrollIntoViewIfNeeded()` before clicking. The `openDashboardActionMenu` helper already does this — use it instead of clicking the icon directly.
- **React Query mutations vs navigation.** UI delete clicks fire an async DELETE through React Query. Navigating away before the mutation completes cancels it. Pair the click with `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` and `await expect(dialog).not.toBeVisible()` before the next `page.goto(...)`.
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title 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.
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title (or any non-editor element inside the dialog) first to blur Monaco; Ant's `keyboard` handler then sees the Escape and dismisses.
- **Empty zero-state hides controls.** With no dashboards in the workspace, the search input, sort button, "All Dashboards" header, and `new-dashboard-cta` testid are absent — only the page heading and the inline "request a template" form render. Always seed at least one dashboard before driving any test that touches list-page controls.
# Quality bar

View File

@@ -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/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>
description: Use this agent to debug and fix failing SigNoz E2E Playwright tests. Examples — <example>Context: A spec is red. user: 'tests/e2e/tests/dashboards/list.spec.ts is failing, fix it' assistant: 'Using the healer agent to debug each failing scenario and adjust the spec.'</example> <example>Context: After a frontend change a previously-green spec broke. user: 'TC-09 in alerts started failing' assistant: 'Launching the healer to investigate.'</example>
tools: Glob, Grep, Read, Write, Edit, Bash, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
model: sonnet
color: red
@@ -27,11 +27,11 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
4. **Root-cause.** Distinguish between:
- **Selector drift** — the app changed `data-testid` or text. Fix the locator where it lives: in the driver under `tests/e2e/drivers/` when the spec uses one, so every spec sharing it heals at once. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action — inside the driver action when there is one.
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Seed through the driver (its `dispose` cleans up) or via API with `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug — assert structure instead.
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds via API and cleans up in `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts.
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
5. **Fix.** Edit the spec or its driver. Preserve TC-NN titles, the driver / `authedPage` fixtures, cleanup, and serial mode if present. A driver change must keep every other spec importing it green — run them too. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
5. **Fix.** Edit the spec. Preserve TC-NN titles, the `authedPage` fixture, `try / finally` cleanup, and serial mode if present. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
6. **Re-run only the fixed test** before moving to the next failure. Three options:
- `npx playwright test -g 'TC-09' --project=chromium` — target a single TC by title
- `npx playwright test --last-failed --project=chromium` — replay everything that failed last run
@@ -41,13 +41,14 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
# Repo-specific signals
- **Reuse drivers and helpers before adding new code.** [`tests/e2e/drivers/`](../../tests/e2e/drivers/) holds the per-feature seed, cleanup, navigation and action methods (`kit.ts` explains `driver` / `seeded`); [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) and [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) the plain plumbing. Prefer them over re-inlining auth/login/POST code in the spec.
- **Reuse helpers before adding new code.** [`tests/e2e/helpers/dashboards.ts`](../../tests/e2e/helpers/dashboards.ts) and [`tests/e2e/helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) already export the API-seed, cleanup, navigation, and action-menu helpers most fixes need. Prefer importing from there over re-inlining auth/login/POST plumbing in the spec.
- **Ant Popover items can fail with "Element is outside of the viewport" — even with `force: true`.** `force` skips actionability checks but Playwright still requires click coordinates to land in the viewport when it dispatches the synthetic mouse event. The robust fix is `tooltip.getByText('…').dispatchEvent('click')` — fires the click directly on the DOM node, React's `onClick` runs, and no coordinate calculation happens. Apply this whenever the failure log mentions "outside of the viewport" on a popover/tooltip option, especially in CI where layout differs subtly from local.
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. Add `scrollIntoViewIfNeeded` before the click, in the driver action.
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. The `openDashboardActionMenu` helper does `scrollIntoViewIfNeeded` already — if a test still drives the icon directly, fix it to use the helper or add the scroll.
- **React Query mutations vs page.goto.** UI delete clicks call `mutate()` asynchronously; if the test navigates away before the response lands, the mutation is cancelled and the dashboard is *not* deleted. Wait for the DELETE response and the dialog dismissal explicitly: `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` plus `await expect(dialog).not.toBeVisible()`.
- **Monaco editor swallows Escape inside the Import JSON dialog.** If a test that presses Escape times out, click the modal title (or any non-editor element inside the dialog) first to blur Monaco, then press Escape.
- **The list pages render zero-state when the workspace is empty.** 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.
- **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.
- **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.

View File

@@ -20,10 +20,8 @@ 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/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.
- [tests/e2e/helpers/dashboards.ts](../../tests/e2e/helpers/dashboards.ts) and [tests/e2e/helpers/auth.ts](../../tests/e2e/helpers/auth.ts) hold reusable helpers (`createDashboardViaApi`, `gotoDashboardsList`, `openDashboardActionMenu`, `newAdminContext`, `importApmMetricsDashboardViaUI`, etc.). When the plan touches dashboards, reference these by name in the steps so the generator can reuse them rather than reinvent.
- [tests/e2e/fixtures/apm-metrics.json](../../tests/e2e/fixtures/apm-metrics.json) is a real-world dashboard payload (rich tags, panels, description) suitable as a seed fixture — note in the plan if a scenario benefits from it.
3. **Navigate and explore**
- Invoke `planner_setup_page` once before any other browser tool.
@@ -41,7 +39,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.** (only the golden telemetry), so plans must seed their own resources).
- Preconditions (what state the test expects — note that the bootstrap creates a fresh stack with **zero dashboards / alerts / etc.**, so plans must seed their own data).
- Step-by-step user actions.
- Expected outcomes per step.
- Cleanup notes (what gets created and how to remove it — usually via API).
@@ -66,7 +64,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 seeds its own through the `dashboards` driver (`dashboards.seed(...)`, deleted when the test ends).
**Bootstrap state:** the pytest harness creates a fresh stack with no pre-seeded dashboards. Every test must seed its own data. The "Enter dashboard name…" inline input is a "request a new template" feedback form — **not** a create flow. The only UI create path is the dropdown.
**Known limitations:**
- Ascending sort is not yet implemented — repeated clicks on the sort button keep `order=descend`.
@@ -89,7 +87,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:** none in the spec — the `dashboards` driver deletes what it seeded.
**Cleanup:** delete the seeded dashboard via `DELETE /api/v1/dashboards/<id>`.
#### TC-02 ...
</example-spec>
@@ -97,7 +95,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. 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.
- Each scenario must own its preconditions and cleanup. **Do not invent cross-file global fixtures** — they break parallel-by-file execution. Suite-level `beforeAll` / `afterAll` *within* a single spec file is fine and is the preferred shape for files with > ~10 scenarios; per-test `try / finally` is fine for smaller specs.
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
- **Don't pad coverage.** Every TC must catch a regression that would actually ship if the test were missing — a real branch in the source, a real user-visible failure mode. A TC that asserts a hard-coded string is still rendered, or that a button is still a button, adds nothing but maintenance cost: it inflates the suite, slows CI, and trains readers to skim past the directory. Before writing a scenario, ask "what code change would break this?" — if the only answer is "deleting the literal under test," cut it or fold it into a richer scenario as one assertion among many.
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should merge into one parameterised scenario unless each input genuinely exercises a distinct branch. Prefer one assertion-rich TC over three thin ones.

View File

@@ -15064,9 +15064,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:list
- tokenizer:
- VIEWER
- planned-maintenance:list
summary: List downtime schedules
tags:
- downtimeschedules
@@ -15121,9 +15121,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- tokenizer:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
summary: Create downtime schedule
tags:
- downtimeschedules
@@ -15168,9 +15172,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:delete
- tokenizer:
- EDITOR
- planned-maintenance:delete
summary: Delete downtime schedule
tags:
- downtimeschedules
@@ -15226,9 +15230,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:read
- tokenizer:
- VIEWER
- planned-maintenance:read
summary: Get downtime schedule by ID
tags:
- downtimeschedules
@@ -15283,9 +15287,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- tokenizer:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
summary: Update downtime schedule
tags:
- downtimeschedules
@@ -24931,9 +24943,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List rule saved views
tags:
- rules
@@ -24989,9 +25001,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Create rule saved view
tags:
- rules
@@ -25043,9 +25055,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Delete rule saved view
tags:
- rules
@@ -25112,9 +25124,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Update rule saved view
tags:
- rules
@@ -25163,9 +25175,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List alert rules
tags:
- rules
@@ -26018,9 +26030,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Create alert rule
tags:
- rules
@@ -26065,9 +26091,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:delete
- tokenizer:
- EDITOR
- rule:delete
summary: Delete alert rule
tags:
- rules
@@ -26123,9 +26149,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get alert rule by ID
tags:
- rules
@@ -26990,9 +27016,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Patch alert rule
tags:
- rules
@@ -27845,9 +27889,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Update alert rule
tags:
- rules
@@ -27914,9 +27976,25 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Clone alert rule
tags:
- rules
@@ -28076,9 +28154,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- tokenizer:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
summary: Get rule history filter keys
tags:
- rules
@@ -28254,9 +28338,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- tokenizer:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
summary: Get rule history filter values
tags:
- rules
@@ -28329,9 +28419,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule overall status timeline
tags:
- rules
@@ -28401,9 +28491,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule history stats
tags:
- rules
@@ -28493,9 +28583,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get rule history timeline
tags:
- rules
@@ -28568,9 +28668,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get top contributors to rule firing
tags:
- rules
@@ -29424,9 +29534,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Test alert rule
tags:
- rules
@@ -31521,9 +31645,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List alert rules (v3)
tags:
- rules

View File

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

View File

@@ -173,10 +173,9 @@ Each spec follows these principles:
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
4. **Self-contained state**: each spec seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Patterns, in order of preference:
- **Driver `dispose`** — the driver tracks what it seeded and deletes it after the test, so the spec has no cleanup code. See `dashboards.seed(...)` in `tests/e2e/drivers/dashboards.ts`.
- **Per-test seed + cleanup in `try / finally`** — small specs without a driver, where each test owns its data.
- **Suite-level seed + `afterAll` teardown** — larger specs without a driver. One `test.afterAll(...)` deletes everything the suite seeded. See `tests/e2e/tests/llm-o11y/llm-pricing.spec.ts`. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAdminContext(browser)` from `helpers/auth.ts` instead — it performs one fresh login per suite hook.
4. **Self-contained state**: each spec seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two patterns work:
- **Per-test seed + cleanup in `try / finally`** — small specs where each test owns its data.
- **Suite-level seed + `afterAll` teardown** — preferred for larger specs. Each `createDashboard(...)` call adds the resulting ID to a module-level `Set<string>`, and one `test.afterAll(...)` deletes everything in the set. See `tests/e2e/tests/dashboards/list.spec.ts` for the full pattern. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAdminContext(browser)` from `helpers/auth.ts` instead — it performs one fresh login per suite hook.
5. **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state. A typical pattern:
```ts
const token = await page.evaluate(

View File

@@ -1,140 +0,0 @@
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);
});
});

View File

@@ -22,7 +22,6 @@ 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';
@@ -38,11 +37,6 @@ import defaultRoutes, {
LIST_LICENSES,
SUPPORT_ROUTE,
} from './routes';
import {
hasNoAppFrames,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
const appRouter = (children: ReactNode): ReactNode => (
<Router history={history}>
@@ -316,7 +310,16 @@ function App(): JSX.Element {
tunnel: window.signozBootData.settings.sentry.tunnel,
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
ignoreErrors: 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.
ignoreErrors: [
/Unable to preload CSS for/,
/Failed to fetch dynamically imported module/,
/error loading dynamically imported module/,
/Importing a module script failed/,
],
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
@@ -351,15 +354,6 @@ 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,
});

View File

@@ -1,49 +0,0 @@
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 ?? '')),
),
);
}

View File

@@ -129,7 +129,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
onChange={handleChangeOperator}
operators={operators}
className="metrics-operators-select"
data-testid="metrics-time-aggregation"
/>
</div>
</div>
@@ -193,10 +192,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
AGGREGATE ACROSS TIME SERIES
</div>
</Tooltip>
<div
className="metrics-aggregation-section-content-item-value"
data-testid="metrics-space-aggregation"
>
<div className="metrics-aggregation-section-content-item-value">
<SpaceAggregationOptions
panelType={panelType}
key={`${panelType}${queryAggregation.spaceAggregation}${queryAggregation.timeAggregation}`}
@@ -233,10 +229,7 @@ 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"
data-testid="metrics-space-aggregation"
>
<div className="metrics-aggregation-section-content-item-value">
<SpaceAggregationOptions
panelType={panelType}
key={`${panelType}${queryAggregation.spaceAggregation}${queryAggregation.timeAggregation}`}

View File

@@ -332,7 +332,6 @@ function HavingFilter({
value={input}
onChange={handleChange}
theme={isDarkMode ? copilot : githubLight}
data-testid="having-editor"
className="having-filter-select-editor"
width="100%"
extensions={[

View File

@@ -615,7 +615,6 @@ function QueryAggregationSelect({
setInput(value);
onChange?.(value);
}}
data-testid="query-aggregation-editor"
className={`query-aggregation-select-editor ${
validationError ? 'error' : ''
}`}

View File

@@ -1607,7 +1607,6 @@ 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,
@@ -1681,7 +1680,6 @@ function QuerySearch({
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
})}
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"

View File

@@ -183,7 +183,6 @@ 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) && (
@@ -247,7 +246,6 @@ export const QueryV2 = forwardRef(function QueryV2(
{isMultiQueryAllowed && (
<Dropdown
items={queryActionItems}
testId="query-actions"
nativeButton={false}
align="end"
side="bottom"

View File

@@ -61,10 +61,7 @@ export default function TraceOperator({
);
return (
<div
className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}
data-testid="trace-operator"
>
<div className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}>
<div className="qb-trace-operator-container">
<div
className={cx(

View File

@@ -378,7 +378,6 @@ 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,

View File

@@ -147,28 +147,6 @@ 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', () => {

View File

@@ -58,12 +58,6 @@ 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', () => {

View File

@@ -30,19 +30,6 @@ 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<
@@ -103,7 +90,7 @@ describe('GCP AccountSettingsDrawer', () => {
});
it('keeps save disabled until the project IDs actually change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const user = userEvent.setup();
renderDrawer();
expect(screen.getByTestId('gcp-update-account-btn')).toHaveAttribute(
@@ -122,7 +109,7 @@ describe('GCP AccountSettingsDrawer', () => {
});
it('sends the updated project IDs while preserving the immutable deployment fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const user = userEvent.setup();
renderDrawer();
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
@@ -165,7 +152,7 @@ describe('GCP AccountSettingsDrawer', () => {
});
it('blocks the update and shows a validation error when every project ID is removed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const user = userEvent.setup();
renderDrawer();
// Strip every tag via its remove icon; the list shrinks as we go.
@@ -201,7 +188,7 @@ describe('GCP AccountSettingsDrawer', () => {
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
const user = userEvent.setup();
renderDrawer();
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);

View File

@@ -1,55 +0,0 @@
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>
);
}

View File

@@ -1,176 +0,0 @@
.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;
}
}

View File

@@ -109,6 +109,191 @@
}
}
.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;

View File

@@ -1,51 +1,36 @@
import React, {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { ChangeEvent, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Color } from '@signozhq/design-tokens';
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 {
@@ -84,7 +69,7 @@ export function PlannedDowntime(): JSX.Element {
}
}, [form, isOpen]);
const [searchValue, setSearchValue] = React.useState<string>(
const [searchValue, setSearchValue] = React.useState<string | number>(
urlQuery.get('search') || '',
);
const [deleteData, setDeleteData] = useState<{ id: string; name: string }>();
@@ -101,62 +86,16 @@ 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();
@@ -186,107 +125,78 @@ 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={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}>
<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">
<Input
containerClassName={styles.searchInput}
placeholder="Search by name"
placeholder="Search for a planned downtime..."
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
value={searchValue}
onChange={handleSearch}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="planned-downtime-search"
/>
</div>
</header>
<div ref={containerRef} className={styles.tableContainer}>
<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 />
<PlannedDowntimeList
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}
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 || ''}
/>
</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>
);
}

View File

@@ -1,65 +0,0 @@
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;

View File

@@ -1,53 +0,0 @@
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;

View File

@@ -43,7 +43,7 @@ import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
import 'dayjs/locale/en';
import { AlertRuleTags } from './AlertRuleTags';
import { AlertRuleTags } from './PlannedDowntimeList';
import {
getAlertOptionsFromIds,
getDurationInfo,

View File

@@ -1,109 +1,379 @@
import { useMemo } from 'react';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
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 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 { showErrorNotification } from 'utils/error';
import {
formatDateTime,
getAlertOptionsFromIds,
getDuration,
recurrenceInfo,
} from './PlannedDowntimeutils';
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;
}
import './PlannedDowntime.styles.scss';
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]);
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 { 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 (
<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}
/>
<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>
);
}
export default PlannedDowntimeList;
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));
}
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 };
});
useEffect(() => {
if (downtimeSchedules.isError) {
showErrorNotification(notifications, downtimeSchedules.error);
}
}, [downtimeSchedules.error, downtimeSchedules.isError, notifications]);
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}
/>
);
}

View File

@@ -1,5 +1,6 @@
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,
@@ -12,8 +13,6 @@ 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;
@@ -57,8 +56,8 @@ export const formatDateTime = (
export const getAlertOptionsFromIds = (
alertIds: string[],
alertOptions: DowntimeAlertOption[],
): DowntimeAlertOption[] =>
alertOptions: DefaultOptionType[],
): DefaultOptionType[] =>
alertOptions.filter(
(alert) =>
alert !== undefined &&
@@ -222,48 +221,3 @@ 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);

View File

@@ -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 '../AlertRuleTags';
import { AlertRuleTags } from '../PlannedDowntimeList';
const RULES = [
{ label: 'Postgres connections near limit', value: 'rule-5' },

View File

@@ -1,34 +1,21 @@
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 {
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 {
mockLocation,
mockQueryParams,
} from 'container/RoutingPolicies/__tests__/testUtils';
import { render } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import { PlannedDowntime } from '../PlannedDowntime';
import { buildSchedule, createMockDowntime } from './testUtils';
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 SEARCH_PLACEHOLDER = 'Search for a planned downtime...';
const MOCK_DOWNTIME_1_NAME = 'Mock Downtime 1';
const MOCK_DOWNTIME_2_NAME = 'Mock Downtime 2';
@@ -40,19 +27,11 @@ 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: 'Asia/Kolkata',
recurrence: {
duration: '2h',
repeatType: AlertmanagertypesRepeatTypeDTO.weekly,
repeatOn: [AlertmanagertypesRepeatOnDTO.monday],
},
timezone: 'UTC',
}),
alertIds: [],
});
@@ -147,152 +126,113 @@ jest.mock('api/generated/services/rules', () => ({
describe('PlannedDowntime Component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlQuery = new URLSearchParams();
mockUrlQuery = mockQueryParams({});
mockLocation('/alerts');
});
it('renders the header, search and create action', () => {
renderPage();
it('renders the PlannedDowntime component properly', () => {
render(<PlannedDowntime />, {}, { role: 'ADMIN' });
// Check if title is rendered
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
'aria-disabled',
'false',
);
// 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();
});
it('disables the "New downtime" button for users with VIEWER role', () => {
renderPage(USER_ROLES.VIEWER);
render(<PlannedDowntime />, {}, { role: USER_ROLES.VIEWER });
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
'aria-disabled',
'true',
);
// 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');
});
it('should load with search term from URL query params', () => {
const searchTerm = 'existing search';
mockUrlQuery = new URLSearchParams({ search: searchTerm });
mockUrlQuery = mockQueryParams({ search: searchTerm });
renderPage();
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
const searchInput = screen.getByPlaceholderText(
SEARCH_PLACEHOLDER,
) as HTMLInputElement;
expect(searchInput.value).toBe(searchTerm);
});
it('should initialize with empty search when no search param is in URL', () => {
renderPage();
mockUrlQuery = mockQueryParams({});
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
const searchInput = screen.getByPlaceholderText(
SEARCH_PLACEHOLDER,
) as HTMLInputElement;
expect(searchInput.value).toBe('');
});
it('should display all downtime schedules when no search term is entered', async () => {
renderPage();
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
await expect(
screen.findByText(MOCK_DOWNTIME_1_NAME),
).resolves.toBeInTheDocument();
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).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 () => {
renderPage();
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
await expect(
screen.findByText(MOCK_DOWNTIME_1_NAME),
).resolves.toBeInTheDocument();
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
target: { value: MOCK_DOWNTIME_1_NAME },
});
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
await expect(
screen.findByText(MOCK_DOWNTIME_1_NAME),
).resolves.toBeInTheDocument();
fireEvent.change(searchInput, { target: { value: MOCK_DOWNTIME_1_NAME } });
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).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 () => {
renderPage();
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
await expect(
screen.findByText(MOCK_DOWNTIME_1_NAME),
).resolves.toBeInTheDocument();
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
target: { value: '2' },
});
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
await expect(
screen.findByText(MOCK_DOWNTIME_2_NAME),
).resolves.toBeInTheDocument();
fireEvent.change(searchInput, { target: { value: '2' } });
expect(screen.getByText(MOCK_DOWNTIME_2_NAME)).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 () => {
renderPage();
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
target: { value: 'NonExistentDowntime' },
});
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
fireEvent.change(searchInput, { target: { value: 'NonExistentDowntime' } });
await expect(
screen.findByTestId('no-results-empty-state'),
).resolves.toBeInTheDocument();
expect(screen.queryByText(MOCK_DOWNTIME_1_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();
expect(screen.queryByText(MOCK_DOWNTIME_2_NAME)).not.toBeInTheDocument();
expect(screen.queryByText(MOCK_DOWNTIME_3_NAME)).not.toBeInTheDocument();
});
});

View File

@@ -1,236 +0,0 @@
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),
},
];
}

View File

@@ -1,33 +0,0 @@
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;
}

View File

@@ -1,108 +0,0 @@
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;

View File

@@ -112,7 +112,7 @@ export function Formula({
);
return (
<Row gutter={[0, 15]} data-testid={`formula-${formula.queryName}`}>
<Row gutter={[0, 15]}>
<QBEntityOptions
isCollapsed={isCollapse}
showFunctions={false}
@@ -130,7 +130,6 @@ export function Formula({
<Input.TextArea
name="expression"
className="formula-expression"
data-testid="formula-expression"
onChange={handleChange}
size="middle"
value={formula.expression}
@@ -142,7 +141,6 @@ export function Formula({
<Input
name="legend"
className="formula-legend"
data-testid="formula-legend"
onChange={handleChange}
size="middle"
value={formula.legend}

View File

@@ -98,7 +98,6 @@ 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>
@@ -109,7 +108,6 @@ 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>
@@ -117,11 +115,7 @@ export default function QBEntityOptions({
{entityType === 'query' && showCloneOption && (
<Tooltip title={`Clone Query ${entityData.queryName}`}>
<Button
className={cx('periscope-btn')}
onClick={handleCloneEntity}
data-testid="qb-entity-clone"
>
<Button className={cx('periscope-btn')} onClick={handleCloneEntity}>
<Copy size={14} />
</Button>
</Tooltip>
@@ -137,7 +131,6 @@ export default function QBEntityOptions({
: '',
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
)}
data-testid="qb-entity-name"
>
{entityData.queryName}
</Button>
@@ -192,11 +185,7 @@ export default function QBEntityOptions({
</div>
{showDeleteButton && !isRawQuery && (
<Button
className="periscope-btn ghost"
onClick={onDelete}
data-testid="qb-entity-delete"
>
<Button className="periscope-btn ghost" onClick={onDelete}>
<Trash2 size={14} />
</Button>
)}

View File

@@ -1,176 +0,0 @@
.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;
}
}

View File

@@ -1,30 +1,21 @@
import { ChangeEvent, useCallback, useMemo } from 'react';
import { ChangeEvent, useMemo } from 'react';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Color } from '@signozhq/design-tokens';
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,
@@ -55,136 +46,79 @@ function RoutingPolicies(): JSX.Element {
isPolicyDetailsModalActionLoading,
} = useRoutingPolicies();
const { containerRef, calculatedPageSize, pagedPolicies, setPage, setLimit } =
useRoutingPoliciesTable(routingPoliciesData);
const disableCreateButton = user?.role === USER_ROLES.VIEWER;
const canEdit = user?.role !== USER_ROLES.VIEWER;
const columns = useMemo(
() => getRoutingPolicyColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const tooltipTitle = useMemo(() => {
if (user?.role === USER_ROLES.VIEWER) {
return 'You need edit permissions to create a routing policy';
}
return '';
}, [user?.role]);
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={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}>
<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">
<Input
containerClassName={styles.searchInput}
placeholder="Search by name or description"
placeholder="Search for a routing policy..."
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
value={searchTerm}
onChange={handleSearch}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="routing-policies-search"
/>
</div>
</header>
<div ref={containerRef} className={styles.tableContainer}>
<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 />
<RoutingPolicyList
columns={columns}
routingPolicies={routingPoliciesData}
pagedPolicies={pagedPolicies}
calculatedPageSize={calculatedPageSize}
onLimitChange={setLimit}
isFetching={isFetching}
isError={isErrorRoutingPolicies}
refetch={refetchRoutingPolicies}
canEdit={canEdit}
onEdit={handleEdit}
onDelete={handleDeleteModalOpen}
onCreate={handleCreate}
hasSearchTerm={hasSearchTerm}
onClearSearch={handleClearSearch}
refetchRoutingPolicies={refetchRoutingPolicies}
isRoutingPoliciesFetching={isFetchingRoutingPolicies}
isRoutingPoliciesLoading={isLoadingRoutingPolicies}
isRoutingPoliciesError={isErrorRoutingPolicies}
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
handleDeleteModalOpen={handleDeleteModalOpen}
hasSearchTerm={(searchTerm?.length ?? 0) > 0}
/>
{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>
);
}

View File

@@ -1,52 +0,0 @@
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;

View File

@@ -1,65 +0,0 @@
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;

View File

@@ -1,106 +1,98 @@
import { useMemo } from 'react';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { Button, Table, TableProps } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { RotateCw } from '@signozhq/icons';
import RoutingPoliciesEmptyState from './RoutingPoliciesEmptyState';
import RoutingPolicyActionsMenu from './RoutingPolicyActionsMenu';
import styles from './RoutingPolicies.module.scss';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import RoutingPolicyListItem from './RoutingPolicyListItem';
import { RoutingPolicy, RoutingPolicyListProps } from './types';
import { ROUTING_POLICIES_QUERY_PARAMS } from './useRoutingPoliciesTable';
function RoutingPolicyList({
columns,
routingPolicies,
pagedPolicies,
calculatedPageSize,
onLimitChange,
isFetching,
isError,
refetch,
canEdit,
onEdit,
onDelete,
onCreate,
refetchRoutingPolicies,
isRoutingPoliciesFetching,
isRoutingPoliciesLoading,
isRoutingPoliciesError,
handlePolicyDetailsModalOpen,
handleDeleteModalOpen,
hasSearchTerm,
onClearSearch,
}: RoutingPolicyListProps): JSX.Element {
const columnsWithActions = useMemo((): TableColumnDef<RoutingPolicy>[] => {
if (!canEdit) {
return columns;
}
const columns: TableProps<RoutingPolicy>['columns'] = [
{
title: 'Routing Policy',
key: 'routingPolicy',
render: (data: RoutingPolicy): JSX.Element => (
<RoutingPolicyListItem
routingPolicy={data}
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
handleDeleteModalOpen={handleDeleteModalOpen}
/>
),
},
];
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}
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"
/>
),
},
];
}, [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} />;
}
)}
{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],
);
return (
<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}
<Table<RoutingPolicy>
columns={columns}
className="routing-policies-table"
bordered={false}
dataSource={routingPolicies}
loading={showLoading}
showHeader={false}
rowKey="id"
pagination={{
total: routingPolicies.length,
calculatedPageSize,
onLimitChange,
showTotalCount: true,
pageSize: 5,
showSizeChanger: false,
hideOnSinglePage: true,
}}
locale={{
emptyText: showLoading ? null : localeEmptyState,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={1}
/>
);
}

View File

@@ -0,0 +1,162 @@
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;

View File

@@ -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,14 +7,34 @@ import {
getAppContextMockState,
getUseRoutingPoliciesMockData,
MOCK_ROUTING_POLICY_1,
mockLocation,
mockQueryParams,
} from './testUtils';
const ROUTING_POLICY_DETAILS_TEST_ID = 'routing-policy-details';
const SEARCH_TEST_ID = 'routing-policies-search';
const CREATE_TEST_ID = 'routing-policies-create';
const SEARCH_PLACEHOLDER = 'Search for a routing policy...';
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(() => (
@@ -46,13 +66,20 @@ 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.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(CREATE_TEST_ID)).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('routing-policy-list')).toBeInTheDocument();
expect(
screen.queryByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
@@ -62,10 +89,9 @@ describe('RoutingPolicies', () => {
it('should enable the "New routing policy" button for users with ADMIN role', () => {
render(<RoutingPolicies />);
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
'aria-disabled',
'false',
);
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeEnabled();
});
it('should disable the "New routing policy" button for users with VIEWER role', () => {
@@ -73,15 +99,14 @@ describe('RoutingPolicies', () => {
.spyOn(appHooks, 'useAppContext')
.mockReturnValueOnce(getAppContextMockState({ role: 'VIEWER' }));
render(<RoutingPolicies />);
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
'aria-disabled',
'true',
);
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeDisabled();
});
it('filters routing policies by search term', () => {
render(<RoutingPolicies />);
const searchInput = screen.getByTestId(SEARCH_TEST_ID);
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
fireEvent.change(searchInput, {
target: { value: MOCK_ROUTING_POLICY_1.name },
});
@@ -91,7 +116,10 @@ describe('RoutingPolicies', () => {
it('clicking on the "New routing policy" button opens the policy details modal', () => {
render(<RoutingPolicies />);
fireEvent.click(screen.getByTestId(CREATE_TEST_ID));
const newRoutingPolicyButton = screen.getByRole('button', {
name: /New routing policy/,
});
fireEvent.click(newRoutingPolicyButton);
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith('create', null);
});
@@ -120,8 +148,9 @@ describe('RoutingPolicies', () => {
expect(screen.getByTestId('delete-routing-policy')).toBeInTheDocument();
});
it('should show the current search term', () => {
it('should load with search term from URL query params', () => {
const searchTerm = 'existing search';
mockQueryParams({ search: searchTerm });
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
searchTerm,
@@ -130,11 +159,14 @@ describe('RoutingPolicies', () => {
render(<RoutingPolicies />);
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
const searchInput = screen.getByPlaceholderText(
SEARCH_PLACEHOLDER,
) as HTMLInputElement;
expect(searchInput.value).toBe(searchTerm);
});
it('should show an empty search when there is no search term', () => {
it('should initialize with empty search when no search param is in URL', () => {
mockQueryParams({});
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
searchTerm: '',
@@ -143,7 +175,9 @@ describe('RoutingPolicies', () => {
render(<RoutingPolicies />);
const searchInput = screen.getByTestId(SEARCH_TEST_ID) as HTMLInputElement;
const searchInput = screen.getByPlaceholderText(
SEARCH_PLACEHOLDER,
) as HTMLInputElement;
expect(searchInput.value).toBe('');
});
});

View File

@@ -1,159 +1,126 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, userEvent } from 'tests/test-utils';
import { fireEvent, render, screen } from '@testing-library/react';
import RoutingPolicyList from '../RoutingPolicyList';
import { getRoutingPolicyColumns } from '../table.config';
import { RoutingPolicyListProps } from '../types';
import { MOCK_ROUTING_POLICY_1, MOCK_ROUTING_POLICY_2 } from './testUtils';
import RoutingPoliciesList from '../RoutingPolicyList';
import { RoutingPolicyListItemProps } from '../types';
import { getUseRoutingPoliciesMockData } from './testUtils';
const POLICIES = [MOCK_ROUTING_POLICY_1, MOCK_ROUTING_POLICY_2];
const useRoutingPolicesMockData = getUseRoutingPoliciesMockData();
const mockHandlePolicyDetailsModalOpen = jest.fn();
const mockHandleDeleteModalOpen = jest.fn();
const mockRefetchRoutingPolicies = jest.fn();
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;
}
jest.mock('../RoutingPolicyListItem', () => ({
__esModule: true,
default: jest.fn(({ routingPolicy }: RoutingPolicyListItemProps) => (
<div data-testid="routing-policy-list-item">{routingPolicy.name}</div>
)),
}));
describe('RoutingPolicyList', () => {
it('renders the key policy fields and hides audit columns by default', async () => {
renderList();
const ROUTING_POLICY_LIST_ITEM_TEST_ID = 'routing-policy-list-item';
await expect(
screen.findByTestId(`routing-policy-row-${MOCK_ROUTING_POLICY_1.id}-name`),
).resolves.toHaveTextContent(MOCK_ROUTING_POLICY_1.name);
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}
/>,
);
expect(
screen.getByTestId(`routing-policy-row-${MOCK_ROUTING_POLICY_2.id}-name`),
).toHaveTextContent(MOCK_ROUTING_POLICY_2.name);
expect(
screen.getByText(MOCK_ROUTING_POLICY_1.expression),
screen.getByText('Something went wrong while fetching routing policies.'),
).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();
const retryButton = screen.getByRole('button', { name: 'Retry' });
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton);
expect(mockRefetchRoutingPolicies).toHaveBeenCalled();
});
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`,
),
it('renders empty state', () => {
render(
<RoutingPoliciesList
routingPolicies={[]}
isRoutingPoliciesLoading={false}
isRoutingPoliciesError={false}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
hasSearchTerm={false}
refetchRoutingPolicies={mockRefetchRoutingPolicies}
isRoutingPoliciesFetching={false}
/>,
);
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();
expect(screen.getByText('No routing policies yet,')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,126 @@
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('-');
});
});

View File

@@ -11,7 +11,7 @@ export const MOCK_ROUTING_POLICY_1: RoutingPolicy = {
description: 'description 1',
channels: ['Channel 1'],
createdAt: '2021-01-04',
updatedAt: '2021-01-04',
updatedAt: undefined,
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 || '',

View File

@@ -1,3 +1,173 @@
.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;

View File

@@ -1,144 +0,0 @@
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),
},
];
}

View File

@@ -1,8 +1,16 @@
import type { AlertmanagertypesGettableRoutePolicyDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnDef } from 'components/TanStackTableView';
import type { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
export type RoutingPolicy = AlertmanagertypesGettableRoutePolicyDTO;
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;
}
type HandlePolicyDetailsModalOpen = (
mode: PolicyDetailsModalMode,
@@ -24,20 +32,30 @@ type HandleDeleteModalOpen = (routingPolicy: RoutingPolicy) => void;
export type PolicyDetailsModalMode = 'create' | 'edit' | null;
export interface RoutingPolicyListProps {
columns: TableColumnDef<RoutingPolicy>[];
routingPolicies: RoutingPolicy[];
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;
refetchRoutingPolicies: () => void;
isRoutingPoliciesLoading: boolean;
isRoutingPoliciesFetching: boolean;
isRoutingPoliciesError: boolean;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handleDeleteModalOpen: HandleDeleteModalOpen;
hasSearchTerm: boolean;
onClearSearch: () => void;
}
export interface RoutingPolicyListItemProps {
routingPolicy: RoutingPolicy;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handleDeleteModalOpen: HandleDeleteModalOpen;
}
export interface PolicyListItemHeaderProps {
name: string;
handleEdit: () => void;
handleDelete: () => void;
}
export interface PolicyListItemContentProps {
routingPolicy: RoutingPolicy;
}
export interface RoutingPolicyDetailsProps {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { 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 => {
void refetchChannels();
refetchChannels();
};
// Handlers
const handlePolicyDetailsModalOpen = useCallback(
(mode: PolicyDetailsModalMode, routingPolicy: RoutingPolicy | null): void => {
if (routingPolicy) {
setSelectedRoutingPolicy(routingPolicy);
}
setPolicyDetailsModalState({
isOpen: true,
mode,
});
},
[],
);
const handlePolicyDetailsModalOpen = (
mode: PolicyDetailsModalMode,
routingPolicy: RoutingPolicy | null,
): void => {
if (routingPolicy) {
setSelectedRoutingPolicy(routingPolicy);
}
setPolicyDetailsModalState({
isOpen: true,
mode,
});
};
const handlePolicyDetailsModalClose = (): void => {
setSelectedRoutingPolicy(null);
@@ -113,13 +113,10 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
});
};
const handleDeleteModalOpen = useCallback(
(routingPolicy: RoutingPolicy): void => {
setSelectedRoutingPolicy(routingPolicy);
setIsDeleteModalOpen(true);
},
[],
);
const handleDeleteModalOpen = (routingPolicy: RoutingPolicy): void => {
setSelectedRoutingPolicy(routingPolicy);
setIsDeleteModalOpen(true);
};
const handleDeleteModalClose = (): void => {
setSelectedRoutingPolicy(null);
@@ -157,7 +154,7 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
{
onSuccess: () => {
toast.success('Routing policy created successfully');
void queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handlePolicyDetailsModalClose();
},
onError: (error) => {
@@ -179,7 +176,7 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
{
onSuccess: () => {
toast.success('Routing policy updated successfully');
void queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handlePolicyDetailsModalClose();
},
onError: () => {
@@ -212,7 +209,7 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
deleteRoutingPolicy(selectedRoutingPolicy.id, {
onSuccess: () => {
toast.success('Routing policy deleted successfully');
void queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handleDeleteModalClose();
},
onError: () => {

View File

@@ -1,74 +0,0 @@
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;

View File

@@ -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
// (e.g. the alert history timeline) must not be caught.
// (Configuration) must not be caught.
flex: 1;
min-height: 0;
@@ -55,6 +55,14 @@
}
}
.configuration-tabs {
.ant-tabs-nav {
.ant-tabs-nav-wrap {
padding: 0 8px;
}
}
}
.alert-rules-container {
margin-top: 10px;
flex: 1;
@@ -62,82 +70,4 @@
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;
}
}
}
}

View File

@@ -4,7 +4,7 @@
*/
import { rest } from 'msw';
import { screen, userEvent, within } from 'storybook/test';
import { userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -111,14 +111,20 @@ export const clickFirstRowAction = async (
canvasElement: HTMLElement,
action: 'edit' | 'delete',
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: `Actions for ${FIRST_DOWNTIME_NAME}` },
const icon = (
await within(canvasElement).findByText(
FIRST_DOWNTIME_NAME,
undefined,
untilLoaded,
),
);
await userEvent.click(
await screen.findByText(action === 'edit' ? 'Edit' : 'Delete'),
);
)
)
.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);
};

View File

@@ -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=planned-downtime`.
* Route: `/alerts?tab=Configuration&subTab=PlannedDowntime`.
*/
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 are gone and New downtime is
* disabled.
* A viewer: the edit and delete actions on a row and the New downtime button
* are gone.
*/
export const Viewer: Story = {
args: { access: 'viewer' },
};
/** A downtime opened from its row: the form prefilled with its window and rules. */
export const OpenFromRow: Story = {
/** A downtime opened up: who scheduled it, the window, and what it silences. */
export const Expanded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(FIRST_DOWNTIME_NAME, undefined, untilLoaded),
);
await screen.findByText(/edit planned downtime/i);
await canvas.findByText(/alerts silenced/i);
},
};
@@ -97,9 +97,7 @@ export const EditDowntime: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickFirstRowAction(canvasElement, 'edit');
await screen.findByText(/edit planned downtime/i);
await within(await screen.findByRole('dialog')).findByText(
/^Postgres connections/,
);
await screen.findByText(/^Postgres connections/);
},
};
@@ -120,13 +118,13 @@ export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const search = await canvas.findByPlaceholderText(
'Search by name',
'Search for a planned downtime...',
undefined,
untilLoaded,
);
await userEvent.type(search, 'no matching downtime');
await canvas.findByText('No matching planned downtime');
await canvas.findByRole('table');
},
};

View File

@@ -10,7 +10,6 @@ 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';
@@ -143,8 +142,7 @@ export const fillNewPolicy = async (): Promise<void> => {
'severity = "critical"',
);
await userEvent.click(await screen.findByRole('combobox'));
const options = await screen.findAllByTitle('ops-slack');
await userEvent.click(options[options.length - 1]);
await userEvent.click(await screen.findByTitle('ops-slack'));
await userEvent.keyboard('{Escape}');
await userEvent.click(
await screen.findByRole('button', { name: 'Save Routing Policy' }),
@@ -155,11 +153,13 @@ export const openEditPolicy = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
FIRST_POLICY_NAME,
undefined,
untilLoaded,
),
(
await within(canvasElement).findAllByTestId(
'edit-routing-policy',
undefined,
untilLoaded,
)
)[0],
);
await userEvent.click(
await screen.findByRole('button', { name: 'Save Routing Policy' }),

View File

@@ -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=routing-policies`.
* Route: `/alerts?tab=Configuration&subTab=RoutingPolicies`.
*/
const meta = {
title: 'Pages/Alerts/Routing Policies',
@@ -47,20 +47,20 @@ export const NoPolicies: Story = {
args: { policies: 0 },
};
/** A viewer: the row actions are gone and New routing policy is disabled. */
/** A viewer: the row actions and the New routing policy button are gone. */
export const Viewer: Story = {
args: { access: 'viewer' },
};
/** A policy opened from its row: the form prefilled with its expression and channels. */
export const EditPolicy: Story = {
/** A policy opened up: the expression it matches on and where it sends. */
export const Expanded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(FIRST_POLICY_NAME, undefined, untilLoaded),
);
await screen.findByText(/edit routing policy/i);
await canvas.findByText(/expression/i);
},
};
@@ -83,13 +83,15 @@ export const DeletePolicyConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const [firstRowActions] = await canvas.findAllByRole(
'button',
{ name: /^Actions for / },
untilLoaded,
await userEvent.click(
(
await canvas.findAllByTestId(
'delete-routing-policy',
undefined,
untilLoaded,
)
)[0],
);
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' });
},
@@ -100,13 +102,13 @@ export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const search = await canvas.findByPlaceholderText(
'Search by name or description',
'Search for a routing policy...',
undefined,
untilLoaded,
);
await userEvent.type(search, 'no matching policy');
await canvas.findByText('No matching routing policies');
await canvas.findByText('No matching routing policies found.');
},
};
@@ -138,10 +140,6 @@ 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(
@@ -154,11 +152,13 @@ export const CreatePolicyToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await NewPolicy.play?.({ canvasElement } as never);
await fillNewPolicy();
await expectToast(
args.createState === 'success'
? /routing policy created successfully/i
: /Policy name already exists/i,
);
if (args.createState === 'success') {
await waitFor(() =>
expect(
screen.getByText(/routing policy created successfully/i),
).toBeVisible(),
);
}
},
};
@@ -173,11 +173,13 @@ export const CreatePolicyErrorToast: Story = {
export const UpdatePolicyToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await openEditPolicy(canvasElement);
await expectToast(
args.updateState === 'success'
? /routing policy updated successfully/i
: /failed to update routing policy/i,
);
if (args.updateState === 'success') {
await waitFor(() =>
expect(
screen.getByText(/routing policy updated successfully/i),
).toBeVisible(),
);
}
},
};
@@ -192,11 +194,13 @@ export const UpdatePolicyErrorToast: Story = {
export const DeletePolicyToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await confirmDelete(canvasElement);
await expectToast(
args.deleteState === 'success'
? /routing policy deleted successfully/i
: /failed to delete routing policy/i,
);
if (args.deleteState === 'success') {
await waitFor(() =>
expect(
screen.getByText(/routing policy deleted successfully/i),
).toBeVisible(),
);
}
},
};

View File

@@ -8,10 +8,8 @@ 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';
@@ -47,12 +45,6 @@ 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>;
@@ -145,6 +137,15 @@ 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);
@@ -166,109 +167,74 @@ 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 with Secondary Sidebar', () => {
it('should render Configuration tab with secondary sidebar containing channels, routing policies, and planned downtime', () => {
mockQueryParams({ tab: 'Configuration' });
mockLocation(ALERTS_PATH);
describe('Configuration Tab', () => {
describe('Rendering', () => {
it('should render Configuration tab with default Planned Downtime sub-tab', () => {
mockQueryParams({ tab: CONFIGURATION_TEXT });
mockLocation(ALERTS_PATH);
render(<AlertList />);
render(<AlertList />);
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();
});
it('should render Routing Policies when subTab is routing-policies', () => {
mockQueryParams({
tab: 'Configuration',
subTab: ROUTING_POLICIES_SUB_TAB,
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
});
mockLocation(ALERTS_PATH);
render(<AlertList />);
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);
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
});
render(<AlertList />);
it('should render Planned Downtime when subTab is planned-downtime', () => {
mockQueryParams({
tab: 'Configuration',
subTab: PLANNED_DOWNTIME_SUB_TAB,
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
});
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
});
it('should navigate to Routing Policies subtab when clicked in secondary sidebar', () => {
mockQueryParams({ tab: 'Configuration', subTab: CHANNELS_SUB_TAB });
mockLocation(ALERTS_PATH);
describe('Navigation', () => {
it('should navigate to Configuration tab with default subTab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
render(<AlertList />);
fireEvent.click(
screen.getByTestId('configuration-sidebar-routing-policies'),
);
clickTab(CONFIGURATION_TEXT);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${ROUTING_POLICIES_SUB_TAB}`,
);
});
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${PLANNED_DOWNTIME_SUB_TAB}`,
);
});
it('should navigate to Planned Downtime subtab when clicked in secondary sidebar', () => {
mockQueryParams({ tab: 'Configuration', subTab: CHANNELS_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 />);
fireEvent.click(
screen.getByTestId('configuration-sidebar-planned-downtime'),
);
clickTab(CONFIGURATION_TEXT);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${PLANNED_DOWNTIME_SUB_TAB}`,
);
});
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${ROUTING_POLICIES_SUB_TAB}`,
);
});
it('should support legacy RoutingPolicies query param as Configuration tab', () => {
mockQueryParams({ tab: 'RoutingPolicies' });
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('Routing Policies Component')).toBeInTheDocument();
});
clickTab(ALERT_RULES_TEXT);
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();
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
});
});
});
});

View File

@@ -10,8 +10,7 @@ import RoutingPolicies from 'container/RoutingPolicies';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import cx from 'classnames';
import { Cable, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import AlertDetails from 'pages/AlertDetails';
import ChannelsEdit from 'pages/ChannelsEdit';
import ChannelsNew from 'pages/ChannelsNew';
@@ -22,30 +21,6 @@ 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();
@@ -59,126 +34,39 @@ function AllAlertList(): JSX.Element {
const isChannelsEdit = location.pathname.startsWith('/alerts/channels/edit/');
const isChannelDetails = isChannelsNew || isChannelsEdit;
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 handleConfigurationTabChange = useCallback(
(subTab: 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', newSubTab);
queryParams.set('subTab', subTab);
safeNavigate(`/alerts?${queryParams.toString()}`);
},
[safeNavigate],
);
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 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 items: TabsProps['items'] = [
{
@@ -205,6 +93,22 @@ 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">
@@ -217,12 +121,37 @@ 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={getActiveTab()}
onChange={handleTopTabChange}
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()}`);
}}
className={`alerts-container ${
isAlertHistory || isAlertOverview || isChannelDetails
? 'alert-details-tabs'

View File

@@ -1,7 +1,6 @@
export enum AlertListSubTabs {
CHANNELS = 'channels',
ROUTING_POLICIES = 'routing-policies',
PLANNED_DOWNTIME = 'planned-downtime',
ROUTING_POLICIES = 'routing-policies',
}
export enum AlertListTabs {
@@ -9,6 +8,4 @@ export enum AlertListTabs {
ALERT_RULES = 'AlertRules',
CONFIGURATION = 'Configuration',
CHANNELS = 'Channels',
ROUTING_POLICIES = 'RoutingPolicies',
PLANNED_DOWNTIME = 'PlannedDowntime',
}

View File

@@ -166,10 +166,7 @@ function PanelEditorQueryBuilder({
return modes.map((queryType) => ({
key: queryType,
label: (
<div
className={styles.queryTypeTab}
data-testid={`query-type-tab-${queryType}`}
>
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
</div>

View File

@@ -95,6 +95,7 @@ type provider struct {
prometheusHandler prometheus.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
rulerService ruler.Ruler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
@@ -142,6 +143,7 @@ func NewFactory(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -196,6 +198,7 @@ func NewFactory(
llmPricingRuleHandler,
traceDetailHandler,
rulerHandler,
rulerService,
statsHandler,
savedViewHandler,
globalConfig,
@@ -252,6 +255,7 @@ func newProvider(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -307,6 +311,7 @@ func newProvider(
prometheusHandler: prometheusHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
rulerService: rulerService,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,

View File

@@ -4,273 +4,633 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/gorilla/mux"
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
Response: make([]*ruletypes.Rule, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
Response: make([]*ruletypes.Rule, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v3/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRulesV3, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetRuleByID), handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
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/v2/rules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CloneRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCloneRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.PatchRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewOptionalRuleBodyTelemetryResources(),
Optional: true,
},
),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/test", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.TestRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/test", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRuleViews, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListDowntimeSchedules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Get downtime schedule by ID",
Description: "This endpoint returns a downtime schedule by ID",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
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/downtime_schedules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetDowntimeScheduleByID), handler.OpenAPIDef{
ID: "GetDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Get downtime schedule by ID",
Description: "This endpoint returns a downtime schedule by ID",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateDowntimeSchedule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.DowntimeRuleIDsExtractor,
TargetSelector: ruler.DowntimeRuleSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.NewDowntimeRuleAttachExtractor(provider.rulerService),
TargetSelector: ruler.DowntimeRuleSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.NewDowntimeRuleDetachExtractor(provider.rulerService),
TargetSelector: ruler.DowntimeRuleSelector,
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -4,7 +4,10 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/gorilla/mux"
@@ -13,7 +16,7 @@ import (
func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/rules/{id}/history/stats", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryStats),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryStats, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryStats",
Tags: []string{"rules"},
@@ -24,13 +27,21 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
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/v2/rules/{id}/history/timeline", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryTimeline",
Tags: []string{"rules"},
@@ -41,13 +52,26 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/top_contributors", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryContributors),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryContributors, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryTopContributors",
Tags: []string{"rules"},
@@ -58,13 +82,26 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/filter_keys", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryFilterKeys",
Tags: []string{"rules"},
@@ -75,13 +112,26 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbList)...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/filter_values", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryFilterValues",
Tags: []string{"rules"},
@@ -92,13 +142,26 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbRead)...)),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}, handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/history/overall_status", handler.New(
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus),
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleHistoryOverallStatus",
Tags: []string{"rules"},
@@ -109,10 +172,26 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}
func buildFieldScopes(verb coretypes.Verb) []string {
return []string{
coretypes.ResourceMetaResourceLogsField.Scope(verb),
coretypes.ResourceMetaResourceTracesField.Scope(verb),
coretypes.ResourceMetaResourceMetricsField.Scope(verb),
}
}

View File

@@ -109,6 +109,8 @@ type TelemetryResourceDef struct {
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
// Optional skips the check instead of failing when the request carries no telemetry resources.
Optional bool
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
@@ -116,6 +118,9 @@ func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []
if err != nil {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
}
if len(refs) == 0 && def.Optional {
return nil
}
if len(refs) == 0 {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
def.Verb,

View File

@@ -69,3 +69,43 @@ func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
}
func TestTelemetryResourceDefWithoutResources(t *testing.T) {
testCases := []struct {
name string
optional bool
wantLen int
wantErr bool
}{
{
name: "Required_Errors",
optional: false,
wantLen: 1,
wantErr: true,
},
{
name: "Optional_ResolvesNothing",
optional: true,
wantLen: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
def := TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) { return nil, nil },
Optional: testCase.optional,
}
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{})
require.Len(t, resolved, testCase.wantLen)
if testCase.wantErr {
assert.Error(t, resolved[0].Err())
}
})
}
}

View File

@@ -1,5 +1,5 @@
{
"version": 5,
"version": 4,
"definition": {
"name": "gen_ai.llm",
"condition": {
@@ -134,12 +134,6 @@
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "langfuse.observation.type",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "openinference.span.kind",
"context": "attribute",
@@ -362,12 +356,6 @@
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "langfuse.observation.input",
"context": "attribute",
"operation": "move",
"priority": 40
},
{
"key": "gen_ai.prompt",
"context": "attribute",
@@ -394,12 +382,6 @@
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "langfuse.observation.output",
"context": "attribute",
"operation": "move",
"priority": 40
},
{
"key": "gen_ai.completion",
"context": "attribute",

View File

@@ -1043,29 +1043,24 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
return nil, err
}
storedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
storedRule, err := ruletypes.NewPatchedPostableRule([]byte(storedJSON.Data), []byte(ruleStr))
if err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
if err := storedRule.Validate(); err != nil {
return nil, err
}
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
if err := m.validateChannels(ctx, claims.OrgID, storedRule); err != nil {
return nil, err
}
// deploy or un-deploy task according to patched (new) rule state
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
return nil, err
}
newStoredJson, err := json.Marshal(&storedRule)
newStoredJson, err := json.Marshal(storedRule)
if err != nil {
m.logger.ErrorContext(ctx, "failed to marshal new stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
@@ -1078,7 +1073,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
if err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to restore rule after patch failure", "task_name", taskName, errors.Attr(err))
}
return nil, err
@@ -1087,7 +1082,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// prepare http response
response := ruletypes.GettableRule{
Id: id.StringValue(),
PostableRule: storedRule,
PostableRule: *storedRule,
}
// fetch state of rule from memory

View File

@@ -2,12 +2,17 @@ package ruler
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
const (
@@ -17,6 +22,195 @@ const (
type diffSide int
type incomingChannelsFunc func(stored *ruletypes.GettableRule, body []byte) ([]string, error)
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
if err != nil {
return nil, err
}
return defaultToAllRules(ids), nil
}}
var DowntimeRuleSelector coretypes.SelectorFunc = func(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
if id == coretypes.WildCardSelectorString {
return coretypes.WildcardSelector(ctx, resource, id, orgID)
}
return coretypes.IDSelector(ctx, resource, id, orgID)
}
func NewDowntimeRuleAttachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
return newDowntimeDiffExtractor(rulerService, attachedSide)
}
func NewDowntimeRuleDetachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
return newDowntimeDiffExtractor(rulerService, detachedSide)
}
func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
scheduleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
stored, err := rulerService.MaintenanceStore().GetPlannedMaintenanceByID(ec.Request.Context(), scheduleID)
if err != nil {
return nil, err
}
incoming, err := DowntimeRuleIDsExtractor.Fn(ec)
if err != nil {
return nil, err
}
attached, detached := diffDowntimeRuleLinks(stored.RuleIDs, incoming)
if side == detachedSide {
return detached, nil
}
return attached, nil
}}
}
func NewCreateRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
names, err := parseRuleChannels(ec.RequestBody)
if err != nil {
return nil, err
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), names)
}}
}
func NewCloneRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), stored.Channels())
}}
}
func NewReplaceRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, attachedSide)
}
func NewReplaceRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, detachedSide)
}
func NewPatchRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, attachedSide)
}
func NewPatchRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, detachedSide)
}
func newRuleChannelDiffExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler, incoming incomingChannelsFunc, side diffSide) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}
incomingNames, err := incoming(stored, ec.RequestBody)
if err != nil {
return nil, err
}
attached, detached := diffLinks(stored.Channels(), incomingNames)
if side == detachedSide {
return resolveStoredChannelIDs(ctx, alertmanagerService, orgID, detached)
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), attached)
}}
}
func parseReplacedRuleChannels(_ *ruletypes.GettableRule, body []byte) ([]string, error) {
return parseRuleChannels(body)
}
func parsePatchedRuleChannels(stored *ruletypes.GettableRule, body []byte) ([]string, error) {
patched, err := applyRulePatch(stored, body)
if err != nil {
return nil, err
}
return patched.Channels(), nil
}
func applyRulePatch(stored *ruletypes.GettableRule, body []byte) (*ruletypes.PostableRule, error) {
encoded, err := json.Marshal(stored.PostableRule)
if err != nil {
return nil, err
}
return ruletypes.NewPatchedPostableRule(encoded, body)
}
func parseRuleChannels(body []byte) ([]string, error) {
rule := new(ruletypes.PostableRule)
if err := json.Unmarshal(body, rule); err != nil {
return nil, err
}
return rule.Channels(), nil
}
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
return diffLinks(defaultToAllRules(stored), incoming)
}
func defaultToAllRules(ids []string) []string {
if len(ids) == 0 {
return []string{coretypes.WildCardSelectorString}
}
return ids
}
func NewCreateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
@@ -77,6 +271,40 @@ func newRoutePolicyDiffExtractor(alertmanagerService alertmanager.Alertmanager,
}}
}
func resolveStoredChannelIDs(ctx context.Context, alertmanagerService alertmanager.Alertmanager, orgID valuer.UUID, names []string) ([]string, error) {
if len(names) == 0 {
return nil, nil
}
idByName, err := mapChannelIDsByName(ctx, alertmanagerService, orgID)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(names))
for _, name := range names {
if id, ok := idByName[name]; ok {
ids = append(ids, id)
}
}
return ids, nil
}
func mapChannelIDsByName(ctx context.Context, alertmanagerService alertmanager.Alertmanager, orgID valuer.UUID) (map[string]string, error) {
channels, err := alertmanagerService.ListChannels(ctx, orgID.StringValue())
if err != nil {
return nil, err
}
idByName := make(map[string]string, len(channels))
for _, channel := range channels {
idByName[channel.DisplayName] = channel.ID.StringValue()
}
return idByName, nil
}
func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -86,6 +314,114 @@ func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
return valuer.NewUUID(claims.OrgID)
}
func NewRuleQueryTelemetryResources(rulerService Ruler) coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
body, err := fetchStoredCompositeQuery(ec, rulerService)
if err != nil {
return nil, err
}
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
}
}
func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
body, err := fetchStoredCompositeQuery(ec, rulerService)
if err != nil {
return nil, err
}
return mapFieldResources(body)
}
}
func NewRuleBodyTelemetryResources() coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
compositeQuery := gjson.GetBytes(ec.RequestBody, "condition.compositeQuery")
if !compositeQuery.Exists() {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery is required")
}
body, err := json.Marshal(map[string]json.RawMessage{"compositeQuery": json.RawMessage(compositeQuery.Raw)})
if err != nil {
return nil, err
}
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
}
}
func NewOptionalRuleBodyTelemetryResources() coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
if !gjson.GetBytes(ec.RequestBody, "condition.compositeQuery").Exists() {
return nil, nil
}
return NewRuleBodyTelemetryResources()(ec)
}
}
func fetchStoredRule(ec coretypes.ExtractorContext, rulerService Ruler) (*ruletypes.GettableRule, error) {
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
return rulerService.GetRule(ec.Request.Context(), ruleID)
}
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
if ec.Request == nil {
return nil, nil
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"compositeQuery": stored.RuleCondition.CompositeQuery})
}
func mapFieldResources(body []byte) ([]coretypes.ResourceWithID, error) {
telemetry, err := querybuilder.QueryRangeResources(coretypes.ExtractorContext{RequestBody: body})
if err != nil {
return nil, err
}
seen := make(map[string]struct{}, 3)
refs := make([]coretypes.ResourceWithID, 0, 3)
for _, ref := range telemetry {
field, err := fieldResourceForKind(ref.Resource.Kind())
if err != nil {
return nil, err
}
kind := field.Kind().String()
if _, ok := seen[kind]; ok {
continue
}
seen[kind] = struct{}{}
refs = append(refs, coretypes.ResourceWithID{Resource: field})
}
return refs, nil
}
func fieldResourceForKind(kind coretypes.Kind) (coretypes.Resource, error) {
switch kind {
case coretypes.KindLogs, coretypes.KindAuditLogs:
return coretypes.ResourceMetaResourceLogsField, nil
case coretypes.KindTraces:
return coretypes.ResourceMetaResourceTracesField, nil
case coretypes.KindMetrics, coretypes.KindMeterMetrics:
return coretypes.ResourceMetaResourceMetricsField, nil
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "no field resource for telemetry kind %q", kind.String())
}
}
func diffLinks(stored, incoming []string) (attached, detached []string) {
storedSet := make(map[string]struct{}, len(stored))
for _, id := range stored {

View File

@@ -1,9 +1,19 @@
package ruler
import (
"context"
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestDiffLinks(t *testing.T) {
@@ -75,3 +85,504 @@ func TestDiffLinks(t *testing.T) {
})
}
}
func TestDiffDowntimeRuleLinks(t *testing.T) {
testCases := []struct {
name string
stored []string
incoming []string
wantAttached []string
wantDetached []string
}{
{
name: "AllToAll_NoChange",
stored: nil,
incoming: []string{"*"},
wantAttached: []string{},
wantDetached: []string{},
},
{
name: "NarrowFromAllToOne_DetachesAllAttachesOne",
stored: nil,
incoming: []string{"r1"},
wantAttached: []string{"r1"},
wantDetached: []string{"*"},
},
{
name: "WidenFromOneToAll_DetachesOneAttachesAll",
stored: []string{"r1"},
incoming: []string{"*"},
wantAttached: []string{"*"},
wantDetached: []string{"r1"},
},
{
name: "SwapRules_DiffsIDs",
stored: []string{"r1"},
incoming: []string{"r2"},
wantAttached: []string{"r2"},
wantDetached: []string{"r1"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attached, detached := diffDowntimeRuleLinks(testCase.stored, testCase.incoming)
assert.ElementsMatch(t, testCase.wantAttached, attached)
assert.ElementsMatch(t, testCase.wantDetached, detached)
})
}
}
func TestDowntimeRuleIDsExtractor(t *testing.T) {
testCases := []struct {
name string
body string
wantIDs []string
}{
{
name: "MissingAlertIds_AllRules",
body: `{"name": "window"}`,
wantIDs: []string{"*"},
},
{
name: "EmptyAlertIds_AllRules",
body: `{"alertIds": []}`,
wantIDs: []string{"*"},
},
{
name: "ExplicitAlertIds_Kept",
body: `{"alertIds": ["r1", "r2"]}`,
wantIDs: []string{"r1", "r2"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ids, err := DowntimeRuleIDsExtractor.Fn(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}
func TestMapFieldResources(t *testing.T) {
testCases := []struct {
name string
body string
wantKinds []coretypes.Kind
wantErr bool
}{
{
name: "Builder_Logs_LogsField",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
},
{
name: "Builder_Traces_TracesField",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"traces"}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
},
{
name: "Builder_Metrics_MetricsField",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
},
{
name: "SubQuery_Metrics_MetricsField",
body: `{"compositeQuery":{"queries":[{"type":"builder_sub_query","spec":{"signal":"metrics"}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
},
{
name: "BuilderAI_TracesField",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
},
{
name: "PromQL_MetricsField",
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
},
{
name: "ClickHouseSQL_AllThreeFields",
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindLogsField, coretypes.KindTracesField, coretypes.KindMetricsField},
},
{
name: "Builder_AuditLogs_LogsField",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit"}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
},
{
name: "Builder_MeterMetrics_MetricsField",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics","source":"meter"}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
},
{
name: "TwoBuilderSameSignal_Deduped",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}},{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
},
{
name: "FormulaAlongsideBuilder_FormulaSkipped",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}},{"type":"builder_formula","spec":{}}]}}`,
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
},
{
name: "NoQueries_Errors",
body: `{"compositeQuery":{"queries":[]}}`,
wantErr: true,
},
{
name: "UnknownSignal_Errors",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"events"}}]}}`,
wantErr: true,
},
{
name: "UnknownQueryType_Errors",
body: `{"compositeQuery":{"queries":[{"type":"graphql","spec":{}}]}}`,
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := mapFieldResources([]byte(testCase.body))
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
kinds := make([]coretypes.Kind, 0, len(refs))
for _, ref := range refs {
kinds = append(kinds, ref.Resource.Kind())
}
assert.Equal(t, testCase.wantKinds, kinds)
})
}
}
func v1RuleJSON(preferredChannels string) string {
return `{
"alert": "TestAlert",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1"
}` + preferredChannels + `
}`
}
func v2RuleJSON(channels string) string {
return `{
"alert": "TestAlert",
"version": "v5",
"schemaVersion": "v2alpha1",
"ruleType": "threshold_rule",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90.0, "matchType": "1", "op": "1", "channels": ` + channels + `}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {"evalWindow": "5m", "frequency": "1m"}
},
"notificationSettings": {
"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}
}
}`
}
func TestParseRuleChannels(t *testing.T) {
testCases := []struct {
name string
body string
wantChannels []string
wantErr bool
}{
{
name: "V1PreferredChannels_FoldedIntoThresholds",
body: v1RuleJSON(`, "preferredChannels": ["slack", "pager"]`),
wantChannels: []string{"slack", "pager"},
},
{
name: "V1NoChannels_Empty",
body: v1RuleJSON(""),
wantChannels: nil,
},
{
name: "V2ThresholdChannels_ReadDirectly",
body: v2RuleJSON(`["email"]`),
wantChannels: []string{"email"},
},
{
name: "V2EmptyChannels_Empty",
body: v2RuleJSON(`[]`),
wantChannels: nil,
},
{
name: "NotJSON_Errors",
body: "not json",
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
channels, err := parseRuleChannels([]byte(testCase.body))
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantChannels, channels)
})
}
}
func TestIncomingRuleChannels(t *testing.T) {
testCases := []struct {
name string
incoming incomingChannelsFunc
stored string
body string
wantChannels []string
wantStoredAfter []string
}{
{
name: "Replaced_IgnoresStored",
incoming: parseReplacedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`["pager"]`),
wantChannels: []string{"pager"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_OmitsCondition_StoredChannelsSurvive",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: `{"disabled": true}`,
wantChannels: []string{"slack"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_SetsChannels_StoredChannelsReplaced",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`["pager"]`),
wantChannels: []string{"pager"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_EmptiesChannels_NoChannelsLeft",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`[]`),
wantChannels: nil,
wantStoredAfter: []string{"slack"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stored := new(ruletypes.GettableRule)
require.NoError(t, json.Unmarshal([]byte(testCase.stored), &stored.PostableRule))
channels, err := testCase.incoming(stored, []byte(testCase.body))
require.NoError(t, err)
assert.Equal(t, testCase.wantChannels, channels)
assert.Equal(t, testCase.wantStoredAfter, stored.Channels())
})
}
}
func TestRuleBodyTelemetryResources(t *testing.T) {
testCases := []struct {
name string
body string
wantKind coretypes.Kind
wantID string
wantErr bool
}{
{
name: "V5Body_NoFilter_MetricsWildcard",
body: v2RuleJSON(`[]`),
wantKind: coretypes.KindMetrics,
wantID: "builder_query/*",
},
{
name: "V1Body_PreferredChannels_MetricsWildcard",
body: v1RuleJSON(`, "preferredChannels": ["slack"]`),
wantKind: coretypes.KindMetrics,
wantID: "builder_query/*",
},
{
name: "MissingCondition_Errors",
body: `{"alert": "TestAlert", "version": "v5"}`,
wantErr: true,
},
{
name: "NotJSON_Errors",
body: "not json",
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := NewRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.Len(t, refs, 1)
assert.Equal(t, testCase.wantKind, refs[0].Resource.Kind())
assert.Equal(t, testCase.wantID, refs[0].ID)
})
}
}
func TestApplyRulePatch(t *testing.T) {
logsQuery := `{"condition": {"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "stepInterval": "5m"}}]}}}`
testCases := []struct {
name string
body string
wantSignal string
wantChannels []string
}{
{
name: "BodyWithoutCondition_KeepsStoredQueryAndChannels",
body: `{"disabled": true}`,
wantSignal: "metrics",
wantChannels: []string{"slack"},
},
{
name: "BodyWithNewQuery_ReplacesSignalKeepsThresholds",
body: logsQuery,
wantSignal: "logs",
wantChannels: []string{"slack"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stored := new(ruletypes.GettableRule)
require.NoError(t, json.Unmarshal([]byte(v2RuleJSON(`["slack"]`)), &stored.PostableRule))
patched, err := applyRulePatch(stored, []byte(testCase.body))
require.NoError(t, err)
require.NotNil(t, patched.RuleCondition)
require.NotNil(t, patched.RuleCondition.CompositeQuery)
encoded, err := json.Marshal(patched.RuleCondition.CompositeQuery)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"signal":"`+testCase.wantSignal+`"`)
assert.Equal(t, testCase.wantChannels, patched.Channels())
})
}
}
func TestOptionalRuleBodyTelemetryResources(t *testing.T) {
testCases := []struct {
name string
body string
wantRefs int
}{
{
name: "BodyWithoutCondition_NoResources",
body: `{"disabled": true}`,
wantRefs: 0,
},
{
name: "BodyWithQuery_MetricsResource",
body: v2RuleJSON(`[]`),
wantRefs: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := NewOptionalRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Len(t, refs, testCase.wantRefs)
})
}
}
func TestResolveStoredChannelIDs(t *testing.T) {
channels := []*alertmanagertypes.Channel{
{Identifiable: types.Identifiable{ID: valuer.GenerateUUID()}, Name: "slack", DisplayName: "slack"},
}
testCases := []struct {
name string
names []string
wantIDs []string
}{
{
name: "KnownName_Resolved",
names: []string{"slack"},
wantIDs: []string{channels[0].ID.StringValue()},
},
{
name: "UnknownName_Skipped",
names: []string{"slack", "gone"},
wantIDs: []string{channels[0].ID.StringValue()},
},
{
name: "NoNames_NoLookup",
names: nil,
wantIDs: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
alertmanagerService := alertmanagertest.NewMockAlertmanager(t)
if len(testCase.names) > 0 {
alertmanagerService.EXPECT().ListChannels(mock.Anything, mock.Anything).Return(channels, nil)
}
ids, err := resolveStoredChannelIDs(context.Background(), alertmanagerService, valuer.GenerateUUID(), testCase.names)
require.NoError(t, err)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}

View File

@@ -105,6 +105,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ llmpricingrule.Handler }{},
struct{ tracedetail.Handler }{},
struct{ ruler.Handler }{},
struct{ ruler.Ruler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
global.Config{},

View File

@@ -48,6 +48,7 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/sharder/singlesharder"
@@ -267,6 +268,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddGCPConnectionMethodFactory(),
sqlmigration.NewDeleteOrphanSARolesFactory(),
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
sqlmigration.NewAddRuleTuplesFactory(sqlstore),
)
}
@@ -331,7 +334,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
)
}
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager, rulerService ruler.Ruler) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
@@ -373,6 +376,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.LLMPricingRuleHandler,
handlers.TraceDetail,
handlers.RulerHandler,
rulerService,
handlers.StatsHandler,
handlers.SavedView,
globalConfig,

View File

@@ -107,6 +107,7 @@ func TestNewProviderFactories(t *testing.T) {
nil,
nil,
nil,
nil,
)
})
}

View File

@@ -622,7 +622,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager, rulerInstance),
"signoz",
)
if err != nil {

View File

@@ -0,0 +1,174 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addDowntimeTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddDowntimeTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_downtime_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDowntimeTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addDowntimeTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDowntimeTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
crudVerbs := []string{"create", "read", "update", "delete", "list"}
readVerbs := []string{"read", "list"}
linkVerbs := []string{"attach", "detach"}
tuples := make([]migrationTuple, 0, 20)
for _, verb := range crudVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "planned-maintenance", verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "planned-maintenance", verb},
)
}
for _, verb := range readVerbs {
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "planned-maintenance", verb})
}
for _, objectName := range []string{"planned-maintenance", "rule"} {
for _, verb := range linkVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", objectName, verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", objectName, verb},
)
}
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addDowntimeTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,140 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addRuleTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddRuleTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_rule_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRuleTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addRuleTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRuleTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
crudVerbs := []string{"create", "read", "update", "delete", "list"}
readVerbs := []string{"read", "list"}
tuples := make([]migrationTuple, 0, 12)
for _, verb := range crudVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "rule", verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "rule", verb},
)
}
for _, verb := range readVerbs {
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "rule", verb})
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addRuleTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -121,13 +121,10 @@ func (f *formatting) messages() {
// OpenRouter's answer object or an Anthropic response beside the OpenAI chat request it answers is one
// format, not a mix: an Anthropic request without system and without Anthropic-only blocks reads as an
// OpenAI chat request. A side kept as generic already warned, and a side read from OpenInference's
// buckets beside the raw payload of the other is the documented fallback.
// OpenAI chat request.
func (f *formatting) warnMixedFormats(inputLabel, outputLabel string) {
switch {
case inputLabel == "" || outputLabel == "" || outputLabel == inputLabel:
case inputLabel == FormatterGeneric || outputLabel == FormatterGeneric:
case inputLabel == FormatterOpenInference || outputLabel == FormatterOpenInference:
case inputLabel == FormatterOpenAIChat && (outputLabel == FormatterOpenRouter || outputLabel == FormatterAnthropic):
default:
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
@@ -186,16 +183,11 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
return f.semconv(v), FormatterSemconv
case isAnthropicMessages(v):
return f.anthropicMessages(v), FormatterAnthropic
case isResponsesItems(v):
return foldAssistant(f.responsesItems(v)), FormatterOpenAIResponses
case first["role"] != nil:
return f.openAIChatMessages(v), FormatterOpenAIChat
}
}
case map[string]any:
if inner, ok := superJSON(v); ok {
return f.side(decode(inner), role)
}
switch {
case isLangChainRun(v):
return f.langChainRun(v), FormatterLangChain
@@ -222,9 +214,6 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
if messages, ok := v["messages"].([]any); ok {
return f.openAIChatMessages(messages), FormatterOpenAIChat
}
if (v["role"] != nil && hasKeys(v, "content")) || v["tool_calls"] != nil {
return OutputMessages{f.openAIChatSingle(v)}, FormatterOpenAIChat
}
case string:
if v == "" {
return OutputMessages{}, FormatterText
@@ -320,23 +309,6 @@ func foldAssistant(msgs OutputMessages) OutputMessages {
return out
}
// superJSON unwraps {json, meta?}, the envelope superjson, the transformer tRPC apps serialise with,
// puts around a value; a Langfuse generation records it around the request when the app passes the
// serialised form:
// https://github.com/flightcontrolhq/superjson/blob/aaa65e36e8e31da740265a56b27c965ca1c7b754/README.md
func superJSON(m map[string]any) (any, bool) {
inner, ok := m["json"]
if !ok {
return nil, false
}
for key := range m {
if key != "json" && key != "meta" {
return nil, false
}
}
return inner, true
}
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
// Anything that is not JSON is returned as is.
func decode(value any) any {

View File

@@ -4,9 +4,8 @@ import (
"strings"
)
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, a bare message list, or a
// single message.
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records, the Langfuse SDK.
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, or a bare message list.
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records.
// Handled: string and block content, tool_calls nested or flattened, tool messages, refusal,
// reasoning_content, finish reasons.
// Not yet: streaming delta chunks, legacy completions choices[].text, audio output.
@@ -89,19 +88,6 @@ func (f *formatting) openAIChatMessage(m map[string]any) OutputMessage {
return msg
}
// openAIChatSingle converts a message recorded outside a list: the first choice's message the Langfuse
// OpenAI wrapper writes as the output, or the additional_kwargs of a LangChain AIMessage, {tool_calls}
// without a role, which the Langfuse LangChain handler writes for a tool-calling turn:
// https://github.com/langfuse/langfuse-python/blob/18baf80450dd5f9c1f85916eb2d1b3c72d2052e1/langfuse/openai.py#L446
// https://github.com/langfuse/langfuse-python/blob/18baf80450dd5f9c1f85916eb2d1b3c72d2052e1/langfuse/langchain/CallbackHandler.py#L1558
func (f *formatting) openAIChatSingle(m map[string]any) OutputMessage {
msg := f.openAIChatMessage(m)
if msg.Role.IsZero() {
msg.Role = RoleAssistant
}
return msg
}
// toolCallPart reads {id, function: {name, arguments}}; a gateway may flatten it to {id, name, arguments|args}.
func toolCallPart(c map[string]any) Part {
call := ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(c["id"]), Name: stringOf(c["name"])}

View File

@@ -205,30 +205,6 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
wantOutput: `[]`,
},
{
// Langfuse SDK generation: the history as a list, the first choice's message as the output
name: "SingleMessageOutput_AssistantMessage",
input: `[{"role": "system", "content": "You are a support agent on a phone call."}, {"role": "user", "content": "Hello?"}]`,
output: `{"role": "assistant", "content": "Hi, this is a courtesy call about your order. Do you have a minute?"}`,
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "You are a support agent on a phone call."}]}, {"role": "user", "parts": [{"type": "text", "content": "Hello?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hi, this is a courtesy call about your order. Do you have a minute?"}]}]`,
},
{
// a structured output that happens to carry a role key is not a message
name: "ObjectWithRoleKeyButNoContent_KeptAsGeneric",
output: `{"role": "admin", "permissions": ["read", "write"]}`,
warnings: []string{"message format not recognised, kept as generic"},
wantInput: `[]`,
wantOutput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"permissions\":[\"read\",\"write\"],\"role\":\"admin\"}"}]}]`,
},
{
// Langfuse LangChain handler: the additional_kwargs of the AIMessage, without a role, as the output
// of a tool-calling turn
name: "RolelessToolCallsOutput_AssistantToolCallPart",
output: `{"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Oslo\"}"}}], "refusal": null}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Oslo"}}]}]`,
},
{
name: "ResponseWithScalarChoice_KeptAsGeneric",
output: `{"choices":["oops",{"finish_reason":"stop","index":1,"message":{"role":"assistant","content":"hi"}}]}`,

View File

@@ -2,15 +2,13 @@ package aiobservabilitytypes
import "strings"
// Format: OpenAI Responses, a request {input, instructions}, a response {output, status} or a bare list
// of items:
// Format: OpenAI Responses, a request {input, instructions} or a response {output, status} of items:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L47597
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53834
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57596
// Written by: gateways recording raw Responses bodies, the raw calls OpenInference records for the
// Responses API and the OpenAI Agents SDK when its flattened messages are absent, and LiteLLM's
// langfuse_otel callback, the function_call items of a tool-calling turn as a bare list.
// Written by: gateways recording raw Responses bodies, and the raw calls OpenInference records for the
// Responses API and the OpenAI Agents SDK when its flattened messages are absent.
// Handled: message items with input_text, output_text, refusal, input_image and input_file content;
// function_call and custom_tool_call with their outputs; reasoning summaries; compaction; every other
// *_call item as a server tool call and *_output item as its response; the status and incomplete_details
@@ -34,19 +32,6 @@ func isResponsesRequest(m map[string]any) bool {
return isItem
}
// isResponsesItems looks for an item only the Responses API has, a tool call or its output, a reasoning
// or compaction item, all without a role; a list of messages has none.
func isResponsesItems(list []any) bool {
for _, item := range list {
m, _ := item.(map[string]any)
typ := stringOf(m["type"])
if m["role"] == nil && (typ == "reasoning" || typ == "compaction" || typ == "mcp_approval_response" || strings.HasSuffix(typ, "_call") || strings.HasSuffix(typ, "_output")) {
return true
}
}
return false
}
func isResponsesResponse(m map[string]any) bool {
if _, ok := m["output"].([]any); !ok {
return false

View File

@@ -73,23 +73,6 @@ func TestFormat_OpenAIResponses(t *testing.T) {
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Be brief."}]}, {"role": "user", "parts": [{"type": "text", "content": "Say hi"}]}]`,
wantOutput: `[]`,
},
{
// LiteLLM langfuse_otel: the function_call items of a tool-calling turn as a bare list
name: "BareFunctionCallItems_OneAssistantMessage",
output: `[{"id": "chatcmpl-1", "name": "set_stage", "call_id": "call_7", "type": "function_call", "arguments": {"fields": {"stage": "consent"}}}]`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "set_stage", "id": "call_7", "arguments": {"fields": {"stage": "consent"}}}]}]`,
},
{
name: "BareItemsWithMessage_ToolRound",
input: `[{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Weather in Oslo?"}]}, {"type": "function_call", "call_id": "call_9", "name": "get_weather", "arguments": "{\"city\":\"Oslo\"}"}, {"type": "function_call_output", "call_id": "call_9", "output": "4C, cloudy"}]`,
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_9", "arguments": {"city": "Oslo"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_9", "response": "4C, cloudy"}]}
]`,
wantOutput: `[]`,
},
{
// openinference capture, CreateEmbeddings span: an embeddings request also has input and model
name: "EmbeddingsRequest_NotResponses_KeptAsGeneric",

View File

@@ -4,9 +4,8 @@ import "github.com/SigNoz/signoz/pkg/valuer"
// Format: the flattened llm.input_messages.N.message.* and llm.output_messages.N.message.* keys, the
// provider-neutral copy of the conversation the OpenInference instrumentations write on LLM spans. A side
// without them, or whose buckets hold no message, falls back to the raw provider payload the mapper moved
// onto gen_ai.*.messages; pipecat streams the reply into output.value alone, LiteLLM's OpenInference mode
// leaves the output bucket empty on a tool-calling turn.
// without them falls back to the raw provider payload the mapper moved onto gen_ai.*.messages; pipecat
// streams the reply into output.value alone.
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L77
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L35
@@ -29,14 +28,15 @@ func (f *formatting) openInference() bool {
return false
}
inputLabel, outputLabel := FormatterOpenInference, FormatterOpenInference
if msgs := openInferenceMessages(input); len(msgs) > 0 {
f.out.Input = inputMessages(msgs)
if len(input) > 0 {
f.out.Input = inputMessages(openInferenceMessages(input))
} else if raw := attribute(f.attributes, GenAIInputMessages); raw != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(raw), RoleUser)
f.out.Input = inputMessages(msgs)
}
if msgs := openInferenceMessages(output); len(msgs) > 0 {
f.out.Output = msgs
if len(output) > 0 {
f.out.Output = openInferenceMessages(output)
if fr := finishReason(stringOf(attribute(f.attributes, openInferenceFinishReason))); fr != nil {
for i := range f.out.Output {
f.out.Output[i].FinishReason = fr

View File

@@ -160,30 +160,16 @@ func TestFormat_OpenInference(t *testing.T) {
// which the mapper moves onto gen_ai.output.messages
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_attributes.py#L430
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_observer.py#L628
name: "OutputKeysAbsent_RawOutputFormatted",
name: "OutputKeysAbsent_RawOutputFormatted_Warns",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "hi",
GenAIOutputMessages: "Hello there.",
},
warnings: []string{"bare assistant text, role assumed"},
warnings: []string{"bare assistant text, role assumed", "output formatted as text, input as openinference"},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello there."}]}]`,
},
{
// LiteLLM's OpenInference mode leaves the output bucket empty on a tool-calling turn; the calls are
// the Langfuse output the mapper moved onto gen_ai.output.messages
name: "EmptyOutputBucket_RawOutputFormatted",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "Yes, go ahead.",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "",
GenAIOutputMessages: `[{"id": "chatcmpl-2", "name": "set_stage", "call_id": "call_8", "type": "function_call", "arguments": {"fields": {"stage": "email"}}}]`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Yes, go ahead."}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "set_stage", "id": "call_8", "arguments": {"fields": {"stage": "email"}}}]}]`,
},
{
// message.role left unset by some instrumentors: tool_call_id, tool_calls or neither decide it,
// as the collector-contrib normalizer does

View File

@@ -122,31 +122,6 @@ So, the ball costs 5 cents.`,
wantInput: `[]`,
wantOutput: `[]`,
},
{
// Langfuse SDK generation: the app passes the request through superjson, the output is the parsed
// structured output
name: "SuperJSONEnvelope_Unwrapped_StructuredOutputGeneric",
input: `{"json": {"model": "gpt-4o-mini", "messages": [{"role": "system", "content": "Extract the main article of the page as JSON."}, {"role": "user", "content": "# Council approves new bike lanes"}], "temperature": 0}}`,
output: `{"hasArticle": true, "article": {"title": "Council approves new bike lanes"}}`,
warnings: []string{"message format not recognised, kept as generic"},
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Extract the main article of the page as JSON."}]}, {"role": "user", "parts": [{"type": "text", "content": "# Council approves new bike lanes"}]}]`,
wantOutput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"article\":{\"title\":\"Council approves new bike lanes\"},\"hasArticle\":true}"}]}]`,
},
{
name: "SuperJSONEnvelopeWithMeta_Unwrapped",
input: `{"json": {"messages": [{"role": "user", "content": "What time is it in Oslo?"}]}, "meta": {"values": {"messages.0.at": ["Date"]}}}`,
output: `{"json": {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "It is 14:05 in Oslo."}}]}, "meta": {"values": {}}}`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What time is it in Oslo?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "It is 14:05 in Oslo."}], "finish_reason": "stop"}]`,
},
{
// a json key beside others is a payload of its own, not the envelope
name: "JSONKeyBesideOthers_NotUnwrapped_Generic",
input: `{"json": {"messages": []}, "schema": "v1"}`,
warnings: []string{"message format not recognised, kept as generic"},
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"json\":{\"messages\":[]},\"schema\":\"v1\"}"}]}]`,
wantOutput: `[]`,
},
{
name: "NoMessageAttributes_EmptyLists",
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},

View File

@@ -134,12 +134,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
// rule — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
// saved-view — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
@@ -245,12 +249,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
// rule — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
// saved-view — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},

View File

@@ -336,6 +336,19 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
return r.validate()
}
func NewPatchedPostableRule(stored, patch []byte) (*PostableRule, error) {
patched := new(PostableRule)
if err := json.Unmarshal(stored, patched); err != nil {
return nil, err
}
if err := json.Unmarshal(patch, patched); err != nil {
return nil, err
}
return patched, nil
}
var _ jsonschema.Preparer = PostableRule{}
// PrepareJSONSchema marks version and source deprecated: nothing reads them,

View File

@@ -65,7 +65,7 @@ export const ThresholdMatchType = {
} as const;
/**
* `AlertListTabs` (`frontend/src/pages/AlertList/types.ts`). The values are
* `AlertListTabs` (`frontend/src/pages/AlertList/types.ts:7-9`). The values are
* space-less — the tab *labels* read "Triggered Alerts" but the `tab` URL param
* is `TriggeredAlerts`, and asserting the label form silently fails.
*/
@@ -73,9 +73,6 @@ export const AlertListTab = {
TRIGGERED_ALERTS: 'TriggeredAlerts',
ALERT_RULES: 'AlertRules',
CONFIGURATION: 'Configuration',
CHANNELS: 'Channels',
ROUTING_POLICIES: 'RoutingPolicies',
PLANNED_DOWNTIME: 'PlannedDowntime',
} as const;
/**

View File

@@ -381,7 +381,6 @@ test.describe('Alert create — v2 builder', () => {
const url = new URL(page.url());
expect(url.pathname).toBe('/alerts');
expect(url.searchParams.get('tab')).toBe('Configuration');
expect(url.searchParams.get('subTab')).toBe('routing-policies');
});
test('CV2-16 the group-by select is disabled until the query groups by something', async ({

View File

@@ -2,7 +2,7 @@ import { expect, test } from '../../fixtures/alerts/alert-rules';
import { ALERTS_LIST_PATH } from '../../helpers/alerts/constants';
import { watchConsole } from '../../helpers/common';
// TC-* — the Alerts page shell: the top-level tabs, how they map to
// TC-* — the Alerts page shell: the four top-level tabs, how they map to
// `?tab=`, and a navigation smoke check per tab. Tab *internals* (channel CRUD,
// planned downtime, routing policies) are deliberately out of scope — each
// deserves its own spec file.
@@ -10,11 +10,12 @@ import { watchConsole } from '../../helpers/common';
const TAB_NAMES = {
triggered: /triggered alerts/i,
rules: /alert rules/i,
channels: /notification channels/i,
configuration: /configuration/i,
};
test.describe('Alerts page shell', () => {
test('TC-01 all top-level tabs render', async ({ authedPage: page }) => {
test('TC-01 all four top-level tabs render', async ({ authedPage: page }) => {
await page.goto(ALERTS_LIST_PATH);
for (const name of Object.values(TAB_NAMES)) {
@@ -42,53 +43,43 @@ test.describe('Alerts page shell', () => {
).toBeVisible();
});
test('TC-03 tab switch writes ?tab=', async ({ authedPage: page }) => {
test('TC-03 tab switch writes ?tab= and clears subTab', async ({
authedPage: page,
}) => {
await page.goto(ALERTS_LIST_PATH);
await page.getByRole('tab', { name: TAB_NAMES.triggered }).click();
await expect(page).toHaveURL(/[?&]tab=TriggeredAlerts/);
await page.getByRole('tab', { name: TAB_NAMES.channels }).click();
await expect(page).toHaveURL(/[?&]tab=Channels/);
// Entering Configuration adds the default subTab...
await page.getByRole('tab', { name: TAB_NAMES.configuration }).click();
await expect(page).toHaveURL(/[?&]tab=Configuration/);
await expect(page).toHaveURL(/[?&]subTab=channels/);
await expect(page).toHaveURL(/[?&]subTab=planned-downtime/);
// ...and leaving it drops subTab entirely (onChange rebuilds the search
// from scratch rather than mutating the existing params).
await page.getByRole('tab', { name: TAB_NAMES.rules }).click();
await expect(page).toHaveURL(/[?&]tab=AlertRules/);
await expect(page).not.toHaveURL(/subTab=/);
});
test('TC-04 Configuration sub-tab deep-links and sidebar navigation', async ({
authedPage: page,
}) => {
test('TC-04 Configuration deep-link', async ({ authedPage: page }) => {
// Deep-linking without subTab: the inner Tabs falls back to
// planned-downtime for its activeKey without writing it to the URL, so
// assert the rendered tab, not the param.
await page.goto(`${ALERTS_LIST_PATH}?tab=Configuration`);
await expect(
page.getByRole('tab', { name: /planned downtime/i }),
).toHaveAttribute('aria-selected', 'true');
await page.goto(
`${ALERTS_LIST_PATH}?tab=Configuration&subTab=routing-policies`,
);
await expect(
page.getByRole('tab', { name: TAB_NAMES.configuration }),
).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('alert-configuration-sidebar')).toBeVisible();
await expect(
page.getByTestId('configuration-sidebar-routing-policies'),
).toBeVisible();
await page.goto(
`${ALERTS_LIST_PATH}?tab=Configuration&subTab=planned-downtime`,
);
await expect(
page.getByRole('tab', { name: TAB_NAMES.configuration }),
).toHaveAttribute('aria-selected', 'true');
await expect(
page.getByTestId('configuration-sidebar-planned-downtime'),
).toBeVisible();
// Backwards-compatible legacy deep links
await page.goto(`${ALERTS_LIST_PATH}?tab=RoutingPolicies`);
await expect(
page.getByRole('tab', { name: TAB_NAMES.configuration }),
).toHaveAttribute('aria-selected', 'true');
await page.goto(`${ALERTS_LIST_PATH}?tab=PlannedDowntime`);
await expect(
page.getByRole('tab', { name: TAB_NAMES.configuration }),
page.getByRole('tab', { name: /routing policies/i }),
).toHaveAttribute('aria-selected', 'true');
});
@@ -111,16 +102,13 @@ test.describe('Alerts page shell', () => {
expect(watch.failedResponses).toEqual([]);
});
test('TC-06 Notification Channels smoke under Configuration', async ({
test('TC-06 Notification Channels tab smoke', async ({
authedPage: page,
alertChannel,
}) => {
// The worker's channel gives this tab a row to render.
await page.goto(`${ALERTS_LIST_PATH}?tab=Configuration&subTab=channels`);
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(
page.getByTestId('configuration-sidebar-channels'),
).toBeVisible();
await expect(page.getByText(alertChannel.name)).toBeVisible();
await expect(
page.getByRole('button', { name: /new channel/i }),
@@ -128,17 +116,16 @@ test.describe('Alerts page shell', () => {
});
test('TC-07 tab state survives reload', async ({ authedPage: page }) => {
await page.goto(`${ALERTS_LIST_PATH}?tab=Configuration&subTab=channels`);
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(
page.getByRole('tab', { name: TAB_NAMES.configuration }),
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
await page.reload();
await expect(page).toHaveURL(/[?&]tab=Configuration/);
await expect(page).toHaveURL(/[?&]subTab=channels/);
await expect(page).toHaveURL(/[?&]tab=Channels/);
await expect(
page.getByRole('tab', { name: TAB_NAMES.configuration }),
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
});
});

View File

@@ -188,7 +188,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{
@@ -199,7 +201,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{
@@ -420,7 +424,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{
@@ -431,7 +437,9 @@
"read",
"update",
"delete",
"list"
"list",
"attach",
"detach"
]
},
{

File diff suppressed because it is too large Load Diff