mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-24 21:50:32 +01:00
Compare commits
3 Commits
feat/alert
...
issue_5955
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f3e78f1d | ||
|
|
9997c3da9c | ||
|
|
485aed0e1a |
@@ -20,16 +20,6 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
|
||||
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
|
||||
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
|
||||
```ts
|
||||
// Alert list tests - need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// Alert history tests - need evaluated history rows
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. 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.
|
||||
|
||||
@@ -49,7 +49,6 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- **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.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
@@ -8010,6 +8010,7 @@ components:
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
- ai_observability
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
|
||||
@@ -112,41 +112,6 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
**Fixture scopes:**
|
||||
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
|
||||
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
|
||||
|
||||
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
|
||||
|
||||
```
|
||||
fixtures/alerts/
|
||||
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
|
||||
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
|
||||
```
|
||||
|
||||
Specs import from the fixture they need:
|
||||
|
||||
```ts
|
||||
// List tests — just need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// History tests — need history rows from ruler evaluation
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
|
||||
**When creating new fixtures:**
|
||||
|
||||
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
|
||||
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
|
||||
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
|
||||
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
|
||||
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
|
||||
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
@@ -267,14 +232,11 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -308,14 +270,7 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
@@ -62,6 +62,40 @@ if (typeof window.ResizeObserver === 'undefined') {
|
||||
(window as any).ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
|
||||
if (typeof globalThis.DOMRect === 'undefined') {
|
||||
(globalThis as any).DOMRect = class DOMRect {
|
||||
x = 0;
|
||||
y = 0;
|
||||
width = 0;
|
||||
height = 0;
|
||||
top = 0;
|
||||
right = 0;
|
||||
bottom = 0;
|
||||
left = 0;
|
||||
constructor(x = 0, y = 0, width = 0, height = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.top = y;
|
||||
this.right = x + width;
|
||||
this.bottom = y + height;
|
||||
this.left = x;
|
||||
}
|
||||
toJSON(): any {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
static fromRect(rect?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}): DOMRect {
|
||||
return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/design-tokens": "2.1.6",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/ui": "0.0.23",
|
||||
"@signozhq/ui": "0.1.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -238,4 +238,4 @@
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
823
frontend/pnpm-lock.yaml
generated
823
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
// anything else
|
||||
console.error('ErrorResponseHandler: unclassified error');
|
||||
console.error('any');
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
|
||||
@@ -9021,6 +9021,7 @@ export enum SavedviewtypesSourceDTO {
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
ai_observability = 'ai_observability',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
|
||||
@@ -8,14 +8,12 @@ export interface AlertBreadcrumbProps {
|
||||
items: BreadcrumbItemConfig[];
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
showDivider = true,
|
||||
testId,
|
||||
}: AlertBreadcrumbProps): JSX.Element {
|
||||
const breadcrumbItems = items.map((item) => ({
|
||||
title: <BreadcrumbItem {...item} />,
|
||||
@@ -26,7 +24,6 @@ function AlertBreadcrumb({
|
||||
<Breadcrumb
|
||||
className={`${styles.breadcrumb} ${className || ''}`}
|
||||
items={breadcrumbItems}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showDivider && <Divider className={styles.divider} />}
|
||||
</>
|
||||
|
||||
@@ -664,6 +664,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => {
|
||||
value ??= '10';
|
||||
setLimit(+value);
|
||||
pagination.onLimitChange?.(+value);
|
||||
if (page !== 1) {
|
||||
|
||||
@@ -29,7 +29,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -41,7 +40,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,10 +26,7 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -41,10 +38,7 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -56,10 +50,7 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,12 +103,7 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -137,7 +123,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
<div className="count-label">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,11 +81,7 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,16 +48,11 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,10 +68,7 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
<div className="total-contribution">
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -81,10 +78,7 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,10 +31,7 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,10 +118,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -131,15 +128,12 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
<div className="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<div className="timeline-table__filter-search">
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -161,7 +155,6 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -179,17 +172,14 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<div className="timeline-table__error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
<div className="timeline-table__pagination-info">
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,14 +21,18 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<AlertState state={value} showLabel testId="timeline-row-state" />
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<AlertLabels labels={labels} testId="timeline-row-labels" />
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -36,10 +40,7 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
<div className="alert-rule__created-at">
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -52,7 +53,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost disabled>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -64,7 +65,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,17 +57,14 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
@@ -131,9 +132,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
<div ref={graphRef} className={styles.graphContainer}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
stack={StackMode.Normal}
|
||||
config={config}
|
||||
data={chartData}
|
||||
isStackedBarChart
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
customTooltip={renderBillingTooltip}
|
||||
width={containerDimensions.width}
|
||||
|
||||
@@ -58,26 +58,17 @@ describe('prepareBillingBarConfig', () => {
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
it('sets padding and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
// Stacking bands come from the chart now — see useChartStacking.
|
||||
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
|
||||
expect(config.focus).toStrictEqual({ alpha: 0.3 });
|
||||
});
|
||||
|
||||
it('sets no bands when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses queryName as label when legend is undefined', () => {
|
||||
const apiResponse: MetricRangePayloadProps = {
|
||||
data: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
@@ -63,7 +62,6 @@ export function prepareBillingBarConfig({
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -67,7 +66,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,6 @@ function EvaluationWindowPopover({
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
data-testid={`${sectionId}-option-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -186,7 +186,6 @@ function Footer(): JSX.Element {
|
||||
color="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="save-alert-rule-button"
|
||||
>
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
|
||||
@@ -219,7 +218,6 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="test-notification-button"
|
||||
>
|
||||
{isTestingAlertRule ? (
|
||||
<Loader data-testid="test-notification-loader-icon" size={14} />
|
||||
@@ -251,7 +249,6 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -6,25 +6,24 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { BarChartProps } from '../types';
|
||||
|
||||
export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
isStackedBarChart,
|
||||
customTooltip,
|
||||
config,
|
||||
data,
|
||||
stack = StackMode.None,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const chartData = useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart,
|
||||
config,
|
||||
});
|
||||
// Written during render so it lands before UPlotChart's effect reads the config,
|
||||
// which derives the fill bands, percent axis unit and percent range from it.
|
||||
config.setStackMode(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
@@ -37,7 +36,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
@@ -48,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.timezone,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
@@ -58,7 +55,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
config={config}
|
||||
data={chartData}
|
||||
data={data}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils';
|
||||
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
|
||||
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
|
||||
import noop from 'lodash-es/noop';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
import { ChartWrapperProps } from '../types';
|
||||
import { useChartStacking } from './useChartStacking';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 120;
|
||||
const TOOLTIP_MIN_WIDTH = 300;
|
||||
@@ -39,9 +42,20 @@ export default function ChartWrapper({
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
}: ChartWrapperProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const stack = config.getStackMode();
|
||||
const chartData = useChartStacking({ data, config });
|
||||
|
||||
// Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the
|
||||
// plot data — otherwise the cursor's index addresses a shorter array.
|
||||
const unstackedData = useMemo(
|
||||
() =>
|
||||
stack === StackMode.None ? undefined : prepareAlignedData({ data, config }),
|
||||
[data, config, stack],
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
@@ -61,11 +75,11 @@ export default function ChartWrapper({
|
||||
const renderTooltipCallback = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
return customTooltip(args);
|
||||
return customTooltip({ ...args, unstackedData });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[customTooltip],
|
||||
[customTooltip, unstackedData],
|
||||
);
|
||||
|
||||
const syncMetadata = useMemo(
|
||||
@@ -91,7 +105,7 @@ export default function ChartWrapper({
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={data}
|
||||
data={chartData}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { useChartStacking } from '../useChartStacking';
|
||||
|
||||
type Hooks = Record<string, (...args: unknown[]) => void>;
|
||||
|
||||
function createConfig(stack: StackMode): {
|
||||
config: UPlotConfigBuilder;
|
||||
hooks: Hooks;
|
||||
} {
|
||||
const hooks: Hooks = {};
|
||||
const config = {
|
||||
getStackMode: (): StackMode => stack,
|
||||
addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => {
|
||||
hooks[type] = hook;
|
||||
return jest.fn();
|
||||
}),
|
||||
} as unknown as UPlotConfigBuilder;
|
||||
return { config, hooks };
|
||||
}
|
||||
|
||||
const data = [[1], [30], [10]] as unknown as uPlot.AlignedData;
|
||||
|
||||
describe('useChartStacking', () => {
|
||||
it('returns the data untouched and registers nothing when the config says `none`', () => {
|
||||
const { config } = createConfig(StackMode.None);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toBe(data);
|
||||
expect(config.addHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a missing config as unstacked', () => {
|
||||
const { result } = renderHook(() => useChartStacking({ data, config: null }));
|
||||
|
||||
expect(result.current).toBe(data);
|
||||
});
|
||||
|
||||
it('accumulates raw values when the config declares `normal`', () => {
|
||||
const { config } = createConfig(StackMode.Normal);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toStrictEqual([[1], [40], [10]]);
|
||||
});
|
||||
|
||||
it('rescales each column to its total when the config declares `percent`', () => {
|
||||
const { config } = createConfig(StackMode.Percent);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toStrictEqual([[1], [100], [25]]);
|
||||
});
|
||||
|
||||
it('registers the uPlot hooks that re-stack on data and visibility changes', () => {
|
||||
const { config } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(
|
||||
(config.addHook as jest.Mock).mock.calls.map(([type]) => type),
|
||||
).toStrictEqual(['setData', 'setSeries']);
|
||||
});
|
||||
|
||||
it('re-stacks from the raw values when the legend hides a series', () => {
|
||||
const { config, hooks } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
const plot = {
|
||||
data: [[1]],
|
||||
series: [{}, { show: true }, { show: false }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
};
|
||||
hooks.setSeries(plot, 2, { show: false });
|
||||
|
||||
// The hidden series keeps its raw value and stops contributing to the total.
|
||||
expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]);
|
||||
expect(plot.delBand).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('ignores a focus-only setSeries so hovering does not re-stack', () => {
|
||||
const { config, hooks } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
const plot = {
|
||||
data: [[1]],
|
||||
series: [{}, { show: true }, { show: true }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
};
|
||||
hooks.setSeries(plot, 1, { focus: true });
|
||||
|
||||
expect(plot.setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stackSeries } from '../utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
return !plot.series[seriesIndex]?.show;
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
updateStacksInChart: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
updateStacksInChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeriesVisibilityChange = (
|
||||
plot: uPlot,
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
// uPlot fires setSeries for hover focus too; only visibility changes restack.
|
||||
if (!has(opts, 'focus')) {
|
||||
updateStacksInChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSetDataHook = config.addHook('setData', onDataChange);
|
||||
const removeSetSeriesHook = config.addHook(
|
||||
'setSeries',
|
||||
onSeriesVisibilityChange,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
removeSetDataHook?.();
|
||||
removeSetSeriesHook?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseChartStackingParams {
|
||||
data: uPlot.AlignedData;
|
||||
config: UPlotConfigBuilder | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks a chart's data for the mode declared on its config, and re-stacks on data or
|
||||
* visibility changes. The pre-stack values live in a ref because the uPlot hooks that
|
||||
* read them run outside React's render cycle.
|
||||
*/
|
||||
export function useChartStacking({
|
||||
data,
|
||||
config,
|
||||
}: UseChartStackingParams): uPlot.AlignedData {
|
||||
const stack = config?.getStackMode() ?? StackMode.None;
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = stack === 'none' ? null : data;
|
||||
|
||||
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (stack === StackMode.None || !data || data.length < 2) {
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
return stackSeries(data, noSeriesHidden, stack).data;
|
||||
}, [data, stack]);
|
||||
|
||||
const updateStacksInChart = useCallback(
|
||||
(plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
unstacked,
|
||||
shouldExcludeSeries,
|
||||
stack,
|
||||
);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
},
|
||||
[stack],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (stack === StackMode.None || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, updateStacksInChart, isUpdatingChartRef);
|
||||
}, [stack, config, updateStacksInChart]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -6,10 +6,16 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, customTooltip, ...rest } = props;
|
||||
const { children, customTooltip, stack = StackMode.None, ...rest } = props;
|
||||
|
||||
// Written during render so it lands before UPlotChart's effect reads the config,
|
||||
// which derives the fill bands, percent axis unit and percent range from it.
|
||||
rest.config.setStackMode(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChartClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -52,27 +53,26 @@ interface UPlotChartDataProps {
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
|
||||
export interface ChartWrapperProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
|
||||
|
||||
export interface TimeSeriesChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
export interface BarChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
isStackedBarChart?: boolean;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { stackSeries } from '../stackSeriesUtils';
|
||||
|
||||
const includeAll = (): boolean => false;
|
||||
|
||||
// Stacking is top-down: the first series carries the column total, the last its own
|
||||
// raw value. Every expectation below reads in that order.
|
||||
describe('stackSeries', () => {
|
||||
it('is a no-op under `none`, returning the data and no bands', () => {
|
||||
const data: AlignedData = [[1], [30], [10]];
|
||||
|
||||
const { data: result, bands } = stackSeries(data, includeAll, StackMode.None);
|
||||
|
||||
expect(result).toBe(data);
|
||||
expect(bands).toStrictEqual([]);
|
||||
});
|
||||
|
||||
describe('normal', () => {
|
||||
it('accumulates raw values from the bottom series upward', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[1, 2],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[11, 22],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats nulls as 0 without breaking the running total', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[10, null],
|
||||
[1, 2],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[11, 2],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits one band per adjacent pair of participating series', () => {
|
||||
const data: AlignedData = [[1], [10], [5], [1]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies omitted series through unstacked and skips their bands', () => {
|
||||
const data: AlignedData = [[1], [10], [5], [1]];
|
||||
const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2;
|
||||
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
data,
|
||||
omitMiddle,
|
||||
StackMode.Normal,
|
||||
);
|
||||
|
||||
expect(stacked).toStrictEqual([[1], [11], [5], [1]]);
|
||||
expect(bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('percent', () => {
|
||||
it('rescales each column to its total so the top series reads 100', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[30, 10],
|
||||
[10, 10],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[100, 100],
|
||||
[25, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalises per column, so an identical series differs across x', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[1, 3],
|
||||
[1, 1],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[100, 100],
|
||||
[50, 25],
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes omitted series from the total, so the visible ones still reach 100', () => {
|
||||
const data: AlignedData = [[1], [30], [10], [60]];
|
||||
const omitLast = (seriesIndex: number): boolean => seriesIndex === 3;
|
||||
|
||||
expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[100],
|
||||
[25],
|
||||
[60],
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields 0 for a column whose participating series sum to zero', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[0, 5],
|
||||
[0, 5],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[0, 100],
|
||||
[0, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('divides by the signed total when a column mixes signs', () => {
|
||||
// 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100.
|
||||
const data: AlignedData = [[1], [30], [-10]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[100],
|
||||
[-50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields 0 across a column whose signed total cancels to zero', () => {
|
||||
const data: AlignedData = [[1], [10], [-10]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[0],
|
||||
[0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to normal when no mode is given', () => {
|
||||
const data: AlignedData = [[1], [30], [10]];
|
||||
|
||||
expect(stackSeries(data, includeAll).data).toStrictEqual(
|
||||
stackSeries(data, includeAll, StackMode.Normal).data,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { getInitialStackedBands, stack } from '../stackUtils';
|
||||
|
||||
describe('stackUtils', () => {
|
||||
describe('stack', () => {
|
||||
const neverOmit = (): boolean => false;
|
||||
|
||||
it('preserves time axis as first row', () => {
|
||||
const data: AlignedData = [
|
||||
[100, 200, 300],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[0]).toStrictEqual([100, 200, 300]);
|
||||
});
|
||||
|
||||
it('stacks value series cumulatively (last = raw, first = total)', () => {
|
||||
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
|
||||
const data: AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3], // series 1
|
||||
[4, 5, 6], // series 2
|
||||
[7, 8, 9], // series 3
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
|
||||
expect(result[1]).toStrictEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
|
||||
expect(result[2]).toStrictEqual([11, 13, 15]); // 4+7, 5+8, 6+9
|
||||
expect(result[3]).toStrictEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('treats null values as 0 when stacking', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, null],
|
||||
[null, 10],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[1]).toStrictEqual([1, 10]); // total
|
||||
expect(result[2]).toStrictEqual([0, 10]); // last series with null→0
|
||||
});
|
||||
|
||||
it('copies omitted series as-is without accumulating', () => {
|
||||
// Omit series 2 (index 2); series 1 and 3 are stacked.
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20], // series 1
|
||||
[100, 200], // series 2 - omitted
|
||||
[1, 2], // series 3
|
||||
];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { data: result } = stack(data, omitSeries2);
|
||||
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
|
||||
expect(result[1]).toStrictEqual([11, 22]); // 10+1, 20+2
|
||||
expect(result[2]).toStrictEqual([100, 200]); // copied, not stacked
|
||||
expect(result[3]).toStrictEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('returns bands between consecutive visible series when none omitted', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
});
|
||||
|
||||
it('returns bands only between visible series when some are omitted', () => {
|
||||
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
|
||||
const data: AlignedData = [[0], [1], [2], [3], [4]];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { bands } = stack(data, omitSeries2);
|
||||
expect(bands).toStrictEqual([{ series: [1, 3] }, { series: [3, 4] }]);
|
||||
});
|
||||
|
||||
it('returns empty bands when only one value series', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialStackedBands', () => {
|
||||
it('returns one band between each consecutive pair for seriesCount 3', () => {
|
||||
expect(getInitialStackedBands(3)).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for seriesCount 0 or 1', () => {
|
||||
expect(getInitialStackedBands(0)).toStrictEqual([]);
|
||||
expect(getInitialStackedBands(1)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns single band for seriesCount 2', () => {
|
||||
expect(getInitialStackedBands(2)).toStrictEqual([{ series: [1, 2] }]);
|
||||
});
|
||||
|
||||
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
|
||||
const bands = getInitialStackedBands(5);
|
||||
expect(bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
{ series: [3, 4] },
|
||||
{ series: [4, 5] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
|
||||
* contributes nothing to the total. `None` is a no-op.
|
||||
*/
|
||||
export function stackSeries(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
mode: StackMode = StackMode.Normal,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
if (mode === StackMode.None) {
|
||||
return { data, bands: [] };
|
||||
}
|
||||
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
@@ -17,6 +24,7 @@ export function stackSeries(
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
@@ -31,6 +39,46 @@ interface BuildStackedSeriesParams {
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
mode: StackMode;
|
||||
}
|
||||
|
||||
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
|
||||
function columnTotals({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
|
||||
const totals = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
rawValues.forEach((rawValue, pointIndex) => {
|
||||
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
|
||||
function toPercent(value: number, total: number): number {
|
||||
return total === 0 ? 0 : (value / total) * 100;
|
||||
}
|
||||
|
||||
/** What a raw value adds to the stack at a given point. */
|
||||
type Contribution = (value: number, pointIndex: number) => number;
|
||||
|
||||
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
|
||||
if (params.mode !== StackMode.Percent) {
|
||||
return (value): number => value;
|
||||
}
|
||||
// Resolved up front: totals span series the accumulation below has not reached yet.
|
||||
const totals = columnTotals(params);
|
||||
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +90,17 @@ function buildStackedSeries({
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
const contributionOf = contributionForMode({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
@@ -54,7 +110,10 @@ function buildStackedSeries({
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
return (cumulativeSums[pointIndex] += contributionOf(
|
||||
numericValue,
|
||||
pointIndex,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,16 +160,3 @@ function findNextVisibleSeriesIndex(
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
*/
|
||||
export function stack(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
|
||||
const stackedSeries = buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
return {
|
||||
data: [timeAxis, ...stackedSeries] as AlignedData,
|
||||
bands,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildStackedSeriesParams {
|
||||
data: AlignedData;
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate from last series upward: last series = raw values, first = total.
|
||||
* Omitted series are copied as-is (no accumulation).
|
||||
*/
|
||||
function buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
|
||||
if (omit(seriesIndex)) {
|
||||
stackedSeries[seriesIndex - 1] = rawValues;
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bands define fill between consecutive visible series for stacked appearance.
|
||||
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
function buildFillBands(
|
||||
seriesLength: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
|
||||
seriesLength,
|
||||
seriesIndex,
|
||||
omit,
|
||||
);
|
||||
if (nextVisibleSeriesIndex !== -1) {
|
||||
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
|
||||
}
|
||||
}
|
||||
|
||||
return bands;
|
||||
}
|
||||
|
||||
function findNextVisibleSeriesIndex(
|
||||
seriesLength: number,
|
||||
afterIndex: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): number {
|
||||
for (let i = afterIndex + 1; i < seriesLength; i++) {
|
||||
if (!omit(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { UseBarChartStackingParams } from '../useBarChartStacking';
|
||||
import { useBarChartStacking } from '../useBarChartStacking';
|
||||
|
||||
type MockConfig = { addHook: jest.Mock };
|
||||
|
||||
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
|
||||
return c as unknown as UseBarChartStackingParams['config'];
|
||||
}
|
||||
|
||||
function createMockConfig(): {
|
||||
config: MockConfig;
|
||||
invokeSetData: (plot: uPlot) => void;
|
||||
invokeSetSeries: (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
) => void;
|
||||
removeSetData: jest.Mock;
|
||||
removeSetSeries: jest.Mock;
|
||||
} {
|
||||
let setDataHandler: ((plot: uPlot) => void) | null = null;
|
||||
let setSeriesHandler:
|
||||
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
|
||||
| null = null;
|
||||
|
||||
const removeSetData = jest.fn();
|
||||
const removeSetSeries = jest.fn();
|
||||
|
||||
const addHook = jest.fn(
|
||||
(
|
||||
hookName: string,
|
||||
handler: (plot: uPlot, ...args: unknown[]) => void,
|
||||
): (() => void) => {
|
||||
if (hookName === 'setData') {
|
||||
setDataHandler = handler as (plot: uPlot) => void;
|
||||
return removeSetData;
|
||||
}
|
||||
if (hookName === 'setSeries') {
|
||||
setSeriesHandler = handler as (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: uPlot.Series,
|
||||
) => void;
|
||||
return removeSetSeries;
|
||||
}
|
||||
return jest.fn();
|
||||
},
|
||||
);
|
||||
|
||||
const config: MockConfig = { addHook };
|
||||
|
||||
const invokeSetData = (plot: uPlot): void => {
|
||||
setDataHandler?.(plot);
|
||||
};
|
||||
|
||||
const invokeSetSeries = (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
): void => {
|
||||
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
invokeSetData,
|
||||
invokeSetSeries,
|
||||
removeSetData,
|
||||
removeSetSeries,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
|
||||
return {
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
],
|
||||
series: [{ show: true }, { show: true }, { show: true }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
...overrides,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
describe('useBarChartStacking', () => {
|
||||
it('returns data as-is when isStackedBarChart is false', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[100, 200],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(data);
|
||||
});
|
||||
|
||||
it('returns data as-is when config is null and isStackedBarChart is true', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[4, 5],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
// Still returns stacked data (computed in useMemo); no hooks registered
|
||||
expect(result.current[0]).toStrictEqual([0, 1]);
|
||||
expect(result.current[1]).toStrictEqual([5, 7]); // stacked
|
||||
expect(result.current[2]).toStrictEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current[0]).toStrictEqual([0, 1, 2]);
|
||||
expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3
|
||||
expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3
|
||||
expect(result.current[3]).toStrictEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('returns data as-is when only one value series (no stacking needed)', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toStrictEqual(data);
|
||||
});
|
||||
|
||||
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
|
||||
expect(config.addHook).toHaveBeenCalledWith(
|
||||
'setSeries',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not register hooks when isStackedBarChart is false', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cleanup when unmounted', () => {
|
||||
const { config, removeSetData, removeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSetData).toHaveBeenCalled();
|
||||
expect(removeSetSeries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-stacks and updates plot when setData hook is invoked', () => {
|
||||
const { config, invokeSetData } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[5, 7, 9],
|
||||
[4, 5, 6],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetData(plot);
|
||||
|
||||
expect(plot.delBand).toHaveBeenCalledWith(null);
|
||||
expect(plot.addBand).toHaveBeenCalled();
|
||||
expect(plot.setData).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
[0, 1, 2],
|
||||
expect.any(Array), // stacked row 1
|
||||
expect.any(Array), // stacked row 2
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20],
|
||||
[5, 10],
|
||||
];
|
||||
// Plot data must match unstacked length so canApplyStacking passes
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1],
|
||||
[15, 30],
|
||||
[5, 10],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetSeries(plot, 1, { show: false });
|
||||
|
||||
expect(plot.setData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-stack when setSeries is called with focus option', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const plot = createMockPlot();
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
(plot.setData as jest.Mock).mockClear();
|
||||
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
|
||||
|
||||
expect(plot.setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stackSeries } from '../charts/utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
return !plot.series[seriesIndex]?.show;
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
applyStackingToChart: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeriesVisibilityChange = (
|
||||
plot: uPlot,
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
if (!has(opts, 'focus')) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSetDataHook = config.addHook('setData', onDataChange);
|
||||
const removeSetSeriesHook = config.addHook(
|
||||
'setSeries',
|
||||
onSeriesVisibilityChange,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
removeSetDataHook?.();
|
||||
removeSetSeriesHook?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseBarChartStackingParams {
|
||||
data: uPlot.AlignedData;
|
||||
isStackedBarChart?: boolean;
|
||||
config: UPlotConfigBuilder | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles stacking for bar charts: computes initial stacked data and re-stacks
|
||||
* when data or series visibility changes (e.g. legend toggles).
|
||||
*/
|
||||
export function useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart = false,
|
||||
config,
|
||||
}: UseBarChartStackingParams): uPlot.AlignedData {
|
||||
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = isStackedBarChart ? data : null;
|
||||
|
||||
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (!isStackedBarChart || !data || data.length < 2) {
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
const { data: stacked } = stackSeries(data, noSeriesHidden);
|
||||
return stacked;
|
||||
}, [data, isStackedBarChart]);
|
||||
|
||||
const applyStackingToChart = useCallback((plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isStackedBarChart || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
|
||||
}, [isStackedBarChart, config, applyStackingToChart]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { prepareBarPanelConfig } from './utils';
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -147,6 +148,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
stack={widget.stackedBarChart ? StackMode.Normal : StackMode.None}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -159,7 +161,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
|
||||
@@ -35,20 +35,10 @@ jest.mock('lib/getLabelName', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
() => ({
|
||||
getInitialStackedBands: jest.fn().mockReturnValue([]),
|
||||
}),
|
||||
);
|
||||
|
||||
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
|
||||
.getLegend as jest.Mock;
|
||||
const getLabelNameMock = jest.requireMock('lib/getLabelName')
|
||||
.default as jest.Mock;
|
||||
const getInitialStackedBandsMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
).getInitialStackedBands as jest.Mock;
|
||||
|
||||
const createApiResponse = (
|
||||
result: MetricRangePayloadProps['data']['result'] = [],
|
||||
@@ -247,36 +237,5 @@ describe('BarPanel utils', () => {
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
|
||||
});
|
||||
|
||||
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
|
||||
const widget = createWidget({ stackedBarChart: true });
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
|
||||
// seriesCount = result.length + 1 = 3
|
||||
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('does not call getInitialStackedBands for non-stacked chart', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, apiResponse });
|
||||
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
@@ -69,11 +68,6 @@ export function prepareBarPanelConfig({
|
||||
return builder;
|
||||
}
|
||||
|
||||
if (widget.stackedBarChart) {
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
}
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
|
||||
@@ -119,7 +119,6 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -148,7 +147,6 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -163,7 +161,6 @@ function BasicInfo({
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
|
||||
@@ -105,7 +105,7 @@ function QuerySection({
|
||||
{
|
||||
label: (
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Button className="nav-btns">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,11 +122,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -166,11 +162,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -188,11 +180,7 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,6 @@ function RuleOptions({
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
@@ -117,7 +116,6 @@ function RuleOptions({
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
@@ -179,7 +177,6 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -197,7 +194,6 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -399,7 +395,6 @@ function RuleOptions({
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -844,6 +844,8 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -966,7 +968,6 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -980,7 +981,6 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,7 +989,6 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -999,7 +998,6 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,7 +174,6 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
|
||||
--tab-content-padding: 0;
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-content-padding: 0;
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageError {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
[role='tabpanel'] {
|
||||
margin: 0;
|
||||
padding: var(--spacing-0) var(--spacing-4);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
--tab-content-padding: 0;
|
||||
--tabs-content-padding: 0;
|
||||
margin-top: var(--spacing-3);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
}
|
||||
|
||||
// Remove default tab content padding/margin — the card provides spacing.
|
||||
--tab-content-padding: 0;
|
||||
--tab-content-margin: var(--spacing-4) 0 0;
|
||||
--tabs-content-padding: 0;
|
||||
--tabs-content-margin: var(--spacing-4) 0 0;
|
||||
}
|
||||
|
||||
.mcp-client-tabs {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -137,6 +138,7 @@ function TimeSeries({
|
||||
key={`${WIDGET_ID}-${index}`}
|
||||
>
|
||||
<BarChart
|
||||
stack={StackMode.Normal}
|
||||
config={chart.config}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
@@ -144,7 +146,6 @@ function TimeSeries({
|
||||
data={chart.chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
isStackedBarChart
|
||||
yAxisUnit={yAxisUnit || 'short'}
|
||||
timezone={timezone}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -89,9 +88,6 @@ export function buildMeterChartConfig({
|
||||
return builder;
|
||||
}
|
||||
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
|
||||
@@ -35,7 +35,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
class="c0"
|
||||
>
|
||||
<p
|
||||
class="_typography_ulrzs_1"
|
||||
class="_typography_j4pmm_1"
|
||||
data-slot="typography"
|
||||
data-variant="text"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
class="value-text-container"
|
||||
>
|
||||
<p
|
||||
class="_typography_ulrzs_1 value-graph-text"
|
||||
class="_typography_j4pmm_1 value-graph-text"
|
||||
data-slot="typography"
|
||||
data-testid="value-graph-text"
|
||||
data-variant="text"
|
||||
@@ -59,7 +59,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
295.43
|
||||
</p>
|
||||
<p
|
||||
class="_typography_ulrzs_1 value-graph-unit"
|
||||
class="_typography_j4pmm_1 value-graph-unit"
|
||||
data-slot="typography"
|
||||
data-testid="value-graph-suffix-unit"
|
||||
data-variant="text"
|
||||
|
||||
@@ -22,11 +22,11 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="_switch-wrapper_jbsv7_1"
|
||||
class="_switch-wrapper_1a8sn_6"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="_switch_jbsv7_1"
|
||||
class="_switch_1a8sn_6"
|
||||
data-color="robin"
|
||||
data-state="checked"
|
||||
id=":r0:"
|
||||
@@ -35,7 +35,7 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
value="on"
|
||||
>
|
||||
<span
|
||||
class="_switch__thumb_jbsv7_59"
|
||||
class="_switch__thumb_1a8sn_71"
|
||||
data-state="checked"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -74,7 +74,7 @@ exports[`PipelinePage container test should render PipelinePageLayout section 1`
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
class="_typography_ulrzs_1"
|
||||
class="_typography_j4pmm_1"
|
||||
data-slot="typography"
|
||||
data-variant="text"
|
||||
>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--tab-content-padding: 0px;
|
||||
--tabs-content-padding: 0px;
|
||||
|
||||
[role='tabpanel'] {
|
||||
display: flex;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
min-width: 400px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
unstackedData: props.unstackedData,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
@@ -21,6 +22,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function TimeSeriesTooltip(
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
unstackedData: props.unstackedData,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
@@ -22,6 +23,7 @@ export default function TimeSeriesTooltip(
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -72,6 +72,35 @@ describe('Tooltip utils', () => {
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it('reports the pre-stack value, identically for normal and percent', () => {
|
||||
const unstackedData: AlignedData = [[0], [30], [10]];
|
||||
const series = [{}, { show: true }, { show: true }] as Series[];
|
||||
const read = (data: AlignedData): number | null =>
|
||||
getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index: 1,
|
||||
dataIndex: 0,
|
||||
isStackedBarChart: true,
|
||||
series,
|
||||
});
|
||||
|
||||
expect(read([[0], [40], [10]])).toBe(30);
|
||||
expect(read([[0], [100], [25]])).toBe(30);
|
||||
});
|
||||
|
||||
it('falls back to subtraction when no pre-stack data is given', () => {
|
||||
const result = getTooltipBaseValue({
|
||||
data: [[0], [40], [10]],
|
||||
index: 1,
|
||||
dataIndex: 0,
|
||||
isStackedBarChart: true,
|
||||
series: [{}, { show: true }, { show: true }] as Series[],
|
||||
});
|
||||
|
||||
expect(result).toBe(30);
|
||||
});
|
||||
|
||||
it('returns null when value is missing', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
|
||||
@@ -23,17 +23,25 @@ export function resolveSeriesColor(
|
||||
|
||||
export function getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
series,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
unstackedData?: AlignedData;
|
||||
index: number;
|
||||
dataIndex: number;
|
||||
isStackedBarChart?: boolean;
|
||||
series?: Series[];
|
||||
}): number | null {
|
||||
// The subtraction below only recovers the raw value under `normal` stacking.
|
||||
const unstackedSeries = unstackedData?.[index];
|
||||
if (unstackedSeries) {
|
||||
return unstackedSeries[dataIndex] ?? null;
|
||||
}
|
||||
|
||||
let baseValue = data[index][dataIndex] ?? null;
|
||||
// Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible].
|
||||
// When series are hidden, we must use the next *visible* series, not index+1,
|
||||
@@ -56,6 +64,7 @@ export function getTooltipBaseValue({
|
||||
|
||||
export function buildTooltipContent({
|
||||
data,
|
||||
unstackedData,
|
||||
series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex,
|
||||
@@ -67,6 +76,7 @@ export function buildTooltipContent({
|
||||
syncFilterMode,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
unstackedData?: AlignedData;
|
||||
series: Series[];
|
||||
dataIndexes: Array<number | null>;
|
||||
activeSeriesIndex: number | null;
|
||||
@@ -115,6 +125,7 @@ export function buildTooltipContent({
|
||||
|
||||
const baseValue = getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index: seriesIndex,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface TooltipRenderArgs {
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
/**
|
||||
* Pre-stack values, injected by `ChartWrapper`. `Percent` discards the column total,
|
||||
* so the raw value cannot be recovered from the plot's own cumulative data.
|
||||
*/
|
||||
unstackedData?: uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface IRenderTooltipFooterArgs {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||
@@ -28,6 +29,11 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
||||
/**
|
||||
* Type definitions for uPlot option objects
|
||||
*/
|
||||
/** Renders a 0–100 number as `50%`, unlike the 0–1 `percentunit`. */
|
||||
const PERCENT_AXIS_UNIT = 'percent';
|
||||
|
||||
const PERCENT_AXIS_MAX = 100;
|
||||
|
||||
type LegendConfig = {
|
||||
show?: boolean;
|
||||
live?: boolean;
|
||||
@@ -57,6 +63,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private bands: uPlot.Band[] = [];
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -143,6 +151,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.axes[scaleKey] = new UPlotAxisBuilder(props);
|
||||
}
|
||||
|
||||
/** Drives the fill bands, the percent axis unit and the percent range below. */
|
||||
setStackMode(stackMode: StackMode): void {
|
||||
this.stackMode = stackMode;
|
||||
}
|
||||
|
||||
getStackMode(): StackMode {
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -211,6 +228,41 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.bands = bands;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's own limits are in the source unit, which means nothing once values are
|
||||
* normalised. Soft rather than hard, so mixed-sign shares outside 0–100 stay visible.
|
||||
*/
|
||||
private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder {
|
||||
if (this.stackMode !== StackMode.Percent || scale.props.scaleKey !== 'y') {
|
||||
return scale;
|
||||
}
|
||||
return new UPlotScaleBuilder({
|
||||
...scale.props,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: 0,
|
||||
softMax: PERCENT_AXIS_MAX,
|
||||
// Thresholds still draw, but a 500ms one must not stretch the axis to 0–500.
|
||||
thresholds: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Explicit bands win; otherwise a stack fills between consecutive series. */
|
||||
private resolveBands(): uPlot.Band[] | undefined {
|
||||
if (this.bands.length > 0) {
|
||||
return this.bands;
|
||||
}
|
||||
if (this.stackMode === StackMode.None || this.series.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
this.series
|
||||
.slice(0, -1)
|
||||
// uPlot series are 1-based (index 0 is the timestamp axis).
|
||||
.map((_, index) => ({ series: [index + 1, index + 2] as [number, number] }))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cursor configuration
|
||||
*/
|
||||
@@ -444,9 +496,19 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
};
|
||||
}),
|
||||
];
|
||||
config.axes = Object.values(this.axes).map((a) => a.getConfig());
|
||||
config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => {
|
||||
if (scaleKey !== 'y' || this.stackMode !== StackMode.Percent) {
|
||||
return axis.getConfig();
|
||||
}
|
||||
// Ticks read as percentages; the panel unit still applies to tooltips and
|
||||
// thresholds, so build from a copy rather than touching the axis props.
|
||||
return new UPlotAxisBuilder({
|
||||
...axis.props,
|
||||
yAxisUnit: PERCENT_AXIS_UNIT,
|
||||
}).getConfig();
|
||||
});
|
||||
config.scales = this.scales.reduce(
|
||||
(acc, s) => ({ ...acc, ...s.getConfig() }),
|
||||
(acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }),
|
||||
{} as Record<string, uPlot.Scale>,
|
||||
);
|
||||
|
||||
@@ -456,7 +518,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
config.cursor = this.getCursorConfig();
|
||||
config.tzDate = this.tzDate;
|
||||
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
|
||||
config.bands = this.bands.length > 0 ? this.bands : undefined;
|
||||
config.bands = this.resolveBands();
|
||||
|
||||
if (Array.isArray(this.padding)) {
|
||||
config.padding = this.padding;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -496,3 +496,161 @@ describe('UPlotConfigBuilder', () => {
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotConfigBuilder stacking', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Soft limits end up captured in the scale's range closure, so the only way to read
|
||||
* them back is to run it and inspect the range config it hands uPlot.
|
||||
*/
|
||||
function scaleSoftLimits(
|
||||
builder: UPlotConfigBuilder,
|
||||
scaleKey: string,
|
||||
): { min: number; max: number } {
|
||||
const rangeNum = jest.fn().mockReturnValue([0, 0]);
|
||||
(uPlot as unknown as { rangeNum: unknown }).rangeNum = rangeNum;
|
||||
|
||||
const range = builder.getConfig().scales?.[scaleKey]?.range as (
|
||||
u: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
key: string,
|
||||
) => void;
|
||||
range({ scales: { [scaleKey]: { distr: 1 } } }, 40, 60, scaleKey);
|
||||
|
||||
const [, , rangeConfig] = rangeNum.mock.calls[0] as [
|
||||
number,
|
||||
number,
|
||||
{ min: { soft: number }; max: { soft: number } },
|
||||
];
|
||||
return { min: rangeConfig.min.soft, max: rangeConfig.max.soft };
|
||||
}
|
||||
|
||||
/** Renders y-axis ticks the way uPlot would, so unit formatting is observable. */
|
||||
function yAxisTicks(builder: UPlotConfigBuilder, ticks: number[]): string[] {
|
||||
const yAxis = builder.getConfig().axes?.find((a) => a.scale === 'y');
|
||||
const values = yAxis?.values as (
|
||||
u: unknown,
|
||||
splits: number[],
|
||||
) => (string | null)[];
|
||||
return values(null, ticks).map((v) => String(v));
|
||||
}
|
||||
|
||||
function builderFor(stack?: StackMode, seriesCount = 3): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-test' });
|
||||
if (stack) {
|
||||
builder.setStackMode(stack);
|
||||
}
|
||||
builder.addAxis({ scaleKey: 'y', show: true, side: 3, yAxisUnit: 'ms' });
|
||||
for (let i = 0; i < seriesCount; i++) {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: `S${i}`,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
colorMapping: {},
|
||||
isDarkMode: false,
|
||||
} as SeriesProps);
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
it('defaults to no stacking, so no bands and the panel unit on the axis', () => {
|
||||
const builder = builderFor();
|
||||
|
||||
expect(builder.getStackMode()).toBe('none');
|
||||
expect(builder.getConfig().bands).toBeUndefined();
|
||||
expect(yAxisTicks(builder, [1000])).toStrictEqual(['1 s']);
|
||||
});
|
||||
|
||||
it('derives one band per adjacent series pair once a stack is declared', () => {
|
||||
expect(builderFor(StackMode.Normal).getConfig().bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits no bands for a single series', () => {
|
||||
expect(builderFor(StackMode.Normal, 1).getConfig().bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the panel unit on the axis for a normal stack', () => {
|
||||
expect(yAxisTicks(builderFor(StackMode.Normal), [1000])).toStrictEqual([
|
||||
'1 s',
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats the axis as percentages for a percent stack', () => {
|
||||
expect(yAxisTicks(builderFor(StackMode.Percent), [0, 50, 100])).toStrictEqual(
|
||||
['0%', '50%', '100%'],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves other axes on their own unit under a percent stack', () => {
|
||||
const builder = builderFor(StackMode.Percent);
|
||||
builder.addAxis({ scaleKey: 'x', show: true, side: 2 });
|
||||
|
||||
expect(builder.getConfig().axes?.map((a) => a.scale)).toStrictEqual([
|
||||
'y',
|
||||
'x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the y scale to the 0–100 band under a percent stack, dropping panel limits', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
|
||||
builder.setStackMode(StackMode.Percent);
|
||||
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
|
||||
|
||||
// Soft, not hard: mixed-sign shares fall outside 0–100 and must stay visible.
|
||||
expect(builder.getConfig().scales?.y).toMatchObject({ auto: true });
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
|
||||
});
|
||||
|
||||
it('leaves the panel limits alone when the stack is not percent', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
|
||||
builder.setStackMode(StackMode.Normal);
|
||||
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
|
||||
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 5, max: 500 });
|
||||
});
|
||||
|
||||
it.each([StackMode.Normal, StackMode.Percent])(
|
||||
'draws thresholds under a %s stack',
|
||||
(stack) => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
|
||||
builder.setStackMode(stack);
|
||||
builder.addThresholds({
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
|
||||
yAxisUnit: 'ms',
|
||||
});
|
||||
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a source-unit threshold from stretching the percent band', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
|
||||
builder.setStackMode(StackMode.Percent);
|
||||
const thresholds = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
builder.addThresholds(thresholds);
|
||||
builder.addScale({ scaleKey: 'y', thresholds });
|
||||
|
||||
// Without this the 500ms threshold would widen a percentage axis to 0–500.
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
|
||||
});
|
||||
|
||||
it('lets explicit bands win over the derived ones', () => {
|
||||
const builder = builderFor(StackMode.Normal);
|
||||
builder.setBands([{ series: [1, 3] }]);
|
||||
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,13 @@ export enum SelectionPreferencesSource {
|
||||
/**
|
||||
* Props for configuring the uPlot config builder
|
||||
*/
|
||||
/** `Percent` rescales each x-slice to its column total, so every column fills to 100. */
|
||||
export enum StackMode {
|
||||
None = 'none',
|
||||
Normal = 'normal',
|
||||
Percent = 'percent',
|
||||
}
|
||||
|
||||
export interface ConfigBuilderProps {
|
||||
id: string;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
|
||||
@@ -281,3 +281,20 @@ describe('dataUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLargeGapNullsIntoAlignedData index alignment', () => {
|
||||
// ChartWrapper gap-processes the pre-stack series to keep tooltip indices aligned;
|
||||
// that only holds because insertions are decided from the x axis, never from y.
|
||||
it('inserts at the same positions regardless of the y values', () => {
|
||||
const x = [0, 100, 200];
|
||||
const options = [{ spanGaps: 50 }];
|
||||
const raw = [x, [1, 2, 3]] as uPlot.AlignedData;
|
||||
const stacked = [x, [10, 20, 30]] as uPlot.AlignedData;
|
||||
|
||||
const fromRaw = insertLargeGapNullsIntoAlignedData(raw, options);
|
||||
const fromStacked = insertLargeGapNullsIntoAlignedData(stacked, options);
|
||||
|
||||
expect(fromRaw[0]).toStrictEqual(fromStacked[0]);
|
||||
expect(fromRaw[1]).toHaveLength((fromStacked[1] as unknown[]).length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,8 +94,6 @@ function AlertDetails(): JSX.Element {
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -117,11 +117,7 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -133,7 +129,6 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,26 +47,21 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState
|
||||
state={alertRuleState ?? state ?? ''}
|
||||
testId="alert-header-state"
|
||||
/>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && (
|
||||
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
|
||||
)}
|
||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,16 +6,14 @@ import './AlertLabels.styles.scss';
|
||||
export type AlertLabelsProps = {
|
||||
labels: Record<string, any>;
|
||||
initialCount?: number;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
function AlertLabels({
|
||||
labels,
|
||||
initialCount = 2,
|
||||
testId,
|
||||
}: AlertLabelsProps): JSX.Element {
|
||||
return (
|
||||
<div className="alert-labels" data-testid={testId}>
|
||||
<div className="alert-labels">
|
||||
<SeeMore initialCount={initialCount} moreLabel="More">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<KeyValueLabel key={`label-${key}`} badgeKey={key} badgeValue={value} />
|
||||
@@ -27,7 +25,6 @@ function AlertLabels({
|
||||
|
||||
AlertLabels.defaultProps = {
|
||||
initialCount: 2,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default AlertLabels;
|
||||
|
||||
@@ -32,10 +32,8 @@ const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
testId,
|
||||
}: {
|
||||
severity: string;
|
||||
testId?: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
@@ -54,16 +52,9 @@ export default function AlertSeverity({
|
||||
};
|
||||
}, [severity]);
|
||||
return (
|
||||
<div
|
||||
className={`alert-severity ${severityDetails.className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={`alert-severity ${severityDetails.className}`}>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
<div className="alert-severity__text">{severityDetails.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertSeverity.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -8,13 +8,11 @@ import './AlertState.styles.scss';
|
||||
type AlertStateProps = {
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
showLabel?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export default function AlertState({
|
||||
state,
|
||||
showLabel,
|
||||
testId,
|
||||
}: AlertStateProps): JSX.Element {
|
||||
let icon;
|
||||
let label;
|
||||
@@ -66,7 +64,7 @@ export default function AlertState({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alert-state" data-testid={testId}>
|
||||
<div className="alert-state">
|
||||
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -74,5 +72,4 @@ export default function AlertState({
|
||||
|
||||
AlertState.defaultProps = {
|
||||
showLabel: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<div className="tab-item">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<div className="tab-item">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
is hidden — the row stays a single crisp line and scrolls only when narrow. */
|
||||
.typeTabsScroll {
|
||||
justify-self: flex-end;
|
||||
--tab-list-wrapper-secondary-padding-left: 0;
|
||||
--tabs-list-wrapper-secondary-padding-left: 0;
|
||||
}
|
||||
|
||||
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
@@ -219,7 +220,9 @@ function BarPanelRenderer({
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
stack={
|
||||
spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
@@ -101,12 +100,6 @@ function addSeries({
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
|
||||
if (spec.visualization?.stackedBarChart) {
|
||||
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
|
||||
// `+1` keeps the band targets aligned with the series we're about to add.
|
||||
builder.setBands(getInitialStackedBands(series.length + 1));
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
@@ -13,8 +13,6 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -65,7 +63,6 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
@@ -23,10 +23,11 @@ var (
|
||||
const savedViewNameSuffixLen = 8
|
||||
|
||||
var (
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceAIObservability = Source{valuer.NewString("ai_observability")}
|
||||
)
|
||||
|
||||
type SavedView struct {
|
||||
@@ -117,12 +118,13 @@ func (Source) Enum() []any {
|
||||
SourceLogs,
|
||||
SourceMetrics,
|
||||
SourceMeter,
|
||||
SourceAIObservability,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Source) Validate() error {
|
||||
switch s {
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter:
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter, SourceAIObservability:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source: %s", s.StringValue())
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestSourceValidate(t *testing.T) {
|
||||
{name: "logs", source: SourceLogs},
|
||||
{name: "metrics", source: SourceMetrics},
|
||||
{name: "meter", source: SourceMeter},
|
||||
{name: "ai_observability", source: SourceAIObservability},
|
||||
{name: "unknown is rejected", source: Source{valuer.NewString("bogus")}, expectError: true},
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,21 @@ func TestSavedViewSpecValidate(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "builder_ai_query is valid",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeList,
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "graph panel query with no aggregation is still rejected",
|
||||
spec: SavedViewSpec{
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
import type { Browser } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createMetricAlertViaApi,
|
||||
createNoDataAlertViaApi,
|
||||
createTracesAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
setRuleDisabledViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import {
|
||||
readTimelineTotal,
|
||||
waitForTimelineEntries,
|
||||
waitForTimelineStates,
|
||||
} from '../../helpers/alerts/history';
|
||||
import {
|
||||
seedAlertHistoryLogs,
|
||||
seedAlertHistoryMetrics,
|
||||
seedAlertHistoryTraces,
|
||||
} from '../../helpers/alerts/seeding';
|
||||
import { expect, test as base, withAdminPage } from './alert-rules';
|
||||
import {
|
||||
FIXTURE_ALERT_HISTORY,
|
||||
FIXTURE_EMPTY_HISTORY,
|
||||
FIXTURE_METRICS_HISTORY,
|
||||
FIXTURE_NODATA_HISTORY,
|
||||
FIXTURE_RESOLVED_HISTORY,
|
||||
FIXTURE_TRACES_HISTORY,
|
||||
WAIT_METRICS_TIMELINE,
|
||||
WAIT_NODATA_TIMELINE,
|
||||
} from './timeouts';
|
||||
|
||||
// Worker-scoped alert-history fixtures. Extends `alert-rules`, so a spec that
|
||||
// imports `test` from here also gets `alertChannel` / `alertList` / `ownedRules`
|
||||
// — the details specs need a history seed *and* their own throwaway rules.
|
||||
//
|
||||
// Every history row has to come from the ruler actually evaluating a rule (there
|
||||
// is no seeder endpoint for `rule_state_history_v0`), so each fixture pays a
|
||||
// real ruler wait: ~20-35s for logs, ~10s for metrics, ~105s for firing→resolved.
|
||||
// Worker scope means one wait per worker instead of one per test, and Playwright
|
||||
// creates each fixture lazily — a spec that never asks for `resolvedHistory`
|
||||
// never pays its 105s.
|
||||
|
||||
/** Service count for `alertHistory`. 25 yields multi-page timeline + pagination tests. */
|
||||
const LOGS_HISTORY_SERVICES = 25;
|
||||
|
||||
/** Service count for `resolvedHistory`. 3 services + 1m window = resolves in ~105s. */
|
||||
const RESOLVED_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Hosts for `metricsHistory`. 2 rows fit one page, no related-logs links. */
|
||||
const METRICS_HISTORY_HOSTS = ['host-0', 'host-1'];
|
||||
|
||||
/** Service count for `tracesHistory`. 3 keeps wait short while proving traces link. */
|
||||
const TRACES_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Team label for the v1 rule in `alertHistory`, so its header labels row is non-empty. */
|
||||
export const V1_RULE_TEAM_LABEL = 'e2e-platform';
|
||||
|
||||
export interface AlertHistorySeed {
|
||||
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
|
||||
ruleId: string;
|
||||
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
|
||||
ruleIdV1: string;
|
||||
channelName: string;
|
||||
/** The `body CONTAINS` marker both rules match. */
|
||||
marker: string;
|
||||
/** The seeded `service.name` values, in creation order. */
|
||||
services: string[];
|
||||
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
|
||||
total: number;
|
||||
/** Baseline `total` for {@link ruleIdV1}. */
|
||||
totalV1: number;
|
||||
}
|
||||
|
||||
export interface MetricsHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
metricName: string;
|
||||
hosts: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TracesHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
/** The span `name` the rule matches (`name = '<marker>'`). */
|
||||
marker: string;
|
||||
services: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ResolvedHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
marker: string;
|
||||
services: string[];
|
||||
/** Rows in the `firing` state — equals `stats.totalCurrentTriggers`. */
|
||||
firingCount: number;
|
||||
/** Rows in the `inactive` state, i.e. what the `Resolved` filter shows. */
|
||||
resolvedCount: number;
|
||||
}
|
||||
|
||||
export interface NoDataHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
export interface EmptyHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
async function cleanup(
|
||||
browser: Browser,
|
||||
{ ruleIds, channelId }: { ruleIds: string[]; channelId?: string },
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ruleIds) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
if (channelId) {
|
||||
await deleteChannelViaApi(page, channelId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup functions ---
|
||||
|
||||
interface HistoryFixtureResult<T> {
|
||||
seed: T;
|
||||
ruleIds: string[];
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
async function createAlertHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<AlertHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert history ${stamp}`;
|
||||
|
||||
const result = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: LOGS_HISTORY_SERVICES,
|
||||
servicePrefix: `e2e-ah-svc`,
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v2-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v2',
|
||||
});
|
||||
const ruleIdV1 = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v1-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v1',
|
||||
extraLabels: { team: V1_RULE_TEAM_LABEL },
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: LOGS_HISTORY_SERVICES });
|
||||
await waitForTimelineEntries(page, ruleIdV1, { min: LOGS_HISTORY_SERVICES });
|
||||
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
await setRuleDisabledViaApi(page, ruleIdV1, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
ruleIdV1,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
totalV1: await readTimelineTotal(page, ruleIdV1),
|
||||
},
|
||||
ruleIds: [ruleId, ruleIdV1],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.seed.total !== LOGS_HISTORY_SERVICES) {
|
||||
throw new Error(
|
||||
`alertHistory expected ${LOGS_HISTORY_SERVICES} timeline rows, got ${result.seed.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createMetricsHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<MetricsHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const metricName = `e2e_ah_probe_metric_${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-metrics-ch-${stamp}`,
|
||||
);
|
||||
|
||||
await seedAlertHistoryMetrics(page, {
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
});
|
||||
|
||||
const ruleId = await createMetricAlertViaApi(page, {
|
||||
name: `e2e-ah-metrics-rule-${stamp}`,
|
||||
metricName,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: METRICS_HISTORY_HOSTS.length,
|
||||
timeoutMs: WAIT_METRICS_TIMELINE,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createTracesHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<TracesHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e-aht-span-${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-traces-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryTraces(page, {
|
||||
marker,
|
||||
services: TRACES_HISTORY_SERVICES,
|
||||
servicePrefix: 'e2e-aht-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createTracesAlertViaApi(page, {
|
||||
name: `e2e-ah-traces-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: TRACES_HISTORY_SERVICES });
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createResolvedHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<ResolvedHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert resolved ${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-resolved-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: RESOLVED_HISTORY_SERVICES,
|
||||
ageSeconds: 40,
|
||||
minAgeSeconds: 28,
|
||||
servicePrefix: 'e2e-ahr-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-resolved-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
evalWindow: '1m0s',
|
||||
});
|
||||
|
||||
const timeline = await waitForTimelineStates(page, ruleId, {
|
||||
states: {
|
||||
firing: RESOLVED_HISTORY_SERVICES,
|
||||
inactive: RESOLVED_HISTORY_SERVICES,
|
||||
},
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
|
||||
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createNoDataHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<NoDataHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-nodata-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createNoDataAlertViaApi(page, {
|
||||
name: `e2e-ah-nodata-rule-${stamp}`,
|
||||
marker: `e2e alert nodata ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: 1,
|
||||
state: 'nodata',
|
||||
timeoutMs: WAIT_NODATA_TIMELINE,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createEmptyHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<EmptyHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-empty-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-empty-rule-${stamp}`,
|
||||
marker: `e2e alert never seeded ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{},
|
||||
{
|
||||
alertHistory: AlertHistorySeed;
|
||||
metricsHistory: MetricsHistorySeed;
|
||||
tracesHistory: TracesHistorySeed;
|
||||
resolvedHistory: ResolvedHistorySeed;
|
||||
noDataHistory: NoDataHistorySeed;
|
||||
emptyHistory: EmptyHistorySeed;
|
||||
}
|
||||
>({
|
||||
alertHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createAlertHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_HISTORY },
|
||||
],
|
||||
|
||||
metricsHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createMetricsHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_METRICS_HISTORY },
|
||||
],
|
||||
|
||||
tracesHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createTracesHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_TRACES_HISTORY },
|
||||
],
|
||||
|
||||
resolvedHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } =
|
||||
await createResolvedHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_RESOLVED_HISTORY },
|
||||
],
|
||||
|
||||
noDataHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createNoDataHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_NODATA_HISTORY },
|
||||
],
|
||||
|
||||
emptyHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createEmptyHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_EMPTY_HISTORY },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,245 +0,0 @@
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import { seedAlertRules } from '../../helpers/alerts/seeding';
|
||||
import type {
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
ThresholdAlertSeed,
|
||||
} from '../../helpers/alerts/types';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { expect, test as base } from '../auth';
|
||||
import { FIXTURE_ALERT_LIST } from './timeouts';
|
||||
|
||||
// Alert *rule* fixtures — the API-only half of the alerts suite. Nothing here
|
||||
// waits on the ruler: a rule is created and that's it. History rows need real
|
||||
// evaluations, so those fixtures live in `alert-history.ts`, which
|
||||
// extends this module — a spec importing from there gets both sets.
|
||||
//
|
||||
// Scopes, and why:
|
||||
// `alertChannel` — worker. Every rule payload has to reference a channel by
|
||||
// name, and one channel serves the whole worker.
|
||||
// `alertList` — worker. Read-only rule list the `tests/alerts/list` specs
|
||||
// page, search and sort through. Names and label values are stamped per
|
||||
// worker so parallel batches never count each other's rules.
|
||||
// `ownedRules` — test. Scenarios that rename/toggle/clone/delete a rule seed
|
||||
// their own and have it removed when they finish; mutating a shared seed
|
||||
// would break every scenario scheduled after it.
|
||||
|
||||
export interface AlertChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AlertListSeed {
|
||||
channelName: string;
|
||||
/** Rules are named `<namePrefix>-NN` — unique to this worker's batch. */
|
||||
namePrefix: string;
|
||||
/** Rules seeded ⇒ the `of N` total once the list is scoped to the prefix. */
|
||||
count: number;
|
||||
/** `team` label on the odd-indexed half of the batch, i.e. `count / 2` rules. */
|
||||
paymentsLabel: string;
|
||||
ruleIds: string[];
|
||||
}
|
||||
|
||||
export interface OwnedRules {
|
||||
/** Seed a metric threshold rule this test owns. */
|
||||
threshold(
|
||||
name: string,
|
||||
overrides?: Partial<Omit<ThresholdAlertSeed, 'name'>>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
|
||||
* it never fires — enough for anything about the details shell.
|
||||
*
|
||||
* `schema: 'v1'` posts the legacy payload; the condition overrides exist so
|
||||
* a v1 *prefill* assertion can be made against values the create form would
|
||||
* not have produced by itself.
|
||||
*/
|
||||
logs(
|
||||
options: {
|
||||
name: string;
|
||||
schema?: AlertSchema;
|
||||
marker?: string;
|
||||
} & Partial<
|
||||
Pick<
|
||||
LogsAlertSeed,
|
||||
'severity' | 'extraLabels' | 'evalWindow' | 'target' | 'op' | 'matchType'
|
||||
>
|
||||
>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Track a rule the *app* created (Clone / Duplicate) so teardown removes it
|
||||
* too. Lives here because the id may legitimately be missing and a
|
||||
* conditional inside a test body is a lint error.
|
||||
*/
|
||||
register(response: { json: () => Promise<unknown> }): Promise<void>;
|
||||
}
|
||||
|
||||
/** alertList size. 12 over page size 10 = short second page for pagination tests. */
|
||||
const LIST_SEED_COUNT = 12;
|
||||
|
||||
/**
|
||||
* Run `body` on a throwaway admin page. Worker hooks can't use the test-scoped
|
||||
* `authedPage`, and every API helper needs a page whose context carries the
|
||||
* admin storage state.
|
||||
*/
|
||||
export async function withAdminPage<T>(
|
||||
browser: Browser,
|
||||
body: (page: Page) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
return await body(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRules(browser: Browser, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ids) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup/teardown functions ---
|
||||
|
||||
async function createAlertChannel(
|
||||
browser: Browser,
|
||||
workerIndex: number,
|
||||
): Promise<AlertChannel> {
|
||||
return withAdminPage(browser, (page) =>
|
||||
createEmailChannelViaApi(page, `e2e-alerts-ch-w${workerIndex}-${Date.now()}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteAlertChannel(
|
||||
browser: Browser,
|
||||
channelId: string,
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, (page) => deleteChannelViaApi(page, channelId));
|
||||
}
|
||||
|
||||
async function createAlertList(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
workerIndex: number,
|
||||
): Promise<AlertListSeed> {
|
||||
const stamp = `w${workerIndex}-${Date.now()}`;
|
||||
const namePrefix = `e2e-alert-list-${stamp}`;
|
||||
const teamSuffix = `-${stamp}`;
|
||||
|
||||
const ruleIds = await withAdminPage(browser, (page) =>
|
||||
seedAlertRules(page, {
|
||||
count: LIST_SEED_COUNT,
|
||||
channelName,
|
||||
namePrefix,
|
||||
teamSuffix,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
channelName,
|
||||
namePrefix,
|
||||
count: LIST_SEED_COUNT,
|
||||
paymentsLabel: `payments${teamSuffix}`,
|
||||
ruleIds,
|
||||
};
|
||||
}
|
||||
|
||||
function createOwnedRulesFactory(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
ids: Set<string>,
|
||||
): OwnedRules {
|
||||
const seed = async (
|
||||
create: (page: Page) => Promise<string>,
|
||||
): Promise<string> => {
|
||||
const id = await withAdminPage(browser, create);
|
||||
ids.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
return {
|
||||
threshold: (name, overrides = {}) =>
|
||||
seed((page) =>
|
||||
createThresholdAlertViaApi(page, {
|
||||
name,
|
||||
target: 42,
|
||||
channels: [channelName],
|
||||
labels: { severity: 'critical' },
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
|
||||
seed((page) =>
|
||||
createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker: marker ?? `e2e alert never seeded ${name}`,
|
||||
channels: [channelName],
|
||||
schema,
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
register: async (response) => {
|
||||
const body = (await response.json()) as { data?: { id?: string } };
|
||||
const id = body.data?.id;
|
||||
if (id) {
|
||||
ids.add(String(id));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
{ ownedRules: OwnedRules },
|
||||
{ alertChannel: AlertChannel; alertList: AlertListSeed }
|
||||
>({
|
||||
alertChannel: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const channel = await createAlertChannel(browser, workerInfo.workerIndex);
|
||||
await use(channel);
|
||||
await deleteAlertChannel(browser, channel.id);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
alertList: [
|
||||
async ({ browser, alertChannel }, use, workerInfo) => {
|
||||
const seed = await createAlertList(
|
||||
browser,
|
||||
alertChannel.name,
|
||||
workerInfo.workerIndex,
|
||||
);
|
||||
await use(seed);
|
||||
await deleteRules(browser, seed.ruleIds);
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_LIST },
|
||||
],
|
||||
|
||||
ownedRules: async ({ browser, alertChannel }, use) => {
|
||||
const ids = new Set<string>();
|
||||
const factory = createOwnedRulesFactory(browser, alertChannel.name, ids);
|
||||
await use(factory);
|
||||
await deleteRules(browser, [...ids]);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Centralized timeout constants for alert fixtures.
|
||||
*
|
||||
* **Ruler**: SigNoz's alert evaluation engine. Runs on ~15s cycles, checks each
|
||||
* rule's query against ClickHouse, writes results to `rule_state_history_v0`.
|
||||
* There's no API to force-evaluate or seed history directly, so fixtures must
|
||||
* poll the timeline endpoint until the ruler writes rows.
|
||||
*
|
||||
* Fixture timeouts are set generously because:
|
||||
* 1. CI environments are slower than local dev machines
|
||||
* 2. Ruler evaluation depends on Kafka/ClickHouse latency
|
||||
* 3. A timeout should mean "something is broken", not "it's just slow today"
|
||||
*
|
||||
* Typical measured times (local):
|
||||
* - API call (create/delete rule/channel): ~1-2s
|
||||
* - waitForTimelineEntries (logs, 25 services): ~20-35s
|
||||
* - waitForTimelineEntries (metrics, 2 hosts): ~10s
|
||||
* - waitForTimelineStates (firing→resolved): ~105s
|
||||
* - waitForTimelineEntries (nodata state): ~60-120s
|
||||
*/
|
||||
|
||||
// ─── Fixture-specific wait overrides (ms) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Metrics history wait override. Metrics push faster than logs, but ruler still
|
||||
* needs 2 evaluation windows to confirm state. 10s typical, 120s defensive.
|
||||
*/
|
||||
export const WAIT_METRICS_TIMELINE = 120_000;
|
||||
|
||||
/**
|
||||
* Nodata state detection. Ruler must evaluate twice with empty result set.
|
||||
* Takes longer than firing detection because it's an absence check.
|
||||
*/
|
||||
export const WAIT_NODATA_TIMELINE = 180_000;
|
||||
|
||||
// ─── Fixture timeouts (ms) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* alertList: seeds 12 rules via API.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + 12×createRule(24s) = ~26s.
|
||||
* Timeout: 120s (~5x headroom for CI).
|
||||
*/
|
||||
export const FIXTURE_ALERT_LIST = 120_000;
|
||||
|
||||
/**
|
||||
* alertHistory: seeds 25 logs + 2 rules, waits for ruler evaluation.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedLogs(10s) + 2×createRule(4s) +
|
||||
* 2×waitForEntries(70s) + 2×disableRule(4s) = ~90s.
|
||||
* Timeout: 240s (~2.5x headroom).
|
||||
*/
|
||||
export const FIXTURE_ALERT_HISTORY = 240_000;
|
||||
|
||||
/**
|
||||
* metricsHistory: seeds 2 hosts, waits for metrics ruler cycle.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedMetrics(5s) + createRule(2s) +
|
||||
* waitForEntries(10s actual, 120s budget) + disableRule(2s) = ~21s.
|
||||
* Timeout: 240s (matches alertHistory for consistency).
|
||||
*/
|
||||
export const FIXTURE_METRICS_HISTORY = 240_000;
|
||||
|
||||
/**
|
||||
* tracesHistory: seeds 3 trace services, waits for ruler.
|
||||
*
|
||||
* Breakdown: similar to alertHistory but fewer services = ~45s.
|
||||
* Timeout: 240s (~5x headroom).
|
||||
*/
|
||||
export const FIXTURE_TRACES_HISTORY = 240_000;
|
||||
|
||||
/**
|
||||
* resolvedHistory: waits for firing→resolved transition.
|
||||
*
|
||||
* Breakdown: setup(30s) + waitForStates(105s) = ~135s.
|
||||
* Timeout: 300s (~2x headroom). Longest because resolved requires
|
||||
* evalWindow expiry after data stops matching.
|
||||
*/
|
||||
export const FIXTURE_RESOLVED_HISTORY = 300_000;
|
||||
|
||||
/**
|
||||
* noDataHistory: waits for nodata state to appear.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + waitForEntries(60-120s).
|
||||
* Timeout: 300s. Nodata detection is slowest because ruler must confirm
|
||||
* absence across multiple evaluation cycles.
|
||||
*/
|
||||
export const FIXTURE_NODATA_HISTORY = 300_000;
|
||||
|
||||
/**
|
||||
* emptyHistory: creates rule then immediately disables it (no ruler wait).
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + disableRule(2s) = ~6s.
|
||||
* Timeout: 120s (generous for slow CI, no ruler dependency).
|
||||
*/
|
||||
export const FIXTURE_EMPTY_HISTORY = 120_000;
|
||||
@@ -1,11 +1,81 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
test as base,
|
||||
expect,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
|
||||
// worker-scoped fixtures and suite hooks share one login with this fixture.
|
||||
export { ADMIN };
|
||||
export type { User };
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
export const ADMIN: User = {
|
||||
email: process.env.SIGNOZ_E2E_USERNAME!,
|
||||
password: process.env.SIGNOZ_E2E_PASSWORD!,
|
||||
};
|
||||
|
||||
// Per-worker storageState cache. One login per unique user per worker.
|
||||
// Promise-valued so concurrent requests share the same in-flight work.
|
||||
// Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await login(page, user);
|
||||
await pinSidenav(page);
|
||||
const state = await ctx.storageState();
|
||||
await ctx.close();
|
||||
return state;
|
||||
})();
|
||||
|
||||
storageByUser.set(user.email, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function login(page: Page, user: User): Promise<void> {
|
||||
if (!user.email || !user.password) {
|
||||
throw new Error(
|
||||
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
|
||||
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
|
||||
);
|
||||
}
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(user.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
// Post-login lands somewhere different depending on whether the org is
|
||||
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
|
||||
// welcome). Wait for URL to move off /login — whichever page follows
|
||||
// is fine, each spec navigates to the feature under test anyway.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
}
|
||||
|
||||
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
|
||||
// Server-side pref, so set once per user at login.
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
/**
|
||||
@@ -25,7 +95,7 @@ export const test = base.extend<{
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
const storageState = await storageStateFor(browser, user);
|
||||
const storageState = await storageFor(browser, user);
|
||||
const ctx = await browser.newContext({ storageState });
|
||||
const page = await ctx.newPage();
|
||||
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// ─── Routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_NEW_PATH = '/alerts/new';
|
||||
|
||||
/**
|
||||
* The standalone edit route. Distinct from `/alerts/overview`, which renders the
|
||||
* *same* editor inside the details shell. The two are not interchangeable for v2
|
||||
* rules — see `edit/v2.spec.ts` EV2-12.
|
||||
*/
|
||||
export const ALERT_EDIT_PATH = '/alerts/edit';
|
||||
|
||||
// ─── Enums mirrored from the frontend ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* URL values of `AlertTypes` (`frontend/src/types/api/alerts/alertTypes.ts`).
|
||||
* Note `METRICS` maps to the *singular* `METRIC_BASED_ALERT` — the enum key and
|
||||
* its value disagree in the source, and the URL carries the value.
|
||||
*/
|
||||
export const AlertType = {
|
||||
METRICS: 'METRIC_BASED_ALERT',
|
||||
LOGS: 'LOGS_BASED_ALERT',
|
||||
TRACES: 'TRACES_BASED_ALERT',
|
||||
EXCEPTIONS: 'EXCEPTIONS_BASED_ALERT',
|
||||
ANOMALY: 'ANOMALY_BASED_ALERT',
|
||||
} as const;
|
||||
|
||||
export type AlertTypeValue = (typeof AlertType)[keyof typeof AlertType];
|
||||
|
||||
/** `AlertDetectionTypes` (`frontend/src/container/FormAlertRules/index.tsx:78-81`). */
|
||||
export const RuleType = {
|
||||
THRESHOLD: 'threshold_rule',
|
||||
ANOMALY: 'anomaly_rule',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdOperator` (`CreateAlertV2/context/types.ts:97-105`) and its
|
||||
* dropdown labels (`context/constants.ts:123-137`).
|
||||
*
|
||||
* Threshold-alert operators only. Anomaly alerts render a different, shorter
|
||||
* list (`ANOMALY_THRESHOLD_OPERATOR_OPTIONS`) with relabelled entries.
|
||||
*/
|
||||
export const ThresholdOperator = {
|
||||
ABOVE: { value: 'above', label: 'ABOVE' },
|
||||
BELOW: { value: 'below', label: 'BELOW' },
|
||||
EQUAL_TO: { value: 'equal', label: 'EQUAL TO' },
|
||||
NOT_EQUAL_TO: { value: 'not_equal', label: 'NOT EQUAL TO' },
|
||||
ABOVE_OR_EQUAL_TO: { value: 'above_or_equal', label: 'ABOVE OR EQUAL TO' },
|
||||
BELOW_OR_EQUAL_TO: { value: 'below_or_equal', label: 'BELOW OR EQUAL TO' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdMatchType` (`CreateAlertV2/context/types.ts:105-111`) and its
|
||||
* dropdown labels (`context/constants.ts:136-142`).
|
||||
*
|
||||
* Watch the plural: the enum *key* is `ALL_THE_TIME` but the wire value is
|
||||
* `all_the_times`, and the API rejects the singular outright — the same
|
||||
* key/value mismatch as `METRICS_BASED_ALERT` → `METRIC_BASED_ALERT`.
|
||||
*/
|
||||
export const ThresholdMatchType = {
|
||||
AT_LEAST_ONCE: { value: 'at_least_once', label: 'AT LEAST ONCE' },
|
||||
ALL_THE_TIME: { value: 'all_the_times', label: 'ALL THE TIME' },
|
||||
ON_AVERAGE: { value: 'on_average', label: 'ON AVERAGE' },
|
||||
IN_TOTAL: { value: 'in_total', label: 'IN TOTAL' },
|
||||
LAST: { value: 'last', label: 'LAST' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `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.
|
||||
*/
|
||||
export const AlertListTab = {
|
||||
TRIGGERED_ALERTS: 'TriggeredAlerts',
|
||||
ALERT_RULES: 'AlertRules',
|
||||
CONFIGURATION: 'Configuration',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The four cards a stock stack shows, in render order
|
||||
* (`CreateAlertRule/SelectAlertType/config.ts:10-31`). Anomaly is `unshift`ed to
|
||||
* the **front** of this list when the `ANOMALY_DETECTION` feature flag is active,
|
||||
* so both the count and the order change when it is enabled.
|
||||
*/
|
||||
export const STOCK_ALERT_TYPE_CARDS: AlertTypeValue[] = [
|
||||
AlertType.METRICS,
|
||||
AlertType.LOGS,
|
||||
AlertType.TRACES,
|
||||
AlertType.EXCEPTIONS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Rolling-window presets (`EvaluationSettings/constants.ts:9-18`) paired with the
|
||||
* button label each one produces. A value *outside* this set collapses to `custom`
|
||||
* on load (`utils.tsx:86-96`), which is what makes it a prefill assertion worth
|
||||
* having: `10m0s` proves the seed was read, `7m0s` proves the fallback fired.
|
||||
*/
|
||||
export const EVALUATION_WINDOW_PRESETS = {
|
||||
'5m0s': 'Last 5 minutes',
|
||||
'10m0s': 'Last 10 minutes',
|
||||
'15m0s': 'Last 15 minutes',
|
||||
'30m0s': 'Last 30 minutes',
|
||||
'1h0m0s': 'Last 1 hour',
|
||||
'2h0m0s': 'Last 2 hours',
|
||||
'4h0m0s': 'Last 4 hours',
|
||||
} as const;
|
||||
@@ -1,118 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
ALERTS_NEW_PATH,
|
||||
AlertType,
|
||||
type AlertTypeValue,
|
||||
RuleType,
|
||||
STOCK_ALERT_TYPE_CARDS,
|
||||
} from './constants';
|
||||
import { v1SaveButton } from './v1';
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the bare type-selection page. `isTypeSelectionMode` is
|
||||
* `!alertType && !ruleType && !compositeQuery`
|
||||
* (`container/CreateAlertRule/index.tsx:39-41`), so *any* of those three params
|
||||
* skips this page — including a stale `compositeQuery` left in the URL.
|
||||
*/
|
||||
export async function gotoAlertTypeSelection(page: Page): Promise<void> {
|
||||
await page.goto(ALERTS_NEW_PATH);
|
||||
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
|
||||
}
|
||||
|
||||
export function alertTypeCard(page: Page, type: AlertTypeValue): Locator {
|
||||
return page.getByTestId(`alert-type-card-${type}`);
|
||||
}
|
||||
|
||||
function alertTypeCards(page: Page): Locator {
|
||||
return page.locator('[data-testid^="alert-type-card-"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the anomaly card is on the page, i.e. whether `ANOMALY_DETECTION` is
|
||||
* active for this stack. It **is** active on the pytest-bootstrapped integration
|
||||
* stack, so every card-count assertion has to branch on it rather than hard-code
|
||||
* 4.
|
||||
*/
|
||||
export async function hasAnomalyAlertTypeCard(page: Page): Promise<boolean> {
|
||||
return (await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the type-selection page shows exactly the expected set of cards: the
|
||||
* four stock ones, plus anomaly *first* when the flag is on (`getOptionList`
|
||||
* `unshift`s it, `SelectAlertType/config.ts:33-40`).
|
||||
*
|
||||
* Written as an exact set rather than "at least four" so that adding a fifth
|
||||
* signal still fails this assertion — the flag branch is the only slack.
|
||||
*/
|
||||
export async function expectAlertTypeCardSet(page: Page): Promise<void> {
|
||||
const anomaly = await hasAnomalyAlertTypeCard(page);
|
||||
const expected = anomaly
|
||||
? [AlertType.ANOMALY, ...STOCK_ALERT_TYPE_CARDS]
|
||||
: STOCK_ALERT_TYPE_CARDS;
|
||||
|
||||
const cards = alertTypeCards(page);
|
||||
await expect(cards).toHaveCount(expected.length);
|
||||
|
||||
// Read the testids positionally so order is asserted too — anomaly being
|
||||
// unshifted rather than appended is the behaviour worth pinning.
|
||||
const rendered: (string | null)[] = [];
|
||||
for (let i = 0; i < expected.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
rendered.push(await cards.nth(i).getAttribute('data-testid'));
|
||||
}
|
||||
expect(rendered).toEqual(expected.map((type) => `alert-type-card-${type}`));
|
||||
}
|
||||
|
||||
export interface CreateAlertUrlOptions {
|
||||
alertType?: AlertTypeValue;
|
||||
ruleType?: string;
|
||||
/** Sets `showClassicCreateAlertsPage=true` ⇒ the v1 classic form. */
|
||||
classic?: boolean;
|
||||
/** Merged in last, so it can override anything above. */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function createAlertUrl({
|
||||
alertType = AlertType.LOGS,
|
||||
ruleType = RuleType.THRESHOLD,
|
||||
classic = false,
|
||||
params = {},
|
||||
}: CreateAlertUrlOptions = {}): string {
|
||||
const search = new URLSearchParams({ alertType, ruleType });
|
||||
if (classic) {
|
||||
search.set('showClassicCreateAlertsPage', 'true');
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
search.set(key, value);
|
||||
}
|
||||
return `${ALERTS_NEW_PATH}?${search.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the **v2** builder and wait until it has settled. The wait is two-part on
|
||||
* purpose: the header proves the builder mounted, and the `compositeQuery` in the
|
||||
* URL proves `useShareBuilderUrl` has finished serialising the default query —
|
||||
* without the second half, an assertion on the URL races the builder's own
|
||||
* rewrite (the same trap `gotoAlertOverview` documents).
|
||||
*/
|
||||
export async function gotoCreateAlertV2(
|
||||
page: Page,
|
||||
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
|
||||
): Promise<void> {
|
||||
await page.goto(createAlertUrl({ ...options, classic: false }));
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Open the **v1** classic create form and wait for its primary action. */
|
||||
export async function gotoCreateAlertV1(
|
||||
page: Page,
|
||||
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
|
||||
): Promise<void> {
|
||||
await page.goto(createAlertUrl({ ...options, classic: true }));
|
||||
await expect(v1SaveButton(page)).toBeVisible();
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// ─── Antd select helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read an antd multi-select's chosen values, for asserting what a threshold row
|
||||
* ended up pointing at.
|
||||
*/
|
||||
export function selectedTags(scope: Locator): Locator {
|
||||
return scope.locator('.ant-select-selection-item-content');
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently-open antd dropdown. Scoping option lookups to it matters because
|
||||
* antd keeps previously-opened dropdowns in the DOM with
|
||||
* `.ant-select-dropdown-hidden`, so an unscoped `.ant-select-item-option` can
|
||||
* resolve into a stale list.
|
||||
*
|
||||
* Adequate when only one select is ever open on the page. When several selects of
|
||||
* the *same kind* exist — the per-threshold channel selects — use
|
||||
* {@link ownDropdown} instead: `-hidden` is applied only after the close
|
||||
* transition, so "the open dropdown" is briefly ambiguous.
|
||||
*/
|
||||
export function openDropdown(page: Page): Locator {
|
||||
return page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropdown belonging to one specific antd select, resolved through the
|
||||
* combobox's `aria-controls` → the listbox id it owns.
|
||||
*
|
||||
* This is the only unambiguous way to address one of several sibling selects'
|
||||
* option lists. Filtering on "the visible dropdown" is not enough: with four
|
||||
* threshold rows, row N's list is still mid-close while row N+1's opens, so the
|
||||
* option lookup lands in the wrong list and the click fails with "element is not
|
||||
* stable" and then "element is not visible".
|
||||
*/
|
||||
export async function ownDropdown(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
): Promise<Locator> {
|
||||
const listId = await select
|
||||
.locator('input[role="combobox"]')
|
||||
.getAttribute('aria-controls');
|
||||
if (!listId) {
|
||||
throw new Error(
|
||||
'select has no aria-controls — not an antd combobox, or not yet opened',
|
||||
);
|
||||
}
|
||||
return page
|
||||
.locator('.ant-select-dropdown')
|
||||
.filter({ has: page.locator(`[id="${listId}"]`) });
|
||||
}
|
||||
|
||||
/**
|
||||
* An option in the open dropdown, matched on its **exact** label. Substring
|
||||
* matching is wrong here: `hasText: 'EQUAL TO'` also matches `NOT EQUAL TO`.
|
||||
*/
|
||||
export function dropdownOption(page: Page, label: string): Locator {
|
||||
return openDropdown(page)
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ has: page.getByText(label, { exact: true }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a notification channel by exact name in one of the two channel selects —
|
||||
* v2's per-threshold one and v1's single `alert-channel-select`. Both are
|
||||
* `mode="multiple"` antd selects over the *same* global channel list, so both need
|
||||
* exactly this sequence; the shared body is why this is one function rather than
|
||||
* two near-copies.
|
||||
*
|
||||
* The list must be **searched**, not scrolled. Channels are global while the
|
||||
* `alertChannel` fixture is worker-scoped, so a shared stack accumulates one
|
||||
* channel per worker (plus anything a killed run leaked) and antd virtualises the
|
||||
* dropdown: measured on this stack, 31 channels render **10** options into the DOM,
|
||||
* and the wanted one is simply not there. Clicking by name without filtering first
|
||||
* is therefore not a slow path, it is a missing element — and it was the single
|
||||
* biggest source of flake in this suite. It fails as a plain click timeout
|
||||
* ("waiting for locator … .ant-select-item-option …"), which reads like a renamed
|
||||
* testid rather than a virtualised list.
|
||||
*/
|
||||
export async function pickChannelByName(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
const tagsBefore = await selectedTags(select).count();
|
||||
await select.click();
|
||||
await expect(select).toHaveClass(/ant-select-open/);
|
||||
|
||||
// `fill` on the combobox input rather than `keyboard.type`: the query is a ~30
|
||||
// character channel name and every keystroke re-runs antd's filter, so typing it
|
||||
// costs ~2.5 s per pick — CV2-09 makes four of them, which was a quarter of that
|
||||
// test's 30 s budget. `fill` sets the value in one input event, which is all
|
||||
// rc-select's search needs.
|
||||
await select.locator('input[role="combobox"]').fill(channelName);
|
||||
const dropdown = await ownDropdown(page, select);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: channelName })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// A multi-select stays open after a pick and its dropdown overlays the controls
|
||||
// below, which the next interaction would otherwise hit instead.
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Wait for *this* select to report itself closed before returning. antd removes
|
||||
// `.ant-select-dropdown-hidden` only after the close transition, so a caller that
|
||||
// immediately opens the next row's select races a still-visible stale list: the
|
||||
// option lookup then resolves inside the previous row's dropdown and the click
|
||||
// fails with "element is not stable" followed by "element is not visible".
|
||||
await expect(select).not.toHaveClass(/ant-select-open/);
|
||||
|
||||
// Fail here rather than three assertions later: a silently-missed pick shows up
|
||||
// as "Save is still disabled", which points at the validator instead of at this.
|
||||
//
|
||||
// Counted, not name-matched: v2's select sets `maxTagTextLength={10}`
|
||||
// (`ThresholdItem.tsx:140`) so its tag reads `e2e-alerts…`, and v1's passes
|
||||
// `optionLabelProp="label"` to options that carry no `label` prop, so its tag
|
||||
// renders empty. Neither can ever contain the full channel name. The name itself
|
||||
// is verified where it actually matters — in the request body (CV2-20, CV1-08).
|
||||
await expect(selectedTags(select)).toHaveCount(tagsBefore + 1);
|
||||
}
|
||||
|
||||
// ─── SEED-CH1: a stack with no notification channels ───────────────────────
|
||||
|
||||
/**
|
||||
* Route-stub `GET /api/v1/channels` to an empty list for this page only.
|
||||
*
|
||||
* This is the **one** place the alerts suite mocks the network, and it is a
|
||||
* deliberate exception to the standing no-stubbing rule. The justification: zero
|
||||
* channels is a real product state — every fresh install has it — and it is the
|
||||
* only state that reaches the `disabled` broadcast switch and
|
||||
* the empty-channel dropdown content. It cannot be produced server-side, because
|
||||
* `alertChannel` is worker-scoped and parallel workers share one stack, so
|
||||
* deleting the channel would break every other scenario running at that moment.
|
||||
*
|
||||
* Both forms read the same endpoint through `api/channels/getAll`, so one stub
|
||||
* covers v1 and v2.
|
||||
*/
|
||||
export async function stubNoChannels(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/channels', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'success', data: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The v1 primary action. Its *label* is mode-dependent — *Create Rule* when
|
||||
* `isNewRule`, *Save Rule* when editing (`FormAlertRules/index.tsx:970`) — so
|
||||
* scenarios that care about the mode assert the text; the locator itself does not.
|
||||
*/
|
||||
export function v1SaveButton(page: Page): Locator {
|
||||
return page.getByTestId('alert-save-button');
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { EVALUATION_WINDOW_PRESETS } from './constants';
|
||||
import { pickChannelByName } from './shared';
|
||||
|
||||
// ─── v2 builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Footer buttons. The disabled Save/Test buttons are wrapped in a `<span>` inside
|
||||
* an antd `Tooltip` (`CreateAlertV2/Footer/Footer.tsx:198-204`) — the wrapper is
|
||||
* why {@link v2SaveTooltip} exists instead of reading a `title` attribute, and
|
||||
* why these are testids rather than accessible names: the name lookup also
|
||||
* matched the wrapper in some states.
|
||||
*/
|
||||
export function v2SaveButton(page: Page): Locator {
|
||||
return page.getByTestId('save-alert-rule-button');
|
||||
}
|
||||
|
||||
export function v2TestButton(page: Page): Locator {
|
||||
return page.getByTestId('test-notification-button');
|
||||
}
|
||||
|
||||
export function v2DiscardButton(page: Page): Locator {
|
||||
return page.getByTestId('discard-alert-rule-button');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the v2 Discard button — via `dispatchEvent`, because a real click cannot
|
||||
* reach it.
|
||||
*
|
||||
* The footer is `position: fixed; left: 63px` (the *collapsed* nav rail width) and
|
||||
* Discard is its left-most control, so the button occupies roughly x 75-170 at the
|
||||
* bottom of the viewport. The side navigation occupies x 0-240 whenever it is
|
||||
* 240px wide, which is: always when pinned — the default — and transiently when
|
||||
* not pinned, because a mouse travelling toward the button crosses the rail and
|
||||
* triggers `:not(.pinned).is-hovered`. Either way `document.elementFromPoint` at
|
||||
* the button's centre returns the nav's `.nav-item-data`, so the nav swallows the
|
||||
* click.
|
||||
*
|
||||
* `{ force: true }` does **not** help: it skips Playwright's actionability wait
|
||||
* but still delivers a real mouse event at those coordinates, which the nav
|
||||
* receives. `dispatchEvent('click')` bypasses hit-testing entirely and React's
|
||||
* delegated handler fires normally — verified: the page navigates to `/alerts`.
|
||||
*
|
||||
* This is a workaround for a **product** bug, not for a flaky test.
|
||||
* `create/edge.spec.ts` CE-09 is the skipped scenario that asserts the fixed
|
||||
* behaviour; unskipping it and reverting this helper to `.click()` belong in the
|
||||
* same commit as the fix.
|
||||
*/
|
||||
export async function v2ClickDiscard(page: Page): Promise<void> {
|
||||
await v2DiscardButton(page).dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the side navigation currently overlaps a point — the mechanism behind
|
||||
* {@link v2ClickDiscard}. Used by CE-09, which asserts the *absence* of that
|
||||
* overlap and is skipped until the footer is fixed.
|
||||
*/
|
||||
export async function elementAtPointClassName(
|
||||
page: Page,
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<string> {
|
||||
return page.evaluate(
|
||||
([px, py]) => {
|
||||
const el = document.elementFromPoint(px as number, py as number);
|
||||
return el ? String(el.className) : '';
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the (disabled) Save button and return the antd tooltip's text — this is
|
||||
* the only way to read `validateCreateAlertState`'s message, since the button
|
||||
* cannot be clicked while a message exists.
|
||||
*/
|
||||
export async function v2SaveTooltip(page: Page): Promise<string> {
|
||||
// The tooltip anchors to the wrapper span, not the disabled button: a disabled
|
||||
// button emits no pointer events, so hovering it directly never opens.
|
||||
await v2SaveButton(page).locator('xpath=..').hover();
|
||||
const tooltip = page.locator('.ant-tooltip-inner').first();
|
||||
await expect(tooltip).toBeVisible();
|
||||
return (await tooltip.innerText()).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold rows. There is **no** `threshold-item-<id>` testid — the row is a bare
|
||||
* `className="threshold-item"` (`AlertCondition/ThresholdItem.tsx`), so rows are
|
||||
* addressed positionally.
|
||||
*/
|
||||
export function thresholdRows(page: Page): Locator {
|
||||
return page.locator('.threshold-item');
|
||||
}
|
||||
|
||||
export function thresholdRow(page: Page, index: number): Locator {
|
||||
return thresholdRows(page).nth(index);
|
||||
}
|
||||
|
||||
/** Assign a notification channel to the Nth v2 threshold. */
|
||||
export async function selectThresholdChannel(
|
||||
page: Page,
|
||||
index: number,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
await pickChannelByName(
|
||||
page,
|
||||
page.getByTestId('threshold-notification-channel-select').nth(index),
|
||||
channelName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a label through the v2 header editor. The input is a single field with two
|
||||
* phases — key, then value, each committed with Enter
|
||||
* (`CreateAlertHeader/LabelsInput.tsx:25-93`) — and a `key:value` string in the
|
||||
* first phase is accepted as a shortcut. This helper drives the two-phase path
|
||||
* because that is what a user does.
|
||||
*/
|
||||
export async function addAlertLabel(
|
||||
page: Page,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('alert-add-label-button').click();
|
||||
const input = page.getByTestId('alert-add-label-input');
|
||||
await input.fill(key);
|
||||
await input.press('Enter');
|
||||
await input.fill(value);
|
||||
await input.press('Enter');
|
||||
|
||||
// Committing a label does *not* close the editor — `isAdding` stays true so a
|
||||
// user can type several in a row, which means `alert-add-label-button` is still
|
||||
// unmounted. Escape (with both fields empty) is what closes it, and without this
|
||||
// a second call to this helper waits forever for the add button.
|
||||
await input.press('Escape');
|
||||
await expect(page.getByTestId('alert-add-label-button')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle inside an `AdvancedOptionItem` (repeat notifications, send-if-missing,
|
||||
* enforce-minimum-datapoints). The `Switch` there carries no testid of its own, so
|
||||
* it is reached through the container's — hence the container testid being the
|
||||
* documented handle rather than the switch.
|
||||
*/
|
||||
export function advancedOptionToggle(
|
||||
page: Page,
|
||||
containerTestId: string,
|
||||
): Locator {
|
||||
return page.getByTestId(containerTestId).locator('[role="switch"]');
|
||||
}
|
||||
|
||||
// ─── Evaluation window + cadence ───────────────────────────────────────────
|
||||
|
||||
export function evaluationSettingsButton(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-settings-button');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the evaluation-window popover. It is an antd `Popover`, so its content is
|
||||
* only in the DOM while open — every option lookup has to come after this.
|
||||
*/
|
||||
export async function openEvaluationSettings(page: Page): Promise<void> {
|
||||
await evaluationSettingsButton(page).click();
|
||||
await expect(page.locator('.evaluation-window-popover')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* A popover option. The popover renders two lists from one component, keyed by
|
||||
* `data-section-id` — `window-type` (Rolling / Cumulative) and `timeframe` — and
|
||||
* the testid carries both, so `timeframe-option-10m0s` cannot collide with a
|
||||
* window-type value.
|
||||
*/
|
||||
export function evaluationWindowOption(
|
||||
page: Page,
|
||||
section: 'window-type' | 'timeframe',
|
||||
value: string,
|
||||
): Locator {
|
||||
return page.getByTestId(`${section}-option-${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a rolling timeframe and wait for the trigger button to reflect it. The wait
|
||||
* matters: the popover closes on its own animation, and a spec that immediately
|
||||
* clicks Save can otherwise post the previous window.
|
||||
*/
|
||||
export async function selectEvaluationTimeframe(
|
||||
page: Page,
|
||||
value: keyof typeof EVALUATION_WINDOW_PRESETS,
|
||||
): Promise<void> {
|
||||
await openEvaluationSettings(page);
|
||||
await evaluationWindowOption(page, 'timeframe', value).click();
|
||||
await expect(evaluationSettingsButton(page)).toContainText(
|
||||
EVALUATION_WINDOW_PRESETS[value],
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the ADVANCED OPTIONS panel inside the alert-condition section.
|
||||
*
|
||||
* antd's `Collapse` renders its panel children lazily, so `evaluation-cadence-*`
|
||||
* and the two `AdvancedOptionItem` containers do not exist in the DOM at all until
|
||||
* this runs — an assertion on them without it fails as "not found" rather than as
|
||||
* "not visible", which reads like a missing testid.
|
||||
*/
|
||||
export async function expandAdvancedOptions(page: Page): Promise<void> {
|
||||
const header = page.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
if ((await header.getAttribute('aria-expanded')) !== 'true') {
|
||||
await header.click();
|
||||
}
|
||||
await expect(page.getByTestId('evaluation-cadence-input-group')).toBeVisible();
|
||||
}
|
||||
|
||||
/** The cadence duration field — `evaluation.spec.frequency`'s UI half. */
|
||||
export function evaluationCadenceInput(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-cadence-duration-input');
|
||||
}
|
||||
|
||||
export function evaluationCadenceUnitSelect(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-cadence-unit-select');
|
||||
}
|
||||
|
||||
export function labelPill(page: Page, key: string, value: string): Locator {
|
||||
return page.getByTestId(`label-pill-${key}-${value}`);
|
||||
}
|
||||
188
tests/e2e/helpers/alerts.ts
Normal file
188
tests/e2e/helpers/alerts.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from './common';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_LIST_PATH = '/alerts';
|
||||
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ThresholdAlertSeed {
|
||||
/** Alert rule name. Keep unique per test to avoid collisions. */
|
||||
name: string;
|
||||
/** The critical-threshold target value to persist and later assert. */
|
||||
target: number;
|
||||
/**
|
||||
* Notification channel names for the critical threshold. At least one is
|
||||
* required by the API — seed one with {@link createEmailChannelViaApi}.
|
||||
*/
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
// ─── Payload ─────────────────────────────────────────────────────────────
|
||||
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
|
||||
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
|
||||
// CreateAlertV2 UI posts to POST /api/v2/rules.
|
||||
function buildThresholdRulePayload({
|
||||
name,
|
||||
target,
|
||||
channels,
|
||||
}: ThresholdAlertSeed): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
annotations: {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '5m0s', frequency: '1m' },
|
||||
},
|
||||
notificationSettings: {
|
||||
groupBy: [],
|
||||
renotify: { enabled: false, interval: '30m', alertStates: [] },
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
compositeQuery: {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_calls_total',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target,
|
||||
targetUnit: '',
|
||||
recoveryTarget: null,
|
||||
matchType: 'at_least_once',
|
||||
op: 'above',
|
||||
channels,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed an email notification channel via API. Returns its `{ id, name }`;
|
||||
* thresholds reference channels by name, cleanup deletes by id. `to` is never
|
||||
* delivered — the channel only needs to exist to satisfy rule validation.
|
||||
*/
|
||||
export async function createEmailChannelViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/channels', {
|
||||
data: {
|
||||
name,
|
||||
email_configs: [
|
||||
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
|
||||
],
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return { id: String(json.data.id), name };
|
||||
}
|
||||
|
||||
/** Delete a notification channel by ID (best-effort cleanup). */
|
||||
export async function deleteChannelViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v1/channels/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
|
||||
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
|
||||
*/
|
||||
export async function createThresholdAlertViaApi(
|
||||
page: Page,
|
||||
seed: ThresholdAlertSeed,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/rules', {
|
||||
data: buildThresholdRulePayload(seed),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
|
||||
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v2/rules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
|
||||
* settled: the condition editor is visible and the query builder has finished
|
||||
* serializing the loaded query into the URL.
|
||||
*/
|
||||
export async function gotoAlertOverview(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
|
||||
await expect(page.getByTestId('threshold-value-input')).toBeVisible();
|
||||
// The builder rewrites location.search shortly after load (adds compositeQuery).
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
// Let post-load state updates flush so callers read the settled value.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../common';
|
||||
|
||||
import {
|
||||
buildThresholdRulePayload,
|
||||
logsCompositeQuery,
|
||||
metricsCompositeQuery,
|
||||
tracesCompositeQuery,
|
||||
v1RulePayload,
|
||||
v2RulePayload,
|
||||
} from './payloads';
|
||||
import type {
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
MetricAlertSeed,
|
||||
ThresholdAlertSeed,
|
||||
TracesAlertSeed,
|
||||
} from './types';
|
||||
|
||||
// ─── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed an email notification channel via API. Returns its `{ id, name }`;
|
||||
* thresholds reference channels by name, cleanup deletes by id. `to` is never
|
||||
* delivered — the channel only needs to exist to satisfy rule validation.
|
||||
*/
|
||||
export async function createEmailChannelViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/channels', {
|
||||
data: {
|
||||
name,
|
||||
email_configs: [
|
||||
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
|
||||
],
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return { id: String(json.data.id), name };
|
||||
}
|
||||
|
||||
/** Delete a notification channel by ID (best-effort cleanup). */
|
||||
export async function deleteChannelViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v1/channels/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
|
||||
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
|
||||
*/
|
||||
export async function createThresholdAlertViaApi(
|
||||
page: Page,
|
||||
seed: ThresholdAlertSeed,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/rules', {
|
||||
data: buildThresholdRulePayload(seed),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
|
||||
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v2/rules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function postRule(
|
||||
page: Page,
|
||||
schema: AlertSchema,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const path = schema === 'v1' ? '/api/v1/rules' : '/api/v2/rules';
|
||||
const res = await page.request.post(path, {
|
||||
data: payload,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST ${path} ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return String(json.data.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logs threshold rule grouped by `service.name`. `schema: 'v1'` posts
|
||||
* the legacy payload to `/api/v1/rules`, which the UI then renders through the
|
||||
* v1 branch of `AlertHeader` / `ActionButtons` — both schemas serve the *same*
|
||||
* history APIs, so history scenarios can be parameterised over them.
|
||||
*/
|
||||
export async function createLogsAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
schema = 'v2',
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
severity = schema === 'v1' ? 'warning' : 'critical',
|
||||
extraLabels,
|
||||
alertOnAbsent,
|
||||
absentFor,
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
}: LogsAlertSeed,
|
||||
): Promise<string> {
|
||||
const extraCondition =
|
||||
alertOnAbsent === undefined
|
||||
? undefined
|
||||
: { alertOnAbsent, absentFor: absentFor ?? 1 };
|
||||
const args = {
|
||||
name,
|
||||
alertType: 'LOGS_BASED_ALERT',
|
||||
compositeQuery: logsCompositeQuery(marker),
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
};
|
||||
return postRule(
|
||||
page,
|
||||
schema,
|
||||
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-H's rule: traces-based over the seeded spans, grouped by `service.name`.
|
||||
* Its history rows carry `relatedTracesLink` and an empty `relatedLogsLink`, so
|
||||
* the popover offers "View Traces" only.
|
||||
*/
|
||||
export async function createTracesAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
}: TracesAlertSeed,
|
||||
): Promise<string> {
|
||||
return postRule(
|
||||
page,
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'TRACES_BASED_ALERT',
|
||||
compositeQuery: tracesCompositeQuery(marker),
|
||||
channels,
|
||||
severity: 'critical',
|
||||
evalWindow,
|
||||
frequency,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** SEED-E's rule: metrics-based, so its history rows carry no related links. */
|
||||
export async function createMetricAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
metricName,
|
||||
channels,
|
||||
groupByKey = 'host',
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
}: MetricAlertSeed,
|
||||
): Promise<string> {
|
||||
return postRule(
|
||||
page,
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
compositeQuery: metricsCompositeQuery(metricName, groupByKey),
|
||||
channels,
|
||||
severity: 'critical',
|
||||
evalWindow,
|
||||
frequency,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-G's rule: a logs rule whose filter matches nothing, with
|
||||
* `alertOnAbsent` set — the only cheap way to get a `nodata` history row.
|
||||
* Seed no telemetry for its marker.
|
||||
*/
|
||||
export async function createNoDataAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
}: { name: string; marker: string; channels: string[] },
|
||||
): Promise<string> {
|
||||
return createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
evalWindow: '5m0s',
|
||||
frequency: '15s',
|
||||
alertOnAbsent: true,
|
||||
absentFor: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze a rule's history. Rows are written on *state change* only, so the
|
||||
* firing wave lands once — but once the eval window rolls past the seeded
|
||||
* records the rule resolves and writes a second row per fingerprint, doubling
|
||||
* `total` mid-suite. Disable the rule as soon as the firing wave is confirmed.
|
||||
*/
|
||||
export async function setRuleDisabledViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
disabled: boolean,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.patch(`/api/v2/rules/${id}`, {
|
||||
data: { disabled },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PATCH /api/v2/rules/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_LIST_PATH = '/alerts';
|
||||
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
|
||||
export const ALERT_HISTORY_PATH = '/alerts/history';
|
||||
|
||||
/**
|
||||
* Mirrors `TIMELINE_TABLE_PAGE_SIZE` in
|
||||
* `frontend/src/container/AlertHistory/constants.ts`. This 20 is what makes the
|
||||
* page-2 cursor `base64url({"offset":20,"limit":20})`, so the two must not drift.
|
||||
*/
|
||||
export const TIMELINE_PAGE_SIZE = 20;
|
||||
|
||||
/** The `relativeTime` the history page falls back to (`DEFAULT_TIME_RANGE`). */
|
||||
export const DEFAULT_RELATIVE_TIME = '30m';
|
||||
|
||||
/**
|
||||
* Page size the list specs pin in the URL, so the number of rendered rows never
|
||||
* depends on the viewport height.
|
||||
*/
|
||||
export const ALERT_LIST_PAGE_SIZE = 10;
|
||||
|
||||
/** Severities the list seed cycles through, so search/sort tests have more than one value. */
|
||||
export const SEED_B_SEVERITIES = ['critical', 'warning', 'info'] as const;
|
||||
|
||||
// ─── Wait timeouts (ms) ──────────────────────────────────────────────────
|
||||
// These timeouts gate on the "ruler" — SigNoz's alert evaluation engine that
|
||||
// runs on ~15s cycles and writes history rows to ClickHouse. No way to force
|
||||
// evaluation or seed history directly, so we poll until rows appear.
|
||||
|
||||
/**
|
||||
* Default timeout for waitForTimelineEntries.
|
||||
*
|
||||
* Logs rules need 2+ ruler cycles (~15s each) to see 25 services fire.
|
||||
* 90s = 6 cycles worst-case. Actual time: 20-35s for logs, ~10s for metrics.
|
||||
*/
|
||||
export const WAIT_TIMELINE_ENTRIES_DEFAULT = 90_000;
|
||||
|
||||
/**
|
||||
* Default timeout for waitForTimelineStates (firing + resolved).
|
||||
*
|
||||
* Resolved state appears after evalWindow expires with no matching data.
|
||||
* 1m window + 2 ruler cycles = ~105s observed. 180s = safe margin.
|
||||
*/
|
||||
export const WAIT_TIMELINE_STATES_DEFAULT = 180_000;
|
||||
@@ -1,359 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { authToken, requestUrl } from '../common';
|
||||
import { typeExpression } from '../query-builder';
|
||||
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
WAIT_TIMELINE_ENTRIES_DEFAULT,
|
||||
WAIT_TIMELINE_STATES_DEFAULT,
|
||||
} from './constants';
|
||||
import type { TimelineItem, TimelineResponse } from './types';
|
||||
|
||||
// ─── History API probes ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the timeline straight from the API. Used to gate on the ruler having
|
||||
* produced rows *before* a spec opens the UI — polling through the browser
|
||||
* would conflate "no rows yet" with "the table failed to render".
|
||||
*/
|
||||
export async function fetchTimeline(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
params: Record<string, string | number> = {},
|
||||
): Promise<TimelineResponse> {
|
||||
const token = await authToken(page);
|
||||
const now = Date.now();
|
||||
const query = new URLSearchParams({
|
||||
start: String(now - 30 * 60 * 1000),
|
||||
end: String(now),
|
||||
limit: '100',
|
||||
order: 'asc',
|
||||
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
});
|
||||
const res = await page.request.get(
|
||||
`/api/v2/rules/${ruleId}/history/timeline?${query.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET /api/v2/rules/${ruleId}/history/timeline ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { data: TimelineResponse | null };
|
||||
return {
|
||||
items: json.data?.items ?? [],
|
||||
total: json.data?.total ?? 0,
|
||||
nextCursor: json.data?.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
function countStates(items: TimelineItem[]): Record<string, number> {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.state] = (acc[item.state] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until at least `min` rows in state `state` exist. Takes ~20-35s for the
|
||||
* logs fixture (the rule fires on the first evaluation that sees the data) and
|
||||
* ~10s for the metrics one, so budget generously — a timeout here means the
|
||||
* marker aged out of the eval window, not that the assertion is wrong.
|
||||
*/
|
||||
export async function waitForTimelineEntries(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
{
|
||||
min,
|
||||
state = 'firing',
|
||||
timeoutMs = WAIT_TIMELINE_ENTRIES_DEFAULT,
|
||||
}: { min: number; state?: string; timeoutMs?: number },
|
||||
): Promise<TimelineResponse> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: TimelineResponse = { items: [], total: 0 };
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
last = await fetchTimeline(page, ruleId);
|
||||
if (last.items.filter((item) => item.state === state).length >= min) {
|
||||
return last;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`timeline for rule ${ruleId} never reached ${min} '${state}' rows within ${timeoutMs}ms ` +
|
||||
`(last: total=${last.total}, states=${JSON.stringify(countStates(last.items))})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until every requested state has at least the requested row count.
|
||||
* SEED-F's firing→resolved wave and SEED-G's `nodata` row both gate on this.
|
||||
*/
|
||||
export async function waitForTimelineStates(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
{
|
||||
states,
|
||||
timeoutMs = WAIT_TIMELINE_STATES_DEFAULT,
|
||||
}: { states: Record<string, number>; timeoutMs?: number },
|
||||
): Promise<TimelineResponse> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: TimelineResponse = { items: [], total: 0 };
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
last = await fetchTimeline(page, ruleId);
|
||||
const seen = countStates(last.items);
|
||||
if (
|
||||
Object.entries(states).every(([state, min]) => (seen[state] ?? 0) >= min)
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`timeline for rule ${ruleId} never reached ${JSON.stringify(states)} within ${timeoutMs}ms ` +
|
||||
`(last states: ${JSON.stringify(countStates(last.items))})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The filtered row count the timeline reports. Ignores `limit`. */
|
||||
export async function readTimelineTotal(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<number> {
|
||||
return (await fetchTimeline(page, ruleId, { limit: 1 })).total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of `encodeCursor` in
|
||||
* `container/AlertHistory/Timeline/Table/useTimelineTableCursor.ts`, so specs
|
||||
* can assert the *exact* cursor the UI sends. Verified byte-identical to the
|
||||
* server's `nextCursor`.
|
||||
*/
|
||||
export function encodeTimelineCursor(
|
||||
page_: number,
|
||||
limit = TIMELINE_PAGE_SIZE,
|
||||
): string | undefined {
|
||||
if (page_ <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = (page_ - 1) * limit;
|
||||
return Buffer.from(JSON.stringify({ offset, limit }))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Labels on a timeline row, flattened to a plain object. */
|
||||
export function timelineLabelsToObject(
|
||||
item: TimelineItem,
|
||||
): Record<string, string> {
|
||||
return (item.labels ?? []).reduce<Record<string, string>>((acc, label) => {
|
||||
const name = label.key?.name;
|
||||
if (name) {
|
||||
acc[name] = String(label.value ?? '');
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the history tab for `ruleId` and wait until the timeline table has
|
||||
* mounted. `params` is merged into the query string, so scenarios can deep-link
|
||||
* `page`, `order`, `timelineFilter`, … in one call.
|
||||
*/
|
||||
export async function gotoAlertHistory(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
// An absolute window and `relativeTime` are mutually exclusive in practice:
|
||||
// with both present the time picker normalises back to the relative range and
|
||||
// **drops** `startTime`/`endTime` from the URL, so the absolute window never
|
||||
// takes effect. Only send the default relative range when no absolute one was
|
||||
// asked for.
|
||||
const hasAbsoluteRange = !!params.startTime && !!params.endTime;
|
||||
const query = new URLSearchParams({
|
||||
ruleId,
|
||||
...(hasAbsoluteRange ? {} : { relativeTime: DEFAULT_RELATIVE_TIME }),
|
||||
...params,
|
||||
});
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?${query.toString()}`);
|
||||
|
||||
// Race the table against the app's error boundary. The history page has been
|
||||
// observed crashing into it intermittently on load; without this the failure
|
||||
// reads as a 15s "timeline-table not found", which says nothing about why.
|
||||
const table = page.getByTestId('timeline-table');
|
||||
const crashed = page.getByText('Something went wrong :/');
|
||||
await expect(table.or(crashed)).toBeVisible();
|
||||
if (await crashed.isVisible()) {
|
||||
throw new Error(
|
||||
`alert history crashed into the app error boundary at ${page.url()} — ` +
|
||||
'a component threw during render; check the captured console output',
|
||||
);
|
||||
}
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
// The `timeline-table` node is rendered by the first paint, *before* the
|
||||
// timeline request settles — antd only overlays a spinner on it. Returning
|
||||
// here would leave that request in flight, and the next
|
||||
// `waitForHistoryResponse` in the spec would resolve with the page's own
|
||||
// load instead of the response its interaction produced. Wait the spinner
|
||||
// out so every caller starts from a quiet page.
|
||||
await expect(page.locator('.timeline-table .ant-spin-spinning')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Locators ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assert the table is back on page 1. Both the list and the timeline use nuqs
|
||||
* with `parseAsInteger.withDefault(1)`, which **removes** the `page` param when
|
||||
* it is reset rather than writing `page=1` — so "absent" and "1" are the same
|
||||
* state and a naive `?page=1` regex never matches.
|
||||
*/
|
||||
export async function expectFirstPage(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get('page') ?? '1')
|
||||
.toBe('1');
|
||||
}
|
||||
|
||||
export function timelineRows(page: Page): Locator {
|
||||
return page.getByTestId('timeline-row');
|
||||
}
|
||||
|
||||
export function timelineFooterRange(page: Page): Locator {
|
||||
return page.getByTestId('timeline-footer-range');
|
||||
}
|
||||
|
||||
export function statsCard(page: Page, title: string): Locator {
|
||||
return page.locator(`[data-testid="stats-card"][data-stats-title="${title}"]`);
|
||||
}
|
||||
|
||||
/** Open the ACTIONS popover on timeline row `index` (0-based). */
|
||||
export async function openTimelineRowActions(
|
||||
page: Page,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
await timelineRows(page)
|
||||
.nth(index)
|
||||
.getByTestId('timeline-row-actions')
|
||||
.click();
|
||||
}
|
||||
|
||||
// ─── History request matchers ──────────────────────────────────────────────
|
||||
|
||||
/** The four v2 endpoints one history page load hits. */
|
||||
export const HISTORY_ENDPOINTS = [
|
||||
'stats',
|
||||
'timeline',
|
||||
'top_contributors',
|
||||
'overall_status',
|
||||
] as const;
|
||||
|
||||
export type HistoryEndpoint = (typeof HISTORY_ENDPOINTS)[number];
|
||||
|
||||
/** Match a request against one history endpoint, whatever the rule id. */
|
||||
export function isHistoryRequest(
|
||||
request: Request,
|
||||
endpoint: HistoryEndpoint,
|
||||
): boolean {
|
||||
return new RegExp(`/api/v2/rules/[^/]+/history/${endpoint}`).test(
|
||||
request.url(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a history API response. Common pattern across history specs.
|
||||
*
|
||||
* Optionally narrow by HTTP status code or by the `filterExpression` the
|
||||
* request carried. The latter matters whenever a scenario reacts to *its own*
|
||||
* request: the page's own load is still in flight when the spec starts typing,
|
||||
* so an unqualified matcher happily resolves with that earlier response.
|
||||
*/
|
||||
export function waitForHistoryResponse(
|
||||
page: Page,
|
||||
endpoint: HistoryEndpoint,
|
||||
options?: { status?: number; filterExpression?: string },
|
||||
): Promise<Response> {
|
||||
return page.waitForResponse((res) => {
|
||||
if (!isHistoryRequest(res.request(), endpoint)) return false;
|
||||
if (options?.status !== undefined && res.status() !== options.status)
|
||||
return false;
|
||||
if (
|
||||
options?.filterExpression !== undefined &&
|
||||
(requestUrl(res.request()).searchParams.get('filterExpression') ?? '') !==
|
||||
options.filterExpression
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── History interactions ──────────────────────────────────────────────────
|
||||
|
||||
/** Apply a filter expression through the real editor + Run button. */
|
||||
export async function runFilterExpression(
|
||||
page: Page,
|
||||
expression: string,
|
||||
): Promise<void> {
|
||||
await typeExpression(page, expression);
|
||||
await page.getByRole('button', { name: /run query/i }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the timeline descending through the STATE header.
|
||||
*
|
||||
* The antd table is *uncontrolled* — it has `sorter: true` but no `sortOrder`,
|
||||
* so its internal cycle is none → ascend → descend regardless of the `order`
|
||||
* the hook already sends. Reaching `desc` therefore takes two clicks, and the
|
||||
* first one only resets the page (asc is nuqs's default, so it writes no param).
|
||||
*/
|
||||
export async function sortTimelineDescending(page: Page): Promise<void> {
|
||||
const header = page.getByRole('columnheader', { name: 'STATE' });
|
||||
const descRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
isHistoryRequest(req, 'timeline') &&
|
||||
requestUrl(req).searchParams.get('order') === 'desc',
|
||||
);
|
||||
await header.click();
|
||||
await header.click();
|
||||
await descRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the LABELS cell of every rendered row. Scenarios that compare two
|
||||
* snapshots taken at different times (page 1 vs page 2, one timezone vs
|
||||
* another) cannot express that as a web-first assertion, so the read lives in a
|
||||
* helper rather than inline in the test.
|
||||
*/
|
||||
export async function timelineRowLabels(page: Page): Promise<string[]> {
|
||||
return timelineRows(page).getByTestId('timeline-row-labels').allInnerTexts();
|
||||
}
|
||||
|
||||
/** Snapshot the first row's CREATED AT cell. See {@link timelineRowLabels}. */
|
||||
export async function firstTimelineRowCreatedAt(page: Page): Promise<string> {
|
||||
return timelineRows(page)
|
||||
.first()
|
||||
.getByTestId('timeline-row-created-at')
|
||||
.innerText();
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
ALERT_LIST_PAGE_SIZE,
|
||||
ALERT_OVERVIEW_PATH,
|
||||
ALERTS_LIST_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
} from './constants';
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
|
||||
* settled: the condition editor is visible and the query builder has finished
|
||||
* serializing the loaded query into the URL.
|
||||
*/
|
||||
export async function gotoAlertOverview(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
|
||||
// `.first()` because a rule may have several thresholds, and the editor renders
|
||||
// one input per threshold. Without it this is a strict-mode violation that only
|
||||
// appears once the *second* row has rendered — i.e. a timing-dependent failure
|
||||
// for multi-threshold rules.
|
||||
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
|
||||
// The builder rewrites location.search shortly after load (adds compositeQuery).
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
// Let post-load state updates flush so callers read the settled value.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the alert details shell (Overview tab) for `ruleId` and wait until it has
|
||||
* mounted. Unlike {@link gotoAlertOverview} this does **not** wait for the
|
||||
* condition editor or the serialised query — use it for scenarios about the
|
||||
* shell itself (header, tabs, actions menu) rather than the rule's contents.
|
||||
*/
|
||||
export async function gotoAlertDetails(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(
|
||||
`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}&relativeTime=${DEFAULT_RELATIVE_TIME}`,
|
||||
);
|
||||
await expect(page.getByTestId('alert-details-root')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Rows currently rendered in the alert-rules table body. */
|
||||
export function alertRuleRows(page: Page): Locator {
|
||||
return page.locator('tbody tr');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the alert-rules list and wait until it has rows. `params` is merged into
|
||||
* the query string (`search`, `page`, `orderBy`, …); `limit` defaults to
|
||||
* {@link ALERT_LIST_PAGE_SIZE} so row counts are viewport-independent.
|
||||
*
|
||||
* Pass `expectRows: false` for scenarios whose filters are *meant* to match
|
||||
* nothing — the row wait would otherwise fail before the assertion runs.
|
||||
*/
|
||||
export async function gotoAlertList(
|
||||
page: Page,
|
||||
params: Record<string, string> = {},
|
||||
{ expectRows = true }: { expectRows?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const query = new URLSearchParams({
|
||||
limit: String(ALERT_LIST_PAGE_SIZE),
|
||||
...params,
|
||||
});
|
||||
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
|
||||
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
|
||||
if (expectRows) {
|
||||
await expect(alertRuleRows(page).first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import type { ThresholdAlertSeed, ThresholdSeedSpec } from './types';
|
||||
|
||||
// ─── Payload builders ────────────────────────────────────────────────────
|
||||
|
||||
const ANNOTATIONS = {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
};
|
||||
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
|
||||
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
|
||||
// CreateAlertV2 UI posts to POST /api/v2/rules.
|
||||
export function buildThresholdRulePayload({
|
||||
name,
|
||||
target,
|
||||
channels,
|
||||
labels,
|
||||
thresholds,
|
||||
evalWindow = '5m0s',
|
||||
frequency = '1m',
|
||||
groupBy = [],
|
||||
queryGroupBy = [],
|
||||
renotify = { enabled: false, interval: '30m', alertStates: [] },
|
||||
alertOnAbsent,
|
||||
recoveryTarget = null,
|
||||
}: ThresholdAlertSeed): Record<string, unknown> {
|
||||
const thresholdSpec = (
|
||||
thresholds ?? [{ name: 'critical', target, channels, recoveryTarget }]
|
||||
).map((spec: ThresholdSeedSpec) => ({
|
||||
name: spec.name,
|
||||
target: spec.target,
|
||||
targetUnit: spec.targetUnit ?? '',
|
||||
recoveryTarget: spec.recoveryTarget ?? null,
|
||||
matchType: spec.matchType ?? 'at_least_once',
|
||||
op: spec.op ?? 'above',
|
||||
channels: spec.channels,
|
||||
}));
|
||||
|
||||
return {
|
||||
alert: name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
...(labels ? { labels } : {}),
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow, frequency },
|
||||
},
|
||||
notificationSettings: {
|
||||
groupBy,
|
||||
renotify,
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
...(alertOnAbsent
|
||||
? { alertOnAbsent: true, absentFor: alertOnAbsent.absentFor }
|
||||
: {}),
|
||||
compositeQuery: {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_calls_total',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
...(queryGroupBy.length > 0
|
||||
? {
|
||||
groupBy: queryGroupBy.map((key) => ({
|
||||
name: key,
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: thresholdSpec,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The v5 `queries[]` envelope is identical for both schema versions
|
||||
// (`AlertCompositeQuery` in pkg/types/ruletypes/alerting.go) — only the
|
||||
// threshold / evaluation / channel envelopes differ. That keeps one builder
|
||||
// per signal and a thin branch over the wrapper.
|
||||
export function logsCompositeQuery(marker: string): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: `body CONTAINS '${marker}'` },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Same shape as the logs query, one signal over: the rule's signal is what
|
||||
// decides which related link the history rows carry (`links()` in
|
||||
// `pkg/modules/rulestatehistory/implrulestatehistory/links.go` returns *either*
|
||||
// a logs link *or* a traces link, never both).
|
||||
export function tracesCompositeQuery(marker: string): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: `name = '${marker}'` },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function metricsCompositeQuery(
|
||||
metricName: string,
|
||||
groupByKey: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
groupBy: [
|
||||
{ name: groupByKey, fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
],
|
||||
aggregations: [
|
||||
{
|
||||
metricName,
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'max',
|
||||
},
|
||||
],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// `target 0 / op above / matchType at_least_once` fires on the first evaluation
|
||||
// that sees any matching record, which is what keeps the ruler wait to ~20-35s.
|
||||
export function v2RulePayload({
|
||||
name,
|
||||
alertType,
|
||||
compositeQuery,
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
}: {
|
||||
name: string;
|
||||
alertType: string;
|
||||
compositeQuery: Record<string, unknown>;
|
||||
channels: string[];
|
||||
severity: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow: string;
|
||||
frequency: string;
|
||||
extraCondition?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: { kind: 'rolling', spec: { evalWindow, frequency } },
|
||||
notificationSettings: {
|
||||
groupBy: [],
|
||||
renotify: { enabled: false, interval: '30m', alertStates: [] },
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
compositeQuery,
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: severity,
|
||||
target: 0,
|
||||
targetUnit: '',
|
||||
recoveryTarget: null,
|
||||
matchType: 'at_least_once',
|
||||
op: 'above',
|
||||
channels,
|
||||
},
|
||||
],
|
||||
},
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy schema: `evalWindow`/`frequency` sit at the top level, channels are
|
||||
// `preferredChannels`, and `condition.{op,target,matchType}` are the numeric
|
||||
// enum forms the v1 validator requires. `labels.severity` becomes the history
|
||||
// `threshold.name`.
|
||||
export function v1RulePayload({
|
||||
name,
|
||||
alertType,
|
||||
compositeQuery,
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
target = 0,
|
||||
op = '1',
|
||||
matchType = '1',
|
||||
}: {
|
||||
name: string;
|
||||
alertType: string;
|
||||
compositeQuery: Record<string, unknown>;
|
||||
channels: string[];
|
||||
severity: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow: string;
|
||||
frequency: string;
|
||||
extraCondition?: Record<string, unknown>;
|
||||
target?: number;
|
||||
op?: string;
|
||||
matchType?: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
disabled: false,
|
||||
source: '',
|
||||
evalWindow,
|
||||
frequency,
|
||||
preferredChannels: channels,
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
// Defaults match the history seeds' original shape — `target 0 / op above /
|
||||
// matchType at_least_once` fires on the first evaluation that sees data — so
|
||||
// overriding them is opt-in and cannot change what those seeds do.
|
||||
op,
|
||||
target,
|
||||
matchType,
|
||||
compositeQuery,
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { seederUrl } from '../common';
|
||||
|
||||
import { createThresholdAlertViaApi } from './api';
|
||||
import { SEED_B_SEVERITIES } from './constants';
|
||||
import type {
|
||||
AlertRulesSeedOptions,
|
||||
LogsSeedOptions,
|
||||
MetricsSeedOptions,
|
||||
TracesSeedOptions,
|
||||
} from './types';
|
||||
|
||||
// ─── Seeding telemetry ───────────────────────────────────────────────────
|
||||
|
||||
async function postToSeeder(
|
||||
page: Page,
|
||||
path: string,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
const url = `${seederUrl()}${path}`;
|
||||
// The seeder shares one ClickHouse client, so concurrent POSTs from parallel
|
||||
// workers collide with a transient 500 "concurrent queries within the same
|
||||
// session". Retry those; anything else is real.
|
||||
const maxAttempts = 6;
|
||||
let lastStatus = 0;
|
||||
let lastText = '';
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await page.request.post(url, {
|
||||
data,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok()) {
|
||||
return;
|
||||
}
|
||||
lastStatus = res.status();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
lastText = await res.text();
|
||||
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
|
||||
});
|
||||
}
|
||||
throw new Error(`seeder POST ${path} ${lastStatus}: ${lastText}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed log records the history rules match on. Returns the generated
|
||||
* `service.name` values, which become the timeline rows' `groupBy` labels
|
||||
* (N distinct services ⇒ N distinct fingerprints ⇒ N timeline rows).
|
||||
*
|
||||
* Seed these **immediately** before creating the rule: the rule only fires
|
||||
* while the records are still inside its eval window, and a stale marker
|
||||
* silently never fires.
|
||||
*/
|
||||
export async function seedAlertHistoryLogs(
|
||||
page: Page,
|
||||
{
|
||||
marker,
|
||||
services,
|
||||
recordsPerService = 2,
|
||||
ageSeconds = 150,
|
||||
minAgeSeconds = 30,
|
||||
servicePrefix = 'e2e-ah-svc',
|
||||
}: LogsSeedOptions,
|
||||
): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
const span = Math.max(ageSeconds - minAgeSeconds, 1);
|
||||
const serviceNames: string[] = [];
|
||||
const records: Record<string, unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < services; i += 1) {
|
||||
const service = `${servicePrefix}-${i}`;
|
||||
serviceNames.push(service);
|
||||
for (let r = 0; r < recordsPerService; r += 1) {
|
||||
const fraction =
|
||||
(i * recordsPerService + r) / (services * recordsPerService);
|
||||
const offset = ageSeconds - Math.floor(fraction * span);
|
||||
records.push({
|
||||
timestamp: new Date(now - offset * 1000).toISOString(),
|
||||
body: marker,
|
||||
resources: { 'service.name': service },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await postToSeeder(page, '/telemetry/logs', records);
|
||||
return serviceNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed spans the traces history rule matches on — one root span per
|
||||
* `service.name`, all sharing the span `name` marker. Returns the generated
|
||||
* service names (⇒ one timeline row each), same contract as
|
||||
* {@link seedAlertHistoryLogs}, and the same "seed immediately before creating
|
||||
* the rule" rule applies.
|
||||
*/
|
||||
export async function seedAlertHistoryTraces(
|
||||
page: Page,
|
||||
{
|
||||
marker,
|
||||
services,
|
||||
spansPerService = 2,
|
||||
ageSeconds = 150,
|
||||
minAgeSeconds = 30,
|
||||
servicePrefix = 'e2e-aht-svc',
|
||||
}: TracesSeedOptions,
|
||||
): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
const span = Math.max(ageSeconds - minAgeSeconds, 1);
|
||||
const serviceNames: string[] = [];
|
||||
const spans: Record<string, unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < services; i += 1) {
|
||||
const service = `${servicePrefix}-${i}`;
|
||||
serviceNames.push(service);
|
||||
for (let s = 0; s < spansPerService; s += 1) {
|
||||
const fraction = (i * spansPerService + s) / (services * spansPerService);
|
||||
const offset = ageSeconds - Math.floor(fraction * span);
|
||||
spans.push({
|
||||
timestamp: new Date(now - offset * 1000).toISOString(),
|
||||
trace_id: randomBytes(16).toString('hex'),
|
||||
span_id: randomBytes(8).toString('hex'),
|
||||
name: marker,
|
||||
kind: 2,
|
||||
duration: 'PT0.05S',
|
||||
resources: { 'service.name': service },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await postToSeeder(page, '/telemetry/traces', spans);
|
||||
return serviceNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a throwaway gauge the metrics rule alerts on. Cheaper than the logs
|
||||
* fixture (~10s to fire) and its history rows carry neither `relatedLogsLink`
|
||||
* nor `relatedTracesLink` — the "no links available" case.
|
||||
*/
|
||||
export async function seedAlertHistoryMetrics(
|
||||
page: Page,
|
||||
{
|
||||
metricName,
|
||||
hosts,
|
||||
pointsPerHost = 3,
|
||||
groupByKey = 'host',
|
||||
}: MetricsSeedOptions,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
const points: Record<string, unknown>[] = [];
|
||||
for (const host of hosts) {
|
||||
for (let p = 0; p < pointsPerHost; p += 1) {
|
||||
points.push({
|
||||
metric_name: metricName,
|
||||
labels: { [groupByKey]: host },
|
||||
timestamp: new Date(now - (pointsPerHost - p) * 20 * 1000).toISOString(),
|
||||
value: 10 + p,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
await postToSeeder(page, '/telemetry/metrics', points);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-B: `count` metric threshold rules sharing one channel. Severities cycle
|
||||
* through {@link SEED_B_SEVERITIES} and every rule carries a `team` label, so
|
||||
* the list's "Alert Name, Severity and Labels" search has hits *and* misses for
|
||||
* all three. Even-indexed rules are `platform`, odd ones `payments` — i.e. half
|
||||
* the batch each. Returns the ids in creation order.
|
||||
*/
|
||||
export async function seedAlertRules(
|
||||
page: Page,
|
||||
{
|
||||
count,
|
||||
channelName,
|
||||
namePrefix = 'e2e-alert-list',
|
||||
teamSuffix = '',
|
||||
}: AlertRulesSeedOptions,
|
||||
): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
// Sequential on purpose: the rules API is not the thing under test and
|
||||
// parallel POSTs make failures harder to attribute.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const id = await createThresholdAlertViaApi(page, {
|
||||
name: `${namePrefix}-${String(i).padStart(2, '0')}`,
|
||||
target: 100 + i,
|
||||
channels: [channelName],
|
||||
labels: {
|
||||
severity: SEED_B_SEVERITIES[i % SEED_B_SEVERITIES.length],
|
||||
team: `${i % 2 === 0 ? 'platform' : 'payments'}${teamSuffix}`,
|
||||
},
|
||||
});
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ThresholdAlertSeed {
|
||||
/** Alert rule name. Keep unique per test to avoid collisions. */
|
||||
name: string;
|
||||
/** The critical-threshold target value to persist and later assert. */
|
||||
target: number;
|
||||
/**
|
||||
* Notification channel names for the critical threshold. At least one is
|
||||
* required by the API — seed one with {@link createEmailChannelViaApi}.
|
||||
*/
|
||||
channels: string[];
|
||||
/**
|
||||
* Rule labels. `severity` drives the list's Severity column and is one of
|
||||
* the things its search box matches on, so list specs set it explicitly.
|
||||
*/
|
||||
labels?: Record<string, string>;
|
||||
|
||||
// ── SEED-RV2 extras ─────────────────────────────────────────────────────
|
||||
// Everything below exists so an *edit* spec can prove the form prefilled from
|
||||
// the rule rather than from its own defaults. A prefill assertion against a
|
||||
// value that equals `INITIAL_CREATE_ALERT_STATE` proves nothing, so each of
|
||||
// these deliberately differs from the corresponding UI default.
|
||||
|
||||
/**
|
||||
* Replaces the single `critical` threshold. Use two or more to exercise the
|
||||
* multi-threshold prefill — and note the UI only reads `op`/`matchType` back
|
||||
* from `spec[0]`, so entries after the first should keep them identical unless
|
||||
* the test is *about* that defect.
|
||||
*/
|
||||
thresholds?: ThresholdSeedSpec[];
|
||||
/** Go duration; UI default is `5m0s`, so pass something else. */
|
||||
evalWindow?: string;
|
||||
/** Go duration; UI default is `1m`, so pass something else. */
|
||||
frequency?: string;
|
||||
/**
|
||||
* `notificationSettings.groupBy`. The UI's group-by select only offers keys
|
||||
* that the *query* groups by (`MultipleNotifications.tsx:20-48`), so set
|
||||
* {@link ThresholdAlertSeed.queryGroupBy} to the same keys or the prefilled
|
||||
* value has no matching option.
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/** Attribute keys the query groups by. Also what unlocks the group-by select. */
|
||||
queryGroupBy?: string[];
|
||||
/** `notificationSettings.renotify`. UI default is `{enabled: false}`. */
|
||||
renotify?: {
|
||||
enabled: boolean;
|
||||
/** Go duration; UI default is `30m`. */
|
||||
interval: string;
|
||||
alertStates: ('firing' | 'nodata')[];
|
||||
};
|
||||
/** `condition.alertOnAbsent` + `condition.absentFor` (minutes). */
|
||||
alertOnAbsent?: { absentFor: number };
|
||||
/** `condition.recoveryTarget` on the first threshold — the UI never renders it. */
|
||||
recoveryTarget?: number | null;
|
||||
}
|
||||
|
||||
export interface ThresholdSeedSpec {
|
||||
name: string;
|
||||
target: number;
|
||||
targetUnit?: string;
|
||||
matchType?: string;
|
||||
op?: string;
|
||||
channels: string[];
|
||||
recoveryTarget?: number | null;
|
||||
}
|
||||
|
||||
/** Rule schema flavour. `v1` is the legacy payload posted to `/api/v1/rules`. */
|
||||
export type AlertSchema = 'v1' | 'v2';
|
||||
|
||||
export interface LogsAlertSeed {
|
||||
name: string;
|
||||
/** Substring the rule matches on (`body CONTAINS '<marker>'`). */
|
||||
marker: string;
|
||||
/** Channel *names* (not ids) — the API validates the reference. */
|
||||
channels: string[];
|
||||
schema?: AlertSchema;
|
||||
/** Go duration, e.g. `5m0s`. Shrink it to make the rule resolve fast. */
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
/** Becomes the history `threshold.name` for v1 rules (`processRuleDefaults`). */
|
||||
severity?: string;
|
||||
/**
|
||||
* Extra rule labels merged alongside `severity`. They show up in the details
|
||||
* header's labels row (which renders `labels` minus `severity`) *and* as extra
|
||||
* history `filter_keys`, so add them only where a scenario needs them.
|
||||
*/
|
||||
extraLabels?: Record<string, string>;
|
||||
/** `condition.alertOnAbsent` — the only route to a `nodata` row. */
|
||||
alertOnAbsent?: boolean;
|
||||
/** `condition.absentFor`, in minutes. */
|
||||
absentFor?: number;
|
||||
|
||||
// ── SEED-RV1 extras ─────────────────────────────────────────────────────
|
||||
// v1 only. Same reasoning as SEED-RV2's block: an `EV1-*` prefill assertion
|
||||
// against the value the create form would have produced anyway proves nothing,
|
||||
// so each of these exists to differ from `alertDefaults`
|
||||
// (`container/CreateAlertRule/defaults.ts`).
|
||||
|
||||
/** `condition.target`. The v1 default is *absent*, so any number differs. */
|
||||
target?: number;
|
||||
/** `condition.op` as the legacy numeric string. `1` above, `2` below, … */
|
||||
op?: string;
|
||||
/** `condition.matchType`, same encoding. `1` at-least-once, `2` all-the-times. */
|
||||
matchType?: string;
|
||||
}
|
||||
|
||||
export interface TracesAlertSeed {
|
||||
name: string;
|
||||
/** Span name the rule matches on (`name = '<marker>'`). */
|
||||
marker: string;
|
||||
channels: string[];
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
}
|
||||
|
||||
export interface MetricAlertSeed {
|
||||
name: string;
|
||||
metricName: string;
|
||||
channels: string[];
|
||||
/** Attribute the history rows group by. Defaults to `host`. */
|
||||
groupByKey?: string;
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
}
|
||||
|
||||
export interface LogsSeedOptions {
|
||||
marker: string;
|
||||
/** Number of distinct `service.name` values ⇒ number of timeline rows. */
|
||||
services: number;
|
||||
recordsPerService?: number;
|
||||
/** Oldest record age in seconds; records spread from here up to `minAgeSeconds`. */
|
||||
ageSeconds?: number;
|
||||
minAgeSeconds?: number;
|
||||
/** Prefix for the generated `service.name` values. */
|
||||
servicePrefix?: string;
|
||||
}
|
||||
|
||||
export interface MetricsSeedOptions {
|
||||
metricName: string;
|
||||
/** Distinct attribute values ⇒ number of timeline rows. */
|
||||
hosts: string[];
|
||||
pointsPerHost?: number;
|
||||
groupByKey?: string;
|
||||
}
|
||||
|
||||
export interface TracesSeedOptions {
|
||||
/** Span `name` the rule matches on. */
|
||||
marker: string;
|
||||
/** Number of distinct `service.name` values ⇒ number of timeline rows. */
|
||||
services: number;
|
||||
spansPerService?: number;
|
||||
/** Oldest span age in seconds; spans spread from here up to `minAgeSeconds`. */
|
||||
ageSeconds?: number;
|
||||
minAgeSeconds?: number;
|
||||
servicePrefix?: string;
|
||||
}
|
||||
|
||||
/** One row of `GET /api/v2/rules/{id}/history/timeline`. */
|
||||
export interface TimelineItem {
|
||||
state: string;
|
||||
unixMilli: number;
|
||||
fingerprint: string;
|
||||
value: number;
|
||||
labels: {
|
||||
key?: { name?: string };
|
||||
value?: string | number | boolean | null;
|
||||
}[];
|
||||
relatedLogsLink?: string;
|
||||
relatedTracesLink?: string;
|
||||
}
|
||||
|
||||
export interface TimelineResponse {
|
||||
items: TimelineItem[];
|
||||
total: number;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface AlertRulesSeedOptions {
|
||||
count: number;
|
||||
channelName: string;
|
||||
/** Rules are named `<namePrefix>-NN`. Keep it unique per batch. */
|
||||
namePrefix?: string;
|
||||
/**
|
||||
* Appended to both `team` label values. Every list spec seeds its own batch
|
||||
* and they run in parallel, so a bare `team: payments` would also match the
|
||||
* neighbouring batches — which is exactly what the label-search scenario
|
||||
* counts. Leave it empty only when nothing asserts an exact label count.
|
||||
*/
|
||||
teamSuffix?: string;
|
||||
}
|
||||
@@ -1,123 +1,34 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
|
||||
export const ADMIN: User = {
|
||||
email: process.env.SIGNOZ_E2E_USERNAME!,
|
||||
password: process.env.SIGNOZ_E2E_PASSWORD!,
|
||||
};
|
||||
import type { Browser, BrowserContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* `browser.newContext()` only inherits `use.baseURL` while a *test* is in
|
||||
* scope. Worker-scoped fixtures (and their teardown) run outside that, where a
|
||||
* relative `page.goto('/login')` fails with "Cannot navigate to invalid URL" —
|
||||
* so pass it explicitly whenever we know it. Left empty when the var is unset
|
||||
* so the config's staging default still applies inside a test.
|
||||
*/
|
||||
const contextDefaults: { baseURL?: string } = process.env.SIGNOZ_E2E_BASE_URL
|
||||
? { baseURL: process.env.SIGNOZ_E2E_BASE_URL }
|
||||
: {};
|
||||
|
||||
// Per-worker storageState cache. One UI login per unique user per worker
|
||||
// process, shared by everything in that worker: the `authedPage` fixture, the
|
||||
// worker-scoped seed fixtures, and their teardown. Promise-valued so concurrent
|
||||
// callers await the same in-flight login rather than racing several of their
|
||||
// own. Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
//
|
||||
// This cache is why `newAdminContext` is cheap. It used to log in through the
|
||||
// UI on every call, and the alerts fixtures call it a dozen-plus times per
|
||||
// worker (channel, rule list, five history seeds, one per owned rule, plus a
|
||||
// teardown for each) — a couple of seconds each, paid over and over for a
|
||||
// session that never changes.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
async function login(page: Page, user: User): Promise<void> {
|
||||
if (!user.email || !user.password) {
|
||||
throw new Error(
|
||||
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
|
||||
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
|
||||
);
|
||||
}
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(user.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
// Post-login lands somewhere different depending on whether the org is
|
||||
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
|
||||
// welcome). Wait for URL to move off /login — whichever page follows
|
||||
// is fine, each spec navigates to the feature under test anyway.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
}
|
||||
|
||||
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
|
||||
// Server-side pref, so set once per user at login.
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const text = await res.text();
|
||||
// Two workers logging in at the same moment both insert the preference and
|
||||
// the loser gets a 500 on `uq_user_preference_name_user_id`. The write it
|
||||
// lost to set the same value, so the preference *is* pinned — treat the
|
||||
// duplicate as success rather than failing an unrelated test.
|
||||
if (text.includes('uq_user_preference_name_user_id')) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated storage state for `user`, logging in once per worker. Callers
|
||||
* hand the result to `browser.newContext({ storageState })`.
|
||||
*/
|
||||
export function storageStateFor(
|
||||
browser: Browser,
|
||||
user: User = ADMIN,
|
||||
): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext(contextDefaults);
|
||||
const page = await ctx.newPage();
|
||||
await login(page, user);
|
||||
await pinSidenav(page);
|
||||
const state = await ctx.storageState();
|
||||
await ctx.close();
|
||||
return state;
|
||||
})();
|
||||
|
||||
storageByUser.set(user.email, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an authenticated admin `BrowserContext`. Used by suite hooks
|
||||
* (`test.beforeAll` / `test.afterAll`) and worker-scoped fixtures, where the
|
||||
* test-scoped `authedPage` fixture from `fixtures/auth.ts` is not reachable.
|
||||
* Build a fresh authenticated `BrowserContext` via UI login. Used by suite
|
||||
* hooks (`test.beforeAll` / `test.afterAll`), where the test-scoped
|
||||
* `authedPage` fixture from `fixtures/auth.ts` is not reachable.
|
||||
*
|
||||
* Reuses this worker's cached session, so only the first call in a worker pays
|
||||
* for a login. The caller owns the context and must close it.
|
||||
* Each call performs one fresh login (~1s). The per-worker storageState
|
||||
* cache in `fixtures/auth.ts` is intentionally not shared here — keeping
|
||||
* this helper standalone avoids coupling suite hooks to the fixture's
|
||||
* private cache.
|
||||
*/
|
||||
export async function newAdminContext(
|
||||
browser: Browser,
|
||||
): Promise<BrowserContext> {
|
||||
return browser.newContext({
|
||||
...contextDefaults,
|
||||
storageState: await storageStateFor(browser, ADMIN),
|
||||
});
|
||||
const email = process.env.SIGNOZ_E2E_USERNAME;
|
||||
const password = process.env.SIGNOZ_E2E_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD must be set ' +
|
||||
'(pytest bootstrap writes them to .env.local).',
|
||||
);
|
||||
}
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
await page.close();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page, Request } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
@@ -18,108 +18,6 @@ export function seederUrl(): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Console / network noise ──────────────────────────────────────────────
|
||||
|
||||
// Requests the bootstrap stack always fails, on every page, for reasons that
|
||||
// have nothing to do with the feature under test. Keep this list tiny and give
|
||||
// every entry a reason — it is a deny-list of *environment* noise, never of real
|
||||
// application errors.
|
||||
const HARNESS_FAILING_REQUESTS = [
|
||||
// Zeus is a WireMock stub with no /api/v2/zeus/hosts mapping, so the app
|
||||
// shell's workspace-URL lookup 404s on every page load. It reaches the console
|
||||
// three ways: the resource-load error, the AxiosError, and the literal `any`
|
||||
// that `api/ErrorResponseHandler.ts`'s fallback branch logs.
|
||||
'/api/v2/zeus/hosts',
|
||||
// The app shell polls GitHub for the latest release. Unauthenticated calls
|
||||
// from CI/dev machines get rate-limited (403), which has nothing to do with
|
||||
// the page under test.
|
||||
'api.github.com',
|
||||
];
|
||||
|
||||
// The console side of {@link HARNESS_FAILING_REQUESTS}. Browsers log a
|
||||
// resource-load error without the URL, so these have to be matched on text —
|
||||
// which is why the URL list above is the precise half of the check.
|
||||
const HARNESS_CONSOLE_NOISE = [
|
||||
'Failed to load resource: the server responded with a status of 404 (Not Found)',
|
||||
'Failed to load resource: the server responded with a status of 403',
|
||||
'Request failed with status code 404',
|
||||
'client never received a response, or request never left',
|
||||
'ErrorResponseHandler: unclassified error',
|
||||
];
|
||||
|
||||
export interface ConsoleWatch {
|
||||
/** Console `error` entries and uncaught page errors, harness noise removed. */
|
||||
errors: string[];
|
||||
/** `"<status> <method> <url>"` for every 4xx/5xx, harness noise removed. */
|
||||
failedResponses: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a page for console errors and failed requests. Call **before** the first
|
||||
* navigation; the returned object fills in as the page runs, so assert on it at
|
||||
* the end of the scenario.
|
||||
*
|
||||
* Console text alone is a weak signal (the harness's Zeus 404 produces three
|
||||
* generic-looking entries), so the failed-response list is the precise half:
|
||||
* text matching is deliberately loose while the URL check stays strict.
|
||||
*/
|
||||
export function watchConsole(
|
||||
page: Page,
|
||||
/**
|
||||
* Extra substrings to ignore. Use this — with a comment naming the defect —
|
||||
* for a *known application* bug that is out of the spec's scope, so the rest
|
||||
* of the console assertion keeps its value instead of being deleted.
|
||||
*/
|
||||
options: { ignore?: string[] } = {},
|
||||
): ConsoleWatch {
|
||||
const watch: ConsoleWatch = { errors: [], failedResponses: [] };
|
||||
const noise = [...HARNESS_CONSOLE_NOISE, ...(options.ignore ?? [])];
|
||||
const isNoise = (text: string): boolean =>
|
||||
noise.some((entry) => text.includes(entry));
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && !isNoise(msg.text())) {
|
||||
watch.errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
if (!isNoise(String(err))) {
|
||||
watch.errors.push(String(err));
|
||||
}
|
||||
});
|
||||
page.on('response', (res) => {
|
||||
if (res.status() < 400) {
|
||||
return;
|
||||
}
|
||||
const url = res.url();
|
||||
if (HARNESS_FAILING_REQUESTS.some((entry) => url.includes(entry))) {
|
||||
return;
|
||||
}
|
||||
watch.failedResponses.push(
|
||||
`${res.status()} ${res.request().method()} ${url}`,
|
||||
);
|
||||
});
|
||||
return watch;
|
||||
}
|
||||
|
||||
// ─── Network capture ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every request the page issues from now on. Call **before** the first
|
||||
* navigation — the returned array fills in as the page runs, so filter it at the
|
||||
* end of the scenario ("endpoint called exactly once", "no legacy route used").
|
||||
*/
|
||||
export function collectRequests(page: Page): Request[] {
|
||||
const requests: Request[] = [];
|
||||
page.on('request', (request) => requests.push(request));
|
||||
return requests;
|
||||
}
|
||||
|
||||
/** A request's URL, parsed — the readable way to reach `searchParams`. */
|
||||
export function requestUrl(request: Request): URL {
|
||||
return new URL(request.url());
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Read the app JWT from the context's stored auth state. No navigation needed:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user