Compare commits

..

3 Commits

Author SHA1 Message Date
Abhi kumar
9197cb8b3b fix(dashboard-v2): open the View modal and editor on the panel's own query (#12345)
* fix(dashboard-v2): seed the query builder before the View modal mounts

The query builder context is global and outlives the View modal, so it still
holds the previously-viewed panel's query when the modal for the next panel
mounts. The modal only re-seeded it in a mount effect, and the builder's fields
seed themselves on mount — PromQL inputs are uncontrolled and QueryAddOns picks
its visible rows once — so they kept rendering the previous panel's query until
the modal was closed and reopened.

Seed the context in the open handlers instead, before the modal renders.
resetQuery (not initQueryBuilderData): the latter sets a staged query, and
swapping one staged id for another makes useSyncTimeOnStagedQueryChange
re-anchor global time, refetching every panel on the grid. Clearing the staged
query also stops the previous panel's query being committed into the new
panel's draft, and keeps the provider's URL hydration running for drilldowns.

* fix: fixed bar panel ordering issue

* test(dashboard-v2): drop the userEvent delay in the View modal suites

`userEvent.setup()` defaults to `delay: 0`, which awaits a macrotask between
every sub-event of a click. Each await flushes React, so every one re-renders
the real query builder these two suites mount — enough to push both add-on
tests past jest's 5s default timeout on CI.

`delay: null` dispatches the sequence synchronously: the add-on tests drop from
3172ms/1171ms to ~495ms/262ms and PromQL from 815ms to ~455ms, with the full
tree still rendered and every assertion unchanged. Verified to still fail
against the unseeded builder, so the regressions stay pinned.

* fix(dashboard-v2): carry the opened panel's query in compositeQuery

The query builder context is mounted above the router, so opening the panel
editor or the View modal leaves the previously-opened panel's query in it.
Fields that read the query once on mount then keep showing that panel: PromQL
inputs are uncontrolled and their rows are keyed by query name, and QueryAddOns
picks its visible rows once.

Both surfaces now state the panel's query in the URL as compositeQuery, so they
derive from the URL and survive a refresh or a shared link (V1 does the same in
WidgetHeader.onEditHandler). The URL alone isn't enough — the provider applies
it in an effect, a tick after those fields have mounted — so the same query goes
into the context before navigating. resetQuery, not initQueryBuilderData:
swapping one staged id for another makes useSyncTimeOnStagedQueryChange
re-anchor global time and refetch the grid.

* fix(dashboard-v2): order time series by mean, sharing the bar sort

Review feedback: the sort shouldn't be a bar-panel local. The mean/tiebreak
rules move to the visualization layer as a generic `sortByMeanDesc`, with a
`PanelSeries` adapter in `Panels/utils` that the bar and time-series renderers
both call, so any other bar consumer can adopt it in one line.

Time series flipped for the same reason bar did — the backend returns a query's
series in Go map-iteration order — so it now sorts too: draw order, legend order
and the View modal's legend table are deterministic across refreshes. Colours
are unaffected, since `generateColor` hashes the label rather than the position.

The order still has to be settled before the uPlot config and aligned data are
built: those two and drilldown click attribution index the flattened list
positionally, and uPlot draws a stacked bar's segments in series-index order, so
sorting inside `BarChart` paints the accumulated total over every segment.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-07-31 06:20:40 +00:00
dependabot[bot]
a0a17a99a6 chore(deps): bump google.golang.org/grpc in /scripts/promqltestcorpus (#12321)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.3 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.82.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 06:10:21 +00:00
Ashwin Bhatkal
8fb5703eb1 fix(dashboard-v2): variable defaults and clear affordance + other fixes (#12343)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(dashboard-v2): apply a variable's configured default reliably

Three separate ways a variable ignored the default its definition carries:

- the reconcile predates the ALL option and filled `options[0]` whenever nothing
  was selected, so an ALL-enabled multi-select never landed on ALL;
- the seed treated any stored entry as a selection, though a selection object is
  truthy even holding '' or [] — an entry persisted empty shadowed the default
  forever, so editing a textbox variable's value in settings never took effect;
- the bar synthesised an empty selection for the render that precedes the seed, and
  the textbox snapshots what it is handed into local state on mount, so a variable
  carrying a value opened with an empty box that filled only on focus + blur.

Precedence is now the same everywhere, through the one resolver: the configured
default first, then ALL where the variable offers it, then the first option. A
stored ALL still wins, even before its option array materializes.

The seed also stops rewriting the store when it resolves to what is already there —
it re-runs on every spec identity change, and an identical write re-rendered every
selection subscriber for nothing.

* fix(dashboard-v2): skip the cascade when a variable's value is unchanged

Committing a selection always wrote the store and enqueued the variable's
dependents, so re-picking the value a variable already held refetched every
dependent variable and re-ran every panel query for nothing. Guarded at the single
choke point, order-independently.

* fix(dashboard-v2): keep ALL readable while a variable's options load

The control renders an ALL selection from the option array, so until those options
arrive there is nothing to show and it falls back to its placeholder: the variable
reads "Select value" next to a spinner, as though nothing were selected. Concrete
values do not have this problem — they render from the selection itself.

Say ALL in that slot while the options are pending. Display only, so it can never
be committed as a value.

* perf(dashboard-v2): settle a variable load without a redundant write

A load committed the same selection twice and refetched everything downstream once
for nothing:

- a CUSTOM variable's options need no request, yet the seed stored ALL as a flag and
  left the post-fetch reconcile to expand it into the concrete array;
- that reconcile runs on a selector's first render, before the seed commits, so its
  auto-fill arrived already satisfied — and the flush wrote it and cascaded anyway.

The seed now resolves against options it already knows (materializing ALL, dropping
values the list no longer offers), and an auto-fill the store already satisfies is
dropped, with the cascade narrowed to the variables that actually moved.

Measured on a dashboard with a text and a custom multi-select variable: a settled
load goes from two store writes and one dependent refetch to one write and none, and
a re-render from a dashboard refetch adds neither.

* fix(dashboard-v2): stop a single-select dynamic variable blocking every save

Saving forced `allowAllValue: true` for every dynamic variable while
`allowMultiple` followed the model, so a single-select one went out with ALL set
and no multi-select. The API rejects that combination, and because a save
re-serializes the whole variable list, one such variable failed every edit to the
dashboard — the error even points at that variable's index rather than the one
being edited, which makes it look unrelated.

ALL is a set of values, so it now requires multi-select for every type. Dynamic
variables still always expose it when they are multi-select (V1 parity).

* enh(dashboard-v2): show the variable clear only where it can act

The clear icon appeared on hover of the closed control — an action in a row of
variable pills whose result you cannot see, since the list it clears is not on
screen — and it committed the default immediately in that state. It was also offered
while ALL was selected, where the shared control refuses to empty the selection, so
the icon sat there doing nothing.

It now exists only while the list is open, and not while every option is selected:
unchecking ALL in the list is the way out of that. Clicking it empties the list and
commits nothing; closing fills in whatever the variable should hold — its configured
default, else ALL where it offers one, else the first option.
2026-07-30 19:08:30 +00:00
99 changed files with 2293 additions and 8027 deletions

View File

@@ -38,8 +38,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

View File

@@ -227,14 +227,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
@@ -268,14 +265,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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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,

View File

@@ -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}

View File

@@ -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">

View File

@@ -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

View File

@@ -21,7 +21,7 @@ export const timelineTableColumns = ({
sorter: true,
width: 140,
render: (value): JSX.Element => (
<div className="alert-rule-state" data-testid="timeline-row-state">
<div className="alert-rule-state">
<AlertState state={value} showLabel />
</div>
),
@@ -30,7 +30,7 @@ export const timelineTableColumns = ({
title: 'LABELS',
dataIndex: 'labels',
render: (labels): JSX.Element => (
<div className="alert-rule-labels" data-testid="timeline-row-labels">
<div className="alert-rule-labels">
<AlertLabels labels={labels} />
</div>
),
@@ -40,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>
),
@@ -56,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>
@@ -68,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>

View File

@@ -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',
},
];

View File

@@ -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>

View File

@@ -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 === ' ') {

View File

@@ -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>

View File

@@ -0,0 +1,66 @@
import { sortByMeanDesc } from '../sortByMeanDesc';
interface Item {
name: string;
values: (number | null)[];
}
const item = (name: string, values: (number | null)[]): Item => ({
name,
values,
});
const sorted = (items: Item[]): string[] =>
sortByMeanDesc(items, {
getValues: (entry) => entry.values,
getKey: (entry) => entry.name,
}).map((entry) => entry.name);
describe('sortByMeanDesc', () => {
it('orders items by descending mean', () => {
expect(
sorted([item('a', [1, 1]), item('b', [10, 20]), item('c', [5, 5])]),
).toStrictEqual(['b', 'c', 'a']);
});
it('sinks items with no finite values to the bottom', () => {
expect(
sorted([item('a', []), item('b', [NaN, Infinity]), item('c', [1])]),
).toStrictEqual(['c', 'a', 'b']);
});
it('ignores non-finite and null values when averaging', () => {
// Mean over the finite values only is 10, so NaN must not poison it to last place.
expect(
sorted([item('a', [2, 2]), item('b', [10, NaN]), item('c', [4, null])]),
).toStrictEqual(['b', 'c', 'a']);
});
it('produces the same order whichever order the items arrive in', () => {
const a = item('a', [5]);
const b = item('b', [5]);
const c = item('c', [9]);
expect(sorted([a, b, c])).toStrictEqual(sorted([c, b, a]));
expect(sorted([b, a, c])).toStrictEqual(['c', 'a', 'b']);
});
it('tiebreaks equal means on the key', () => {
expect(sorted([item('b', [1]), item('a', [1])])).toStrictEqual(['a', 'b']);
});
it('tiebreaks on the key for items that have no finite values either', () => {
expect(sorted([item('b', []), item('a', [NaN])])).toStrictEqual(['a', 'b']);
});
it('does not mutate the input array', () => {
const items = [item('a', [1]), item('b', [9])];
expect(sorted(items)).toStrictEqual(['b', 'a']);
expect(items.map((entry) => entry.name)).toStrictEqual(['a', 'b']);
});
it('returns an empty array for no items', () => {
expect(sorted([])).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,45 @@
type MeanInput = number | null | undefined;
function finiteMean(values: Iterable<MeanInput>): number | null {
let sum = 0;
let count = 0;
for (const value of values) {
if (typeof value === 'number' && Number.isFinite(value)) {
sum += value;
count += 1;
}
}
return count > 0 ? sum / count : null;
}
export interface SortByMeanDescOptions<T> {
/** Numeric values of an item; non-finite entries are ignored. */
getValues: (item: T) => Iterable<MeanInput>;
/** Stable identity of an item, used to break ties on equal means. */
getKey: (item: T) => string;
}
/**
* Orders series by descending mean, on a copy (V1 parity: `utils/getSortedSeriesData`). The wire
* order can't stand in for it: the backend returns series in Go map-iteration order.
*
* Call this before building the uPlot config and aligned data — those two and click attribution
* all index the list positionally, and uPlot draws a stacked bar's segments in series-index order.
*/
export function sortByMeanDesc<T>(
items: T[],
{ getValues, getKey }: SortByMeanDescOptions<T>,
): T[] {
return items
.map((item) => ({ item, mean: finiteMean(getValues(item)) }))
.sort((a, b) => {
if (a.mean !== null && b.mean !== null && a.mean !== b.mean) {
return b.mean - a.mean;
}
if ((a.mean === null) !== (b.mean === null)) {
return a.mean === null ? 1 : -1;
}
return getKey(a.item).localeCompare(getKey(b.item));
})
.map(({ item }) => item);
}

View File

@@ -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,

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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 => {

View File

@@ -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"

View File

@@ -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}

View File

@@ -47,29 +47,21 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<div data-testid="alert-header-state">
<AlertState state={alertRuleState ?? state ?? ''} />
</div>
<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 && (
<div data-testid="alert-header-severity">
<AlertSeverity severity={labels.severity} />
</div>
)}
{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()}
/> */}
<div data-testid="alert-header-labels">
<AlertLabels labels={labelsWithoutSeverity} />
</div>
<AlertLabels labels={labelsWithoutSeverity} />
</div>
</div>
);

View File

@@ -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 />

View File

@@ -0,0 +1,97 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
}
/** The list spec a saved variable carries, whatever its plugin. */
function listSpec(m: VariableFormModel): {
allowMultiple?: boolean;
allowAllValue?: boolean;
} {
return formModelToDto(m).spec as {
allowMultiple?: boolean;
allowAllValue?: boolean;
};
}
describe('formModelToDto — the ALL flag needs multi-select', () => {
it('keeps ALL for a multi-select dynamic variable', () => {
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: true,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: true });
});
it('drops ALL for a single-select dynamic variable', () => {
// The API rejects allowAllValue without allowMultiple, and this used to be forced
// true for every dynamic variable — which blocked saving the whole dashboard.
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: false,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('drops ALL for a single-select query variable that still carries the flag', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: false,
showAllOption: true,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('respects the toggle on a multi-select query variable', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: true,
showAllOption: false,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: false });
});
it('round-trips a single-select dynamic variable unchanged', () => {
// What the dashboard in the report holds: saving it must not flip the flag.
const dto = {
kind: 'ListVariable',
spec: {
name: 'host.name',
display: { name: 'host.name', description: '' },
allowMultiple: false,
allowAllValue: false,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'host.name', signal: 'all' },
},
},
} as never;
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
allowMultiple: false,
allowAllValue: false,
});
});
});

View File

@@ -133,9 +133,14 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
// forced showALLOption true on save); other types respect the toggle. Either
// way it needs multi-select — ALL is a set of values, and the API rejects the
// flag without it, which used to make a single-select dynamic variable
// unsaveable and blocked every other edit to the dashboard with it.
allowAllValue:
model.multiSelect &&
(model.type === 'DYNAMIC' ? true : model.showAllOption),
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,

View File

@@ -0,0 +1,164 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
import { MemoryRouter, Route, useHistory, useParams } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import appStore from 'store';
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory: useRouterHistory } =
jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useRouterHistory();
return {
safeNavigate: (to: string): void => history.push(to),
};
},
};
});
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
function makePromPanel(name: string, promql: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/PromQLQuery',
spec: { name: 'A', query: promql, legend: '', disabled: false },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
A: makePromPanel('Panel A', 'up{job="alpha"}'),
B: makePromPanel('Panel B', 'up{job="bravo"}'),
};
const noop = (): void => {};
/** Stands in for the editor route: the same draft + builder sync `PanelEditorContainer` runs. */
function EditorRoute(): JSX.Element {
const { panelId } = useParams<{ panelId: string }>();
const [panel] = useState(PANELS[panelId]);
usePanelEditorQuerySync({
draft: panel,
panelType: PANEL_TYPES.TIME_SERIES,
setSpec: noop,
refetch: noop,
signal: TelemetrytypesSignalDTO.metrics,
savedQueries: panel.spec.queries,
});
return (
<PanelEditorQueryBuilder
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}
onCancelQuery={noop}
/>
);
}
function Harness(): JSX.Element {
const openPanelEditor = useOpenPanelEditor();
const history = useHistory();
return (
<>
<button
type="button"
data-testid="edit-a"
onClick={(): void => openPanelEditor('A', { panel: PANELS.A })}
>
edit A
</button>
<button
type="button"
data-testid="edit-b"
onClick={(): void => openPanelEditor('B', { panel: PANELS.B })}
>
edit B
</button>
<button
type="button"
data-testid="back"
onClick={(): void => history.push('/dashboard/dash-1')}
>
back
</button>
<Route
path="/dashboard/:dashboardId/panel/:panelId"
component={EditorRoute}
/>
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>
<TooltipProvider>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</TooltipProvider>
</ReduxProvider>
</QueryClientProvider>
</CompatRouter>
</MemoryRouter>,
);
};
describe('Panel editor route, PromQL panels', () => {
it('opens on the edited panel query, not the previously edited one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('edit-a'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="alpha"}',
);
await user.click(screen.getByTestId('back'));
await user.click(screen.getByTestId('edit-b'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="bravo"}',
);
});
});

View File

@@ -27,6 +27,7 @@ import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
import { buildBarChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -70,7 +71,12 @@ function BarPanelRenderer({
const flatSeries = useMemo(
() =>
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
sortSeriesByMeanDesc(
flattenTimeSeries(
getTimeSeriesResults(data.response),
data.legendMap ?? {},
),
),
[data.response, data.legendMap],
);

View File

@@ -27,6 +27,7 @@ import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
import { buildTimeSeriesConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -71,7 +72,12 @@ function TimeSeriesPanelRenderer({
const flatSeries = useMemo(
() =>
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
sortSeriesByMeanDesc(
flattenTimeSeries(
getTimeSeriesResults(data.response),
data.legendMap ?? {},
),
),
[data.response, data.legendMap],
);

View File

@@ -0,0 +1,95 @@
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { sortSeriesByMeanDesc } from '../sortSeriesByMean';
function makeSeries(
queryName: string,
values: number[],
overrides: Partial<PanelSeries> = {},
): PanelSeries {
return {
queryName,
legend: '',
labels: {},
kind: 'series',
values: values.map((value, index) => ({
timestamp: (index + 1) * 1_000,
value,
})),
aggregation: { index: 0, alias: '' },
...overrides,
};
}
const names = (series: PanelSeries[]): string[] =>
series.map((item) => item.queryName);
describe('sortSeriesByMeanDesc', () => {
it('orders series by descending mean', () => {
const sorted = sortSeriesByMeanDesc([
makeSeries('A', [1, 1]),
makeSeries('B', [10, 20]),
makeSeries('C', [5, 5]),
]);
expect(names(sorted)).toStrictEqual(['B', 'C', 'A']);
});
it('sinks series with no finite points to the bottom', () => {
const sorted = sortSeriesByMeanDesc([
makeSeries('A', []),
makeSeries('B', [NaN, Infinity]),
makeSeries('C', [1]),
]);
expect(names(sorted)).toStrictEqual(['C', 'A', 'B']);
});
it('ignores non-finite points when averaging', () => {
const sorted = sortSeriesByMeanDesc([
makeSeries('A', [2, 2]),
// Mean over finite points only is 10, so NaN must not poison it to last place.
makeSeries('B', [10, NaN]),
]);
expect(names(sorted)).toStrictEqual(['B', 'A']);
});
it('produces the same order regardless of input order', () => {
const a = makeSeries('A', [5]);
const b = makeSeries('B', [5]);
const c = makeSeries('C', [9]);
expect(names(sortSeriesByMeanDesc([a, b, c]))).toStrictEqual(
names(sortSeriesByMeanDesc([c, b, a])),
);
expect(names(sortSeriesByMeanDesc([b, a, c]))).toStrictEqual(['C', 'A', 'B']);
});
it('tiebreaks equal means on labels when the query name matches', () => {
const forward = sortSeriesByMeanDesc([
makeSeries('A', [1], { labels: { host: 'b' } }),
makeSeries('A', [1], { labels: { host: 'a' } }),
]);
const reversed = sortSeriesByMeanDesc([
makeSeries('A', [1], { labels: { host: 'a' } }),
makeSeries('A', [1], { labels: { host: 'b' } }),
]);
expect(forward.map((item) => item.labels.host)).toStrictEqual(['a', 'b']);
expect(reversed.map((item) => item.labels.host)).toStrictEqual(['a', 'b']);
});
it('does not mutate the input array', () => {
const input = [makeSeries('A', [1]), makeSeries('B', [9])];
const sorted = sortSeriesByMeanDesc(input);
expect(names(input)).toStrictEqual(['A', 'B']);
expect(names(sorted)).toStrictEqual(['B', 'A']);
});
it('returns an empty array for no series', () => {
expect(sortSeriesByMeanDesc([])).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,23 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getPanelDefinition } from '../registry';
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
/**
* The panel's saved query as a builder `Query` — what the editor route and the View
* modal put in `compositeQuery` when they open. Matches the seed
* `usePanelEditorQuerySync` computes from the panel.
*/
export function getPanelBuilderQuery(panel: DashboardtypesPanelDTO): Query {
const kind = panel.spec.plugin.kind;
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
if (panel.spec.queries.length === 0 && defaultSignal) {
return initialQueriesMap[defaultSignal];
}
return fromPerses(panel.spec.queries, PANEL_KIND_TO_PANEL_TYPE[kind]);
}

View File

@@ -0,0 +1,18 @@
import { sortByMeanDesc } from 'container/DashboardContainer/visualization/charts/utils/sortByMeanDesc';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
function seriesKey(series: PanelSeries): string {
const labels = Object.keys(series.labels)
.sort()
.map((name) => `${name}=${series.labels[name]}`)
.join(',');
return `${series.queryName}|${series.aggregation.index}|${series.kind}|${labels}`;
}
/** `sortByMeanDesc` over flattened V5 series; call it before building the config and chart data. */
export function sortSeriesByMeanDesc(series: PanelSeries[]): PanelSeries[] {
return sortByMeanDesc(series, {
getValues: (item) => item.values.map((point) => point.value),
getKey: seriesKey,
});
}

View File

@@ -240,7 +240,10 @@ describe('usePanelActionItems', () => {
(i) => 'key' in i && i.key === 'edit-panel',
);
(edit as { onClick: () => void }).onClick();
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
// The panel rides along so its saved query lands in the editor URL.
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1', {
panel: mockPanel,
});
});
it('"Move to section" offers a single "Dashboard (root)" target', () => {
@@ -394,7 +397,9 @@ describe('usePanelActionItems', () => {
(i) => 'key' in i && i.key === 'view-panel',
);
(view as { onClick: () => void }).onClick();
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
// The panel goes along so the opener can seed the shared query builder before
// the modal mounts (otherwise it renders the previously-viewed panel's query).
expect(mockOpenView).toHaveBeenCalledWith('panel-1', baseArgs.panel);
});
it('create-alert seeds an alert from this panel', () => {

View File

@@ -130,7 +130,7 @@ export function usePanelActionItems({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
onClick: (): void => openView(panelId),
onClick: (): void => openView(panelId, panel),
});
}
if (canEdit && canEditWidget && panelCapabilities.edit) {
@@ -139,7 +139,7 @@ export function usePanelActionItems({
label: label('Edit panel'),
icon: <PenLine size={14} />,
disabled: isLocked,
onClick: (): void => openPanelEditor(panelId),
onClick: (): void => openPanelEditor(panelId, { panel }),
});
}
if (canEdit && canEditWidget && panelCapabilities.clone) {

View File

@@ -0,0 +1,253 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import appStore from 'store';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// CodeMirror (the where-clause editor) needs real DOM measurement APIs.
beforeAll(() => {
const rect = {
width: 100,
height: 20,
top: 0,
left: 0,
right: 100,
bottom: 20,
x: 0,
y: 0,
toJSON: (): unknown => rect,
} as DOMRect;
const rects = { length: 1, item: (): DOMRect => rect, 0: rect };
document.createRange = (): Range =>
({
getClientRects: (): unknown => rects,
getBoundingClientRect: (): DOMRect => rect,
setStart: (): void => {},
setEnd: (): void => {},
startContainer: document.body,
endContainer: document.body,
startOffset: 0,
endOffset: 0,
collapsed: true,
commonAncestorContainer: document.body,
}) as unknown as Range;
Element.prototype.getBoundingClientRect = (): DOMRect => rect;
});
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest
.fn()
.mockResolvedValue({ data: { data: { keys: {} } } }),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
() => ({
usePanelQuery: (): unknown => ({
data: undefined,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
() =>
function MockPreviewPane(): ReactElement {
return <div data-testid="preview-pane" />;
},
);
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
enableDrillDown: false,
onPanelClick: jest.fn(),
contextMenuProps: {
coordinates: null,
popoverPosition: null,
items: null,
onClose: jest.fn(),
},
}),
}));
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: { syncMode: 0 },
}),
}));
jest.mock(
'../ViewPanelModal/ViewPanelModalHeader',
() =>
function MockViewPanelModalHeader(): ReactElement {
return <div data-testid="view-panel-header" />;
},
);
function makePanel(
name: string,
extras: Record<string, unknown>,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
disabled: false,
filter: { expression: "service = 'x'" },
aggregations: [{ expression: 'count()' }],
...extras,
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
plain: makePanel('Plain panel', {}),
grouped: makePanel('Grouped panel', {
groupBy: [{ name: 'host.name', fieldDataType: 'string' }],
having: { expression: 'count() > 5' },
}),
};
function Harness(): JSX.Element {
const { expandedPanelId, openView, closeView } = useViewPanel();
return (
<>
<button
type="button"
data-testid="open-plain"
onClick={(): void => openView('plain', PANELS.plain)}
>
open plain
</button>
<button
type="button"
data-testid="open-grouped"
onClick={(): void => openView('grouped', PANELS.grouped)}
>
open grouped
</button>
<button type="button" data-testid="close" onClick={closeView}>
close
</button>
<ViewPanelModal
open={!!expandedPanelId}
panel={expandedPanelId ? PANELS[expandedPanelId] : undefined}
panelId={expandedPanelId ?? undefined}
onClose={closeView}
/>
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>
<TooltipProvider>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</TooltipProvider>
</ReduxProvider>
</QueryClientProvider>
</CompatRouter>
</MemoryRouter>,
);
};
// QueryAddOns picks which rows are visible once on mount and never re-seeds, so unlike
// the field values these never recover from a context swap that lands after mount.
describe('View modal, builder add-on rows', () => {
it('shows the opened panel add-ons, not the previous panel ones', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderHarness();
await user.click(screen.getByTestId('open-grouped'));
expect(screen.getByTestId('group-by-content')).toBeInTheDocument();
expect(screen.getByTestId('having-content')).toBeInTheDocument();
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId('open-plain'));
expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument();
expect(screen.queryByTestId('having-content')).not.toBeInTheDocument();
});
it('shows add-ons the opened panel has after a panel without them', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderHarness();
await user.click(screen.getByTestId('open-plain'));
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId('open-grouped'));
expect(screen.getByTestId('group-by-content')).toBeInTheDocument();
expect(screen.getByTestId('having-content')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,187 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import appStore from 'store';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
() => ({
usePanelQuery: (): unknown => ({
data: undefined,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
() =>
function MockPreviewPane(): ReactElement {
return <div data-testid="preview-pane" />;
},
);
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
enableDrillDown: false,
onPanelClick: jest.fn(),
contextMenuProps: {
coordinates: null,
popoverPosition: null,
items: null,
onClose: jest.fn(),
},
}),
}));
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: { syncMode: 0 },
}),
}));
jest.mock(
'../ViewPanelModal/ViewPanelModalHeader',
() =>
function MockViewPanelModalHeader(): ReactElement {
return <div data-testid="view-panel-header" />;
},
);
function makePromPanel(name: string, promql: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/PromQLQuery',
spec: { name: 'A', query: promql, legend: '', disabled: false },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
A: makePromPanel('Panel A', 'up{job="alpha"}'),
B: makePromPanel('Panel B', 'up{job="bravo"}'),
};
function Harness(): JSX.Element {
const { expandedPanelId, openView, closeView } = useViewPanel();
return (
<>
<button
type="button"
data-testid="open-a"
onClick={(): void => openView('A', PANELS.A)}
>
open A
</button>
<button
type="button"
data-testid="open-b"
onClick={(): void => openView('B', PANELS.B)}
>
open B
</button>
<button type="button" data-testid="close" onClick={closeView}>
close
</button>
<ViewPanelModal
open={!!expandedPanelId}
panel={expandedPanelId ? PANELS[expandedPanelId] : undefined}
panelId={expandedPanelId ?? undefined}
onClose={closeView}
/>
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>
<TooltipProvider>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</TooltipProvider>
</ReduxProvider>
</QueryClientProvider>
</CompatRouter>
</MemoryRouter>,
);
};
describe('View modal, PromQL panels', () => {
it('shows the opened panel query, not the previously viewed one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderHarness();
await user.click(screen.getByTestId('open-a'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="alpha"}',
);
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId('open-b'));
expect(screen.getByTestId('promql-query-input')).toHaveValue(
'up{job="bravo"}',
);
});
});

View File

@@ -0,0 +1,285 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
() => ({
usePanelQuery: (): unknown => ({
data: undefined,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}),
);
function makePanel(name: string, expression: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
disabled: false,
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const PANELS: Record<string, DashboardtypesPanelDTO> = {
A: makePanel('Panel A', "service = 'alpha'"),
B: makePanel('Panel B', "service = 'bravo'"),
};
const panelOf = (json: string): string => {
if (json.includes('alpha')) {
return 'A';
}
if (json.includes('bravo')) {
return 'B';
}
return 'none';
};
/** Every render of the open modal, so a single stale frame can't hide. */
const renders: { current: string; draft: string; filterItems: number }[] = [];
const stagedIds: (string | undefined)[] = [];
function ModalBody({ panelId }: { panelId: string }): JSX.Element {
const { currentQuery } = useQueryBuilder();
const { draft } = useViewPanelMode({
panel: PANELS[panelId],
panelId,
time: { startMs: 0, endMs: 1000 },
});
renders.push({
current: panelOf(JSON.stringify(currentQuery)),
draft: panelOf(JSON.stringify(draft.spec.queries)),
filterItems: currentQuery.builder.queryData[0]?.filters?.items.length ?? 0,
});
return <div data-testid="modal-body" />;
}
function SearchProbe(): JSX.Element {
return <div data-testid="search">{useLocation().search}</div>;
}
function StagedQueryProbe(): null {
const { stagedQuery } = useQueryBuilder();
if (stagedIds.at(-1) !== stagedQuery?.id) {
stagedIds.push(stagedQuery?.id);
}
return null;
}
const drilldownQuery = (base: Query): Query => ({
...base,
builder: {
...base.builder,
queryData: base.builder.queryData.map((q) => ({
...q,
filter: { expression: "service = 'bravo' AND host = 'h1'" },
})),
},
});
function Harness(): JSX.Element {
const { expandedPanelId, openView, openViewWithQuery, closeView } =
useViewPanel();
return (
<>
<button
type="button"
data-testid="open-a"
onClick={(): void => openView('A', PANELS.A)}
>
open A
</button>
<button
type="button"
data-testid="open-b"
onClick={(): void => openView('B', PANELS.B)}
>
open B
</button>
<button
type="button"
data-testid="drilldown-b"
onClick={(): void =>
openViewWithQuery(
'B',
drilldownQuery(
fromPerses(PANELS.B.spec.queries, PANEL_TYPES.TIME_SERIES),
),
PANEL_TYPES.TIME_SERIES,
)
}
>
drilldown B
</button>
<button type="button" data-testid="close" onClick={closeView}>
close
</button>
<StagedQueryProbe />
<SearchProbe />
{expandedPanelId && <ModalBody panelId={expandedPanelId} />}
</>
);
}
const renderHarness = (): void => {
render(
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryBuilderProvider>
<Harness />
</QueryBuilderProvider>
</CompatRouter>
</MemoryRouter>,
);
};
describe('useViewPanel', () => {
beforeEach(() => {
renders.length = 0;
stagedIds.length = 0;
});
// The builder context is global and outlives the modal; its fields seed themselves
// on mount, so a late swap leaves them on the previously-viewed panel.
it('seeds the builder with the opened panel before the modal renders', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
renders.length = 0;
await user.click(screen.getByTestId('open-b'));
expect(renders.length).toBeGreaterThan(0);
expect(renders.every((r) => r.current === 'B')).toBe(true);
});
it("carries the opened panel's query in the URL", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-b'));
const search = new URLSearchParams(
screen.getByTestId('search').textContent ?? '',
);
expect(search.get(QueryParams.expandedWidgetId)).toBe('B');
const carried = search.get(QueryParams.compositeQuery);
expect(panelOf(decodeURIComponent(carried as string))).toBe('B');
expect(search.get(QueryParams.graphType)).toBeNull();
});
// The edit session commits any staged query on mount, so a staged query left over
// from the previous panel would make this panel's preview fetch the old query.
it('never lets the previous panel query reach the new panel draft', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
renders.length = 0;
await user.click(screen.getByTestId('open-b'));
expect(renders.every((r) => r.draft === 'B')).toBe(true);
});
// The drilldown URL carries the query's own id, so a staged query with that id makes
// the provider skip hydration — losing the normalisation that fills `filters.items`.
it('still lets the provider hydrate a drilldown query from the URL', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
renders.length = 0;
await user.click(screen.getByTestId('drilldown-b'));
expect(renders.every((r) => r.current === 'B')).toBe(true);
expect(renders.at(-1)?.filterItems).toBeGreaterThan(0);
});
// `useSyncTimeOnStagedQueryChange` (dashboard toolbar) re-anchors global time when one
// non-null staged id replaces another, refetching every panel behind the modal.
it.each([['open-b'], ['drilldown-b']])(
'never swaps one staged query for another (%s)',
async (trigger) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByTestId('open-a'));
await user.click(screen.getByTestId('close'));
await user.click(screen.getByTestId(trigger));
const swapsWithoutNull = stagedIds.some(
(id, i) => i > 0 && id !== undefined && stagedIds[i - 1] !== undefined,
);
expect(swapsWithoutNull).toBe(false);
},
);
});

View File

@@ -1,11 +1,14 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { getPanelBuilderQuery } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelBuilderQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
@@ -13,8 +16,8 @@ import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
openView: (panelId: string) => void;
/** Open the View modal on the saved panel, its query carried in `compositeQuery`. */
openView: (panelId: string, panel: DashboardtypesPanelDTO) => void;
/**
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
@@ -30,30 +33,38 @@ export interface UseViewPanelApi {
/**
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
* is shareable, survives refresh, and the browser back-button closes it.
* panel and `compositeQuery` the query it opens on, which a drilldown retargets along with
* `graphType`. URL-backed state is shareable, survives refresh, and browser Back closes it.
*/
export function useViewPanel(): UseViewPanelApi {
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
const { resetQuery } = useQueryBuilder();
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
const openView = useCallback(
(panelId: string): void => {
(panelId: string, panel: DashboardtypesPanelDTO): void => {
// Copy before mutating: useUrlQuery returns a memoized instance.
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
// on the saved panel, not stale state the modal would otherwise hydrate from.
next.delete(QueryParams.compositeQuery);
// Only a drilldown retargets the panel type.
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
const query = getPanelBuilderQuery(panel);
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
// The provider applies the URL in an effect, a tick after the builder's fields have
// mounted and read the query they keep. `resetQuery` — not `initQueryBuilderData`:
// swapping one staged id for another re-anchors global time and refetches the grid.
resetQuery(query);
void logEvent(DashboardDetailEvents.PanelViewed, { panelId });
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
[pathname, safeNavigate, urlQuery, resetQuery],
);
const openViewWithQuery = useCallback(
@@ -63,6 +74,10 @@ export function useViewPanel(): UseViewPanelApi {
next.set(QueryParams.graphType, panelType);
// A grid drilldown opens on the saved panel, never a stale editor handoff.
clearViewPanelHandoff();
// As in `openView`. Clearing the staged query matters twice over here: the URL
// below carries this query's own id, and a staged query with a matching id
// makes the provider skip the hydration that normalises legacy filter fields.
resetQuery(query);
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
@@ -71,7 +86,7 @@ export function useViewPanel(): UseViewPanelApi {
);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
[pathname, safeNavigate, urlQuery, resetQuery],
);
const closeView = useCallback((): void => {

View File

@@ -13,6 +13,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import { useVariableSelection } from './hooks/useVariableSelection';
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
@@ -96,12 +97,10 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
variable={variable}
variables={variables}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
// Until the seed commits a selection, stand in the same default it will
// commit, through the one resolver — an empty stand-in reads as "nothing
// selected" to a control that snapshots it on mount.
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
onChange={(next): void => setSelection(variable.name, next)}
onAutoSelect={(next): void => autoSelect(variable.name, next)}
/>

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VariableSelection } from '../selectionTypes';
import TextSelector from '../components/selectors/TextSelector';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
function renderSelector(
selection: VariableSelection,
onChange = jest.fn(),
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
const { rerender } = render(
<TextSelector
selection={selection}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
);
return {
onChange,
rerender: (next): void =>
rerender(
<TextSelector
selection={next}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
),
};
}
function input(): HTMLInputElement {
return screen.getByTestId('variable-input-service') as HTMLInputElement;
}
describe('TextSelector', () => {
it('shows the value the seed commits after mount', () => {
// The bar mounts before the seed resolves the definition's default, so the first
// render sees nothing selected. The box must follow the value once it lands.
const { rerender } = renderSelector({ value: '', allSelected: false });
rerender({ value: 'flower', allSelected: false });
expect(input().value).toBe('flower');
});
it('follows a selection replaced from outside (share link, reset)', () => {
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
rerender({ value: 'rose', allSelected: false });
expect(input().value).toBe('rose');
});
it('keeps what the user types until they commit it', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
await user.clear(input());
await user.type(input(), 'tulip');
// Still local — a keystroke must not cascade to dependent panels.
expect(input().value).toBe('tulip');
expect(onChange).not.toHaveBeenCalled();
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'tulip',
allSelected: false,
});
});
it('restores the default when emptied', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
await user.clear(input());
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'flower',
allSelected: false,
});
expect(input().value).toBe('flower');
});
});

View File

@@ -87,4 +87,99 @@ describe('ValueSelector', () => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
describe('an ALL selection whose options are still loading', () => {
it('reads ALL, not the "Select value" placeholder', () => {
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
// which carries no values at all) — the control must not read as unselected.
renderSelector({ value: null, allSelected: true }, [], true);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toHaveTextContent('ALL');
});
it('still shows concrete values while options load', () => {
renderSelector(
{ value: ['checkout-service-prod'], allSelected: false },
[],
true,
);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toBeNull();
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
expect(clearIcon()).toBeNull();
});
it('offers it once the list is open', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await openDropdown();
expect(clearIcon()).not.toBeNull();
});
it('offers no clear icon while every option is selected', async () => {
// ALL is every option, so there is nothing to clear — and the shared control
// refuses to empty an ALL selection, which would leave the icon inert.
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
await openDropdown();
expect(clearIcon()).toBeNull();
});
it('empties the list and commits nothing until it closes', async () => {
const onChange = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="query"
multiSelect
showAllOption
selection={{ value: VALUES, allSelected: false }}
onChange={onChange}
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
await openDropdown();
await user.click(clearIcon() as Element);
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
0,
);
expect(onChange).not.toHaveBeenCalled();
// Closing fills in whatever the variable should hold.
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenCalledWith({
value: [OPTIONS[0]],
allSelected: false,
});
});
});
});

View File

@@ -61,6 +61,47 @@ describe('useAutoSelect', () => {
expect(next).toBeUndefined();
});
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('falls back to ALL, not the first option, when every selected value is gone', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('still honours a configured default over ALL', () => {
const next = run(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),

View File

@@ -97,6 +97,125 @@ describe('useSeedVariableSelection', () => {
});
});
it('prefers the configured default over a stored empty text value', () => {
// Persisted from a session where the box was never filled — the default the
// definition carries must win, or the variable reads as unset forever.
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: '', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'prod',
allSelected: false,
});
});
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: [], allSelected: false } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
// Custom options need no request, so ALL is materialized here rather than left as
// a flag for the post-fetch reconcile to expand.
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b'],
allSelected: true,
});
});
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b,c',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b', 'c'],
allSelected: true,
});
});
it('drops values a custom variable no longer offers, at seed time', () => {
useDashboardStore.getState().setVariableValues('d1', {
env: { value: ['a', 'gone'], allSelected: false },
});
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a'],
allSelected: false,
});
});
it('keeps a stored ALL selection that has not materialized yet', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: null, allSelected: true } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: null,
allSelected: true,
});
});
it('does not rewrite the store when the effect re-runs with the same values', () => {
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
// re-running the seed. Writing an identical map would re-render every subscriber.
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
const { rerender } = renderHook(
({ dash }) => useSeedVariableSelection(dash),
{ initialProps: { dash: dashboard('d1', [variable]) } },
);
const afterSeed = useDashboardStore.getState().variableValues;
rerender({ dash: dashboard('d1', [{ ...variable }]) });
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
});
it('initializes the fetch context with idle states for every variable', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT' }),

View File

@@ -0,0 +1,152 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useVariableSelection } from '../hooks/useVariableSelection';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
useQueryState: (): unknown => [null, jest.fn()],
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
const env = model({
name: 'env',
type: 'QUERY',
multiSelect: true,
queryValue: 'SELECT env',
});
const svc = model({
name: 'svc',
type: 'QUERY',
queryValue: 'SELECT svc WHERE env = $env',
});
const dashboard = {
id: 'd1',
spec: { variables: [env, svc] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
function svcCycleId(): number {
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
}
describe('useVariableSelection — setSelection', () => {
beforeEach(() => {
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
it('does not re-cascade when the picked value is the one already held', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
const afterFirstPick = svcCycleId();
// Same values, then the same set in a different order: neither is a change, so
// the dependent must not be re-fetched.
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
act(() => {
result.current.setSelection('env', {
value: ['b', 'a'],
allSelected: false,
});
});
expect(svcCycleId()).toBe(afterFirstPick);
});
it('ignores an auto-fill that the store already satisfies', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
// What a selector's first-render reconcile produces before the seed commits: a
// value the store then resolves to on its own.
await act(async () => {
result.current.autoSelect('env', { value: ['a'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(svcCycleId()).toBe(before);
});
it('still applies an auto-fill that changes the value', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
await act(async () => {
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
it('still cascades a genuine change', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
act(() => {
result.current.setSelection('env', {
value: ['a', 'c'],
allSelected: false,
});
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'c'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
});

View File

@@ -9,6 +9,7 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../../selectionTypes';
import { textDefault } from '../../utils/resolveVariableSelection';
import { computeVariableDependencies } from '../../utils/variableDependencies';
import TextSelector from '../selectors/TextSelector';
import VariableValueControl from '../selectors/VariableValueControl';
@@ -65,7 +66,7 @@ function VariableSelector({
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
defaultValue={textDefault(variable)}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
@@ -31,6 +31,17 @@ function TextSelector({
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
// The committed value can land after this input mounts — the seed resolves the
// definition's default one tick later, and a share link or a reset can replace it
// at any point. Without following it the box would keep showing its first render
// (empty, since nothing is seeded yet) until the user focused and blurred it.
// Typing never touches the selection, so an edit in progress cannot be interrupted.
useEffect(() => {
setValue(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
}, [selection.value, defaultValue]);
const commit = useCallback(
(next: string): void => {
void logEvent(

View File

@@ -54,11 +54,26 @@ function ValueSelector({
[selection, options],
);
// That "all" path needs the options, so an ALL selection whose options have not
// arrived yet has nothing to render and the control would read "Select value"
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
// selection is known, only its options are pending. Display only, so it can never
// be committed as a value.
const isAllPendingOptions = selection.allSelected && options.length === 0;
// Buffer edits while the dropdown is open; the committed selection is shown
// when closed. This defers the dependent cascade to a single commit-on-close.
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(committedValues);
// ALL is every option, so there is nothing to clear — and the shared control refuses
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
// in the list is the way out of it.
const draftIsAll =
showAllOption &&
options.length > 0 &&
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
@@ -94,8 +109,11 @@ function ValueSelector({
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
allowClear
placeholder="Select value"
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
// not visible.
allowClear={isOpen && !draftIsAll}
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
@@ -129,12 +147,10 @@ function ValueSelector({
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
variableType,
});
// Empties the list, committing nothing. Closing resolves an empty draft
// to whatever the variable should hold — its configured default, else ALL
// where it offers one, else the first option.
setDraft([]);
// A clear on the closed control falls back to the default immediately;
// while open it just empties the draft (committed on close).
if (!isOpen) {
onChange(emptyFallback);
}
}}
/>
);

View File

@@ -6,7 +6,13 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
areSelectionsEqual,
reconcileWithOptions,
resolveDefaultSelection,
} from '../utils/resolveVariableSelection';
import { hasUsableValue } from '../utils/selectionUtils';
import type {
SelectedVariableValue,
VariableSelection,
@@ -18,6 +24,44 @@ import {
} from '../utils/variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
function isStoredSelectionSet(
stored: VariableSelection,
model: VariableFormModel,
): boolean {
return !!stored.allSelected || hasUsableValue(stored, model.type);
}
/** Whether the seeded map matches, entry for entry, what the store already holds. */
function isSameSelection(
seeded: VariableSelectionMap,
current: VariableSelectionMap,
): boolean {
const names = Object.keys(seeded);
return (
names.length === Object.keys(current).length &&
names.every(
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
)
);
}
/**
* A seeded value taken as far as it can go: for a variable whose options need no
* request, resolve against them now — ALL becomes the concrete array, values the list
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
* once the options arrive.
*/
function resolveAgainstKnownOptions(
value: VariableSelection,
model: VariableFormModel,
): VariableSelection {
const options = knownVariableOptions(model);
if (options.length === 0) {
return value;
}
return reconcileWithOptions(model, value, options) ?? value;
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -70,22 +114,31 @@ export function useSeedVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
const seed = (value: VariableSelection): void => {
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
};
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seeded[variable.name] =
seed(
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
: fromUrl,
);
} else if (stored && isStoredSelectionSet(stored, variable)) {
seed(stored);
} else {
seeded[variable.name] = resolveDefaultSelection(variable);
seed(resolveDefaultSelection(variable));
}
});
setVariableValues(dashboardId, seeded);
// This runs again whenever the spec's variable array changes identity — a refetch
// or any spec edit — and writing an identical map would re-render every selection
// subscriber for nothing.
if (!isSameSelection(seeded, selection)) {
setVariableValues(dashboardId, seeded);
}
// Read-once: a share link's `?variables=` seeds the store, then the param is
// dropped so the store is the sole source of truth. Selection changes never

View File

@@ -1,14 +1,11 @@
import { useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import {
sortValuesByOrder,
VARIABLE_TYPE_EVENT_LABEL,
} from '../../DashboardSettings/Variables/variableFormModel';
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../selectionTypes';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
useFetchedVariableOptions,
type VariableOptions,
@@ -30,14 +27,12 @@ export function useVariableOptions(
): VariableOptions {
const fetched = useFetchedVariableOptions(variable, variables, selections);
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
// refetch hands back an equal-but-new model, and a new options array would re-fire
// the post-fetch reconcile for nothing.
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
() => knownVariableOptions(variable),
// eslint-disable-next-line react-hooks/exhaustive-deps
[variable.type, variable.customValue, variable.sort],
);

View File

@@ -12,6 +12,7 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
import { useSeedVariableSelection } from './useSeedVariableSelection';
/**
@@ -103,6 +104,10 @@ export function useVariableSelection(
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
const current = selectionRef.current[name];
if (current && areSelectionsEqual(next, current)) {
return;
}
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
},
@@ -124,8 +129,19 @@ export function useVariableSelection(
if (names.length === 0 || !dashboardId) {
return;
}
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
enqueueDescendantsBatch(names);
// A fill can arrive already satisfied: a selector reconciles against the options
// on its first render, before the seed has committed, and the seed then resolves
// to the same value. Refresh only what actually moved, so a settled load ends
// without a write or a dependent refetch.
const current = selectionRef.current;
const changed = names.filter(
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
);
if (changed.length === 0) {
return;
}
setVariableValues(dashboardId, { ...current, ...fills });
enqueueDescendantsBatch(changed);
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(

View File

@@ -0,0 +1,23 @@
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
/**
* The options of a variable whose list needs no request — a CUSTOM variable's comma
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
* TEXT, which has none.
*
* Knowing them up front lets the seed resolve such a variable's value completely
* (materializing ALL, dropping values the list no longer offers) instead of leaving
* that to the post-fetch reconcile, which would cost a second store write and a
* refetch of everything downstream.
*/
export function knownVariableOptions(model: VariableFormModel): string[] {
if (model.type !== 'CUSTOM') {
return [];
}
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
String,
);
}

View File

@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
}
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
function textDefault(model: VariableFormModel): string {
export function textDefault(model: VariableFormModel): string {
return firstConfiguredDefault(model) ?? model.textValue;
}
@@ -53,15 +53,31 @@ function isAllDefault(
);
}
/** The configured default (or first option) as a fresh selection. */
/**
* The default selection for a variable with nothing usable selected: its configured
* default, else ALL for an ALL-enabled multi-select, else the first option.
*/
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
const initial = fallback && options.includes(fallback) ? fallback : options[0];
if (fallback && options.includes(fallback)) {
return {
value: model.multiSelect ? [fallback] : fallback,
allSelected: false,
};
}
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
// to an arbitrary first option — the same default the seed applies (keep in sync
// with resolveDefaultSelection).
if (model.multiSelect && model.showAllOption) {
return materializeAll(model, options, null) ?? ALL_SELECTION;
}
return {
value: model.multiSelect ? [initial] : initial,
value: model.multiSelect ? [options[0]] : options[0],
allSelected: false,
};
}

View File

@@ -1,5 +1,7 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { EQueryType } from 'types/common/dashboard';
import { useOpenPanelEditor } from '../useOpenPanelEditor';
@@ -81,6 +83,37 @@ describe('useOpenPanelEditor', () => {
);
});
it("carries the panel's saved query into the editor URL", () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const panel = {
kind: 'Panel',
spec: {
display: { name: 'Panel A' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/PromQLQuery',
spec: { name: 'A', query: 'up{job="alpha"}', disabled: false },
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9', { panel });
const [url] = mockSafeNavigate.mock.calls[0];
const carried = new URLSearchParams(url.split('?')[1]).get('compositeQuery');
const parsed = JSON.parse(decodeURIComponent(carried as string));
expect(parsed.queryType).toBe(EQueryType.PROM);
expect(parsed.promql[0].query).toBe('up{job="alpha"}');
});
it('merges search with the time window (leading ? tolerated)', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());

View File

@@ -1,9 +1,13 @@
import { useCallback } from 'react';
import { generatePath } from 'react-router-dom';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from './useTimeSearchParams';
import logEvent from '@/api/common/logEvent';
@@ -13,6 +17,8 @@ interface OpenPanelEditorOptions {
handoffState?: PanelEditorHandoffState;
/** Extra query merged into the editor URL (leading `?` optional). */
search?: string;
/** The panel being edited — its query rides in the URL as `compositeQuery`. */
panel?: DashboardtypesPanelDTO;
}
/** Opens the V2 panel editor, carrying the active time window in the URL. */
@@ -23,6 +29,7 @@ export function useOpenPanelEditor(): (
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { resetQuery } = useQueryBuilder();
return useCallback(
(panelId: string, options?: OpenPanelEditorOptions): void => {
@@ -39,12 +46,24 @@ export function useOpenPanelEditor(): (
new URLSearchParams(timeSearch).forEach((value, key) => {
params.set(key, value);
});
if (options?.panel) {
const query = getPanelBuilderQuery(options.panel);
// Single-encoded: `useGetCompositeQueryParam` decodes once on top of the decode
// `URLSearchParams` already does.
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
// The provider applies the URL in an effect, a tick after the builder's fields
// have mounted and read the query they keep (PromQL inputs, add-on rows).
resetQuery(query);
}
const search = params.toString();
safeNavigate(
search ? `${path}?${search}` : path,
options?.handoffState ? { state: options.handoffState } : undefined,
);
},
[safeNavigate, dashboardId, timeSearch],
[safeNavigate, dashboardId, timeSearch, resetQuery],
);
}

View File

@@ -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}

View File

@@ -70,24 +70,24 @@ require (
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/goleak v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.272.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -74,8 +74,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -355,16 +355,16 @@ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0/go.mod h1:gO9smoZe9KnZcJCqcB0lMmQ4Z5VEifYmjMTpnwtTSuQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -377,40 +377,40 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,451 +0,0 @@
import type { Browser } from '@playwright/test';
import {
createEmailChannelViaApi,
createLogsAlertViaApi,
createMetricAlertViaApi,
createNoDataAlertViaApi,
createTracesAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
readTimelineTotal,
seedAlertHistoryLogs,
seedAlertHistoryMetrics,
seedAlertHistoryTraces,
setRuleDisabledViaApi,
waitForTimelineEntries,
waitForTimelineStates,
} from '../helpers/alerts';
import { expect, test as base, withAdminPage } from './alert-rules';
// 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 the logs fixtures, ~10s for metrics, ~105s for
// the firing→resolved wave. 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.
//
// See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3 for the recipes and the
// empirically-measured timings each budget here is derived from.
/** Distinct `service.name` values SEED-A seeds ⇒ its timeline row count. */
const SEED_A_SERVICES = 25;
/** SEED-F seeds fewer services and a 1m window so it resolves inside ~105s. */
const SEED_F_SERVICES = 3;
/** SEED-E's group-by values ⇒ a 2-row history that fits on one page. */
const SEED_E_HOSTS = ['host-0', 'host-1'];
/** SEED-H seeds just enough services to prove the traces link; keeps the wait short. */
const SEED_H_SERVICES = 3;
/** Non-severity label on SEED-C, so the v1 header's labels row is non-empty. */
export const SEED_C_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);
}
});
}
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;
}
>({
/**
* SEED-A (25-row firing history, v2) **plus** SEED-C (the same logs seen
* through a legacy v1 rule). Both rules share one seeded log batch, so the
* two ruler waves overlap and the fixture costs roughly one wait, not two.
*/
alertHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e alert history ${stamp}`;
let channelId = '';
let ruleId = '';
let ruleIdV1 = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
channelId = channel.id;
// Seed and create in the same breath: the rules only fire while the
// records are inside the 5m eval window.
const services = await seedAlertHistoryLogs(page, {
marker,
services: SEED_A_SERVICES,
servicePrefix: `e2e-ah-svc`,
});
ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v2-${stamp}`,
marker,
channels: [channel.name],
schema: 'v2',
});
ruleIdV1 = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v1-${stamp}`,
marker,
channels: [channel.name],
schema: 'v1',
// The v1 header renders `labels` minus `severity`, so without a
// second label its labels row is present but empty (AD-02).
extraLabels: { team: SEED_C_TEAM_LABEL },
});
await waitForTimelineEntries(page, ruleId, { min: SEED_A_SERVICES });
await waitForTimelineEntries(page, ruleIdV1, { min: SEED_A_SERVICES });
// Freeze both before the eval window rolls past the seeded records —
// otherwise the resolve wave doubles `total` mid-suite.
await setRuleDisabledViaApi(page, ruleId, true);
await setRuleDisabledViaApi(page, ruleIdV1, true);
return {
ruleId,
ruleIdV1,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
totalV1: await readTimelineTotal(page, ruleIdV1),
};
});
if (seed.total !== SEED_A_SERVICES) {
// A different total means the fixture is not what the scenarios were
// written against — most likely the resolve wave landed before the
// PATCH froze the rule. Fail loudly here rather than let every
// downstream count assertion fail with a confusing off-by-N.
throw new Error(
`SEED-A expected ${SEED_A_SERVICES} timeline rows, got ${seed.total}`,
);
}
await use(seed);
await cleanup(browser, { ruleIds: [ruleId, ruleIdV1], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-E — a metrics rule over two hosts. Two things SEED-A can't give:
* history rows with **no** related links (links are derived from the rule's
* signal), and a 2-row history that fits on a single page.
*/
metricsHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const metricName = `e2e_ah_probe_metric_${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-metrics-ch-${stamp}`,
);
channelId = channel.id;
await seedAlertHistoryMetrics(page, {
metricName,
hosts: SEED_E_HOSTS,
});
ruleId = await createMetricAlertViaApi(page, {
name: `e2e-ah-metrics-rule-${stamp}`,
metricName,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: SEED_E_HOSTS.length,
timeoutMs: 120_000,
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
metricName,
hosts: SEED_E_HOSTS,
total: await readTimelineTotal(page, ruleId),
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-H — a traces rule over seeded spans. The only fixture whose history
* rows carry `relatedTracesLink`: the backend derives the link from the
* rule's signal and returns either a logs link or a traces link, never both,
* so the "View Traces" popover entry is unreachable from SEED-A.
*/
tracesHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e-aht-span-${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-traces-ch-${stamp}`,
);
channelId = channel.id;
const services = await seedAlertHistoryTraces(page, {
marker,
services: SEED_H_SERVICES,
servicePrefix: 'e2e-aht-svc',
});
ruleId = await createTracesAlertViaApi(page, {
name: `e2e-ah-traces-rule-${stamp}`,
marker,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, { min: SEED_H_SERVICES });
// Same reason as SEED-A: freeze before the eval window rolls past the
// seeded spans and the resolve wave doubles `total`.
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 240_000 },
],
/**
* SEED-F — firing **and** resolved, without touching the seeder: a 1m eval
* window means the seeded records fall out of it fast, so the rule resolves
* on its own in ~105s. This is the only fixture that produces a non-zero
* average resolution time and a 3-segment overall-status graph.
*/
resolvedHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
const marker = `e2e alert resolved ${stamp}`;
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-resolved-ch-${stamp}`,
);
channelId = channel.id;
const services = await seedAlertHistoryLogs(page, {
marker,
services: SEED_F_SERVICES,
ageSeconds: 40,
minAgeSeconds: 28,
servicePrefix: 'e2e-ahr-svc',
});
ruleId = await createLogsAlertViaApi(page, {
name: `e2e-ah-resolved-rule-${stamp}`,
marker,
channels: [channel.name],
evalWindow: '1m0s',
});
const timeline = await waitForTimelineStates(page, ruleId, {
states: {
firing: SEED_F_SERVICES,
inactive: SEED_F_SERVICES,
},
});
await setRuleDisabledViaApi(page, ruleId, true);
return {
ruleId,
channelName: channel.name,
marker,
services,
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
};
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 300_000 },
],
/**
* SEED-G — a `nodata` row, reached the same way
* `integration/testdata/alerts/test_scenarios/no_data_rule_test` does:
* `alertOnAbsent` on a query that matches nothing.
*/
noDataHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-nodata-ch-${stamp}`,
);
channelId = channel.id;
ruleId = await createNoDataAlertViaApi(page, {
name: `e2e-ah-nodata-rule-${stamp}`,
// Deliberately unseeded — the query must match nothing.
marker: `e2e alert nodata ${stamp}`,
channels: [channel.name],
});
await waitForTimelineEntries(page, ruleId, {
min: 1,
state: 'nodata',
timeoutMs: 180_000,
});
await setRuleDisabledViaApi(page, ruleId, true);
return { ruleId, channelName: channel.name };
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 300_000 },
],
/**
* A rule that will never have history: its query matches nothing and it is
* disabled immediately. Covers "no history yet" (empty table, zero stats) and
* "no key suggestions" without waiting on the ruler at all.
*/
emptyHistory: [
async ({ browser }, use) => {
const stamp = Date.now();
let channelId = '';
let ruleId = '';
const seed = await withAdminPage(browser, async (page) => {
const channel = await createEmailChannelViaApi(
page,
`e2e-ah-empty-ch-${stamp}`,
);
channelId = channel.id;
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 { ruleId, channelName: channel.name };
});
await use(seed);
await cleanup(browser, { ruleIds: [ruleId], channelId });
},
{ scope: 'worker', timeout: 120_000 },
],
});
export { expect };

View File

@@ -1,214 +0,0 @@
import type { Browser, Page } from '@playwright/test';
import {
type AlertSchema,
createEmailChannelViaApi,
createLogsAlertViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
type LogsAlertSeed,
seedAlertRules,
type ThresholdAlertSeed,
} from '../helpers/alerts';
import { newAdminContext } from '../helpers/auth';
import { expect, test as base } from './auth';
// 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 `fixtures/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. SEED-B, the 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 and is SEED-RV1; 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>;
}
/** SEED-B size. 12 over a pinned page size of 10 ⇒ a short second page. */
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);
}
});
}
export const test = base.extend<
{ ownedRules: OwnedRules },
{ alertChannel: AlertChannel; alertList: AlertListSeed }
>({
alertChannel: [
async ({ browser }, use, workerInfo) => {
const channel = await withAdminPage(browser, (page) =>
createEmailChannelViaApi(
page,
`e2e-alerts-ch-w${workerInfo.workerIndex}-${Date.now()}`,
),
);
await use(channel);
await withAdminPage(browser, (page) =>
deleteChannelViaApi(page, channel.id),
);
},
{ scope: 'worker' },
],
alertList: [
async ({ browser, alertChannel }, use, workerInfo) => {
const stamp = `w${workerInfo.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: alertChannel.name,
namePrefix,
teamSuffix,
}),
);
await use({
channelName: alertChannel.name,
namePrefix,
count: LIST_SEED_COUNT,
paymentsLabel: `payments${teamSuffix}`,
ruleIds,
});
await deleteRules(browser, ruleIds);
},
{ scope: 'worker', timeout: 120_000 },
],
ownedRules: async ({ browser, alertChannel }, use) => {
const ids = new Set<string>();
const seed = async (
create: (page: Page) => Promise<string>,
): Promise<string> => {
const id = await withAdminPage(browser, create);
ids.add(id);
return id;
};
await use({
threshold: (name, overrides = {}) =>
seed((page) =>
createThresholdAlertViaApi(page, {
name,
target: 42,
channels: [alertChannel.name],
labels: { severity: 'critical' },
...overrides,
}),
),
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
seed((page) =>
createLogsAlertViaApi(page, {
name,
marker: marker ?? `e2e alert never seeded ${name}`,
channels: [alertChannel.name],
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));
}
},
});
// Best-effort: `deleteAlertViaApi` tolerates a rule a scenario already
// deleted through the UI.
await deleteRules(browser, [...ids]);
},
});
export { expect };

View File

@@ -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

View File

@@ -1,732 +0,0 @@
import { expect, type Locator, type Page } from '@playwright/test';
// Helpers for the alert *create* and *edit* forms — two different form
// implementations behind one feature. Rule *seeding* lives in
// `helpers/alerts.ts`; this module is about driving the UI, so nothing here talks
// to the rules API except through the browser.
//
// The single most important thing to know before using any of this: v1 and v2
// are not two skins over one form, they are two components with different
// selectors, different save gates and different success feedback. Every helper
// below is therefore named for the form it drives (`v1…` / `v2…`) unless it is
// genuinely shared, and the shared set is small.
// ─── 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-103`) and its
* dropdown labels (`context/constants.ts:123-128`).
*/
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' },
} 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,
];
// ─── 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}`);
}
export 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();
}
// ─── 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 asserts the obstruction directly, so when the
* footer is fixed that scenario fails and this helper can go back to `.click()`.
*/
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 the scenario that pins the bug so the
* workaround above never becomes invisible.
*/
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);
}
/**
* 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.
*/
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);
}
/** 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,
);
}
/**
* 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 }) });
}
/**
* 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');
}
/**
* 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 ───────────────────────────────────────────
/**
* 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;
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}`);
}
// ─── v1 classic form ───────────────────────────────────────────────────────
// Every locator below addresses a testid added to the classic form for this suite.
// Before those, each of these was an antd label or role lookup. If one stops
// resolving, the testid was dropped from the component, not renamed here.
/**
* 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');
}
export function v1TestButton(page: Page): Locator {
return page.getByTestId('alert-test-button');
}
/** *Cancel* on create, *Discard* on edit (`FormAlertRules/index.tsx:991-992`). */
export function v1CancelButton(page: Page): Locator {
return page.getByTestId('alert-cancel-button');
}
export function v1NameInput(page: Page): Locator {
return page.getByTestId('alert-name-input-v1');
}
export function v1DescriptionInput(page: Page): Locator {
return page.getByTestId('alert-description-input');
}
export function v1SeveritySelect(page: Page): Locator {
return page.getByTestId('alert-severity-select');
}
/** The four `RuleOptions` controls, in the order the condition sentence reads. */
export function v1OperatorSelect(page: Page): Locator {
return page.getByTestId('alert-threshold-op-select');
}
export function v1MatchTypeSelect(page: Page): Locator {
return page.getByTestId('alert-threshold-match-type-select-v1');
}
export function v1EvalWindowSelect(page: Page): Locator {
return page.getByTestId('alert-eval-window-select');
}
/**
* The threshold value. antd's `InputNumber` spreads unknown props straight onto
* its inner `<input>` (rc-input-number), *not* onto the `.ant-input-number`
* wrapper — so the testid is already the field and looking for an `input`
* underneath it finds nothing.
*/
export function v1ThresholdInput(page: Page): Locator {
return page.getByTestId('alert-threshold-target-input');
}
export function v1BroadcastSwitch(page: Page): Locator {
return page.getByTestId('alert-broadcast-to-all-channels');
}
export function v1ChannelSelect(page: Page): Locator {
return page.getByTestId('alert-channel-select');
}
/**
* Pick a channel in the classic form. Deliberately **not** {@link v1SelectOption}:
* that helper scrolls to nothing and clicks the option by label, which cannot work
* on a virtualised list — see {@link pickChannelByName} for the measurement. The
* other v1 selects (operator, match type, evaluation window, severity) have a
* handful of options each and no search box, so they keep using `v1SelectOption`.
*/
export async function v1SelectChannel(
page: Page,
channelName: string,
): Promise<void> {
await pickChannelByName(page, v1ChannelSelect(page), channelName);
}
/**
* v1 gates every save behind a confirm dialog: the Save button only opens it
* (`FormAlertRules/index.tsx:653-655`), and both the field validation and the
* request live in `saveRule`, which the dialog's OK invokes (`:1007-1010`).
* A spec that clicks Save and waits for a POST without this step will time out —
* and one that expects a *validation error* without it will too.
*/
export function v1ConfirmDialog(page: Page): Locator {
return page.getByTestId('alert-save-confirm-dialog');
}
export async function v1ConfirmSave(page: Page): Promise<void> {
await expect(v1ConfirmDialog(page)).toBeVisible();
await v1ConfirmDialog(page).getByRole('button', { name: 'OK' }).click();
}
/** Dismiss the confirm dialog without saving — the CV1-07 half that must not POST. */
export async function v1CancelSave(page: Page): Promise<void> {
await expect(v1ConfirmDialog(page)).toBeVisible();
await v1ConfirmDialog(page).getByRole('button', { name: 'Cancel' }).click();
await expect(v1ConfirmDialog(page)).toBeHidden();
}
/**
* Pick an option in one of v1's antd selects. Scoped through {@link ownDropdown}
* because the condition sentence puts four selects side by side, and matched on
* the exact label because several share option text (*Above* / *Below* appear in
* both the operator and the match-type lists in the anomaly variant).
*/
export async function v1SelectOption(
page: Page,
select: Locator,
label: string,
): Promise<void> {
await select.click();
const dropdown = await ownDropdown(page, select);
await dropdown
.locator('.ant-select-item-option')
.filter({ has: page.getByText(label, { exact: true }) })
.first()
.click();
// The channel select is `mode="multiple"` (`ChannelSelect/index.tsx:91`), so it
// stays open after a pick and its list overlays the controls below — which the
// next interaction would hit instead of its target. Escape closes it; on the
// single selects it is a no-op.
await page.keyboard.press('Escape');
await expect(select).not.toHaveClass(/ant-select-open/);
}
/** Switch the v1 query section to another query mode (`QuerySection.tsx`). */
export async function v1SelectQueryMode(
page: Page,
mode: 'query-builder' | 'promql' | 'clickhouse',
): Promise<void> {
await page.getByTestId(`${mode}-tab`).click();
}
// ─── 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: [] }),
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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',
'any',
];
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:

View File

@@ -5,12 +5,7 @@
"main": "index.js",
"scripts": {
"preinstall": "npx only-allow pnpm",
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown e2e/bootstrap/setup.py::test_teardown",
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
"test": "playwright test",
"test:local": "pnpm env:start && pnpm test",
"test:all": "playwright test",
"test:staging": "SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",

View File

@@ -1,37 +1,15 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import fs from 'fs';
import os from 'os';
import path from 'path';
// Precedence, lowest to highest:
// .env — user-provided defaults (staging creds)
// .env.local — written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally, so it must win over
// any stale .env value
// the real environment — anything the caller exported on purpose, e.g.
// `SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test` to run
// against a locally served frontend, or the vars pytest injects
// when it shells out to `pnpm test`.
//
// This is deliberately *not* `dotenv.config({ override: true })`: that flag
// makes the file beat process.env, so an exported SIGNOZ_E2E_BASE_URL was
// silently discarded and every run went to whatever .env.local pointed at.
// Parsing by hand is the only way to get ".env.local beats .env" without also
// getting ".env.local beats the caller".
const exported = new Set(Object.keys(process.env));
for (const file of ['.env', '.env.local']) {
const filePath = path.resolve(__dirname, file);
if (!fs.existsSync(filePath)) {
continue;
}
const parsed = dotenv.parse(fs.readFileSync(filePath));
for (const [key, value] of Object.entries(parsed)) {
if (!exported.has(key)) {
process.env[key] = value;
}
}
}
// .env holds user-provided defaults (staging creds).
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally; override=true so local-backend
// coordinates win over any stale .env values. Subprocess-injected env
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
// dotenv doesn't touch vars that are already set in process.env.
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
export default defineConfig({
testDir: './tests',
@@ -55,17 +33,8 @@ export default defineConfig({
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Workers. Playwright's local default is `cpus / 2`, which on a 32-core box is
// 16 — and 16 is strictly worse than 6 here, because every worker's browser
// shares one SigNoz container: measured on `tests/alerts/{create,edit}` at
// `--repeat-each=3` (224 tests), 16 workers took 128 s with 3 failures while 6
// took 119 s with none. Past ~6 the extra workers only add queueing, which shows
// up as 4-6 s app mounts and save requests that outlive the test timeout — i.e.
// as flakes that look like product bugs. Capped rather than fixed at 6 so a
// 4-core laptop still gets `cpus / 2`.
workers: process.env.CI
? 2
: Math.max(1, Math.min(6, Math.floor(os.cpus().length / 2))),
// Workers
workers: process.env.CI ? 2 : undefined,
// The SPA hydrates slowly on CI, so the 5s expect default fires mid-load.
expect: { timeout: 15_000 },

View File

@@ -0,0 +1,67 @@
import { expect, test } from '../../fixtures/auth';
import {
createEmailChannelViaApi,
createThresholdAlertViaApi,
deleteAlertViaApi,
deleteChannelViaApi,
gotoAlertOverview,
} from '../../helpers/alerts';
import { newAdminContext } from '../../helpers/auth';
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
});
test.describe('alerts — threshold persists on edit-page load', () => {
const TARGET = 245;
let ruleId: string;
let channelId: string;
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const stamp = Date.now();
const channel = await createEmailChannelViaApi(
page,
`e2e-threshold-persistence-ch-${stamp}`,
);
channelId = channel.id;
ruleId = await createThresholdAlertViaApi(page, {
name: `e2e-threshold-persistence-${stamp}`,
target: TARGET,
channels: [channel.name],
});
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
if (ruleId) {
await deleteAlertViaApi(page, ruleId);
}
if (channelId) {
await deleteChannelViaApi(page, channelId);
}
} finally {
await ctx.close();
}
});
test('TC-02 edit page shows the saved threshold value', async ({
authedPage: page,
}) => {
await gotoAlertOverview(page, ruleId);
// The condition editor should show the persisted target once loaded.
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
String(TARGET),
);
});
});

View File

@@ -1,125 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
AlertType,
elementAtPointClassName,
gotoCreateAlertV1,
gotoCreateAlertV2,
selectThresholdChannel,
v1SaveButton,
v2DiscardButton,
v2SaveButton,
} from '../../../helpers/alert-forms';
import {
createEmailChannelViaApi,
deleteChannelViaApi,
gotoAlertDetails,
gotoAlertOverview,
} from '../../../helpers/alerts';
import { watchConsole } from '../../../helpers/common';
// CE-* — errors and edges that are not specific to one form. CE-03 lives in
// `edit/edge.spec.ts`; CE-05/CE-06 are v1-only validation and live with the v1
// specs.
test.describe('Alert create — errors and edges', () => {
test('CE-04 a server-side rejection opens the error modal and keeps the draft', async ({
authedPage: page,
}) => {
// A duplicate rule name is *not* rejected — the API happily creates two rules
// with the same `alert`. A missing channel is, with
// `400 invalid_input: channels: the following channels do not exist`.
//
// So the 4xx comes from a real race rather than a stub: the form is filled with
// a channel that exists, and the channel is deleted behind its back before the
// save. Nothing about the response is faked.
const channel = await createEmailChannelViaApi(
page,
`e2e-ce04-ch-${Date.now()}`,
);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-ce04-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, channel.name);
await deleteChannelViaApi(page, channel.id);
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
expect(response.status()).toBe(400);
// Both forms funnel every save error into the shared error modal, which is
// antd's wrapped in `.error-modal__wrap`.
await expect(page.locator('.error-modal__wrap')).toBeVisible();
await expect(page.getByText(/do not exist/)).toBeVisible();
// A rejected save must not navigate, and must not lose what the user typed.
expect(new URL(page.url()).pathname).toBe('/alerts/new');
await page.getByTestId('close-button').click();
await expect(page.locator('.error-modal__wrap')).toBeHidden();
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
});
test('CE-07 none of the four builder mounts logs a console error', async ({
authedPage: page,
ownedRules,
}) => {
const watch = watchConsole(page);
// v2 create.
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// v1 create. Metrics-based on purpose: it is the only alert type whose classic
// form renders the detection-method step and the PromQL tab, i.e. the most code.
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
// v2 edit.
const v2Rule = await ownedRules.threshold(`e2e-ce07-v2-${Date.now()}`);
await gotoAlertOverview(page, v2Rule);
// v1 edit. `gotoAlertOverview` is wrong here — it waits for
// `threshold-value-input`, which only the v2 builder renders — so the shell-level
// wait is used and the classic form is asserted directly.
const v1Rule = await ownedRules.logs({
name: `e2e-ce07-v1-${Date.now()}`,
schema: 'v1',
});
await gotoAlertDetails(page, v1Rule);
await expect(v1SaveButton(page)).toBeVisible();
expect(watch.errors).toEqual([]);
});
test('CE-09 the side navigation covers the v2 Discard button', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// 🐞 A user-facing bug: the footer is `position: fixed; left: 63px` (the
// *collapsed* rail width) while the nav is 240px wide whenever expanded, which
// is the default. Discard is the footer's left-most control, so the nav sits on
// top of it.
const box = await v2DiscardButton(page).boundingBox();
expect(box).not.toBeNull();
const covering = await elementAtPointClassName(
page,
box!.x + box!.width / 2,
box!.y + box!.height / 2,
);
expect(covering).toMatch(/nav-item/);
// And the consequence: a real click cannot land. `{ force: true }` would not
// help either — it skips the actionability wait but still dispatches a mouse
// event at these coordinates, which the nav receives.
await expect(v2DiscardButton(page).click({ timeout: 3_000 })).rejects.toThrow(
/intercepts pointer events/,
);
// This row exists so `v2ClickDiscard`'s `dispatchEvent` workaround cannot rot
// silently: when the footer is fixed, this test fails, and that failure is the
// signal to put the plain `.click()` back in the helper.
});
});

View File

@@ -1,264 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERTS_NEW_PATH,
AlertType,
type AlertTypeValue,
evaluationSettingsButton,
gotoCreateAlertV2,
RuleType,
thresholdRows,
ThresholdMatchType,
ThresholdOperator,
} from '../../../helpers/alert-forms';
import { gotoAlertOverview } from '../../../helpers/alerts';
// CD-* — deep-link prefill.
//
// The contract is producer-agnostic (`context/resolveUrlAlertPrefill.ts`), but the
// three producers do **not** write the same params: dashboards
// (`buildAlertUrl`) and the explorer only ever emit query/panel params, while
// metering (`MultiIngestionSettings`) is the sole producer of `ruleName`,
// `yAxisUnit` and `evaluationWindowPreset`. CD-04 and CD-05 therefore drive the
// *metering* URL shape — aiming them at a dashboard URL would test a link nobody
// generates.
/**
* A `compositeQuery` param harvested from the app itself.
*
* Hand-writing the v5 envelope would be a second, drifting copy of the query
* builder's serialiser — the thing these scenarios are *reading*, not testing. So
* the builder is opened once, allowed to serialise its own default query into the
* URL, and that exact value is reused as the deep link.
*/
async function harvestCompositeQuery(
page: Page,
alertType: AlertTypeValue,
): Promise<string> {
await gotoCreateAlertV2(page, { alertType });
const value = new URL(page.url()).searchParams.get('compositeQuery');
if (!value) {
throw new Error(
'the builder did not serialise a compositeQuery into the URL',
);
}
return value;
}
/** `Threshold` as `context/types.ts` declares it — the shape the URL param carries. */
function urlThreshold(
overrides: Record<string, unknown>,
): Record<string, unknown> {
return {
id: 'e2e-url-threshold',
label: 'from-url',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: [],
color: '#e5484d',
...overrides,
};
}
function prefillUrl(params: Record<string, string>): string {
return `${ALERTS_NEW_PATH}?${new URLSearchParams(params).toString()}`;
}
test.describe('Alert create — deep-link prefill', () => {
test('CD-01 a compositeQuery alone selects the alert type', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.LOGS);
// No `alertType` and no `ruleType` in this URL: both come from the query's data
// source through `ALERT_TYPE_VS_SOURCE_MAPPING`. The presence of
// `compositeQuery` is also what skips the type-selection page, so this one
// param decides two things at once.
await page.goto(prefillUrl({ compositeQuery }));
await expect(page.getByTestId('alert-name-input')).toBeVisible();
// Asserted on the *rendered* signal tab, not on the URL: the mapping only feeds
// the memo that picks the form — it does **not** write `alertType` back into the
// query string. So a spec waiting for `alertType=LOGS_BASED_ALERT` in the URL
// waits forever.
await expect(
page.locator('.list-view-tab.active-tab', {
has: page.getByTestId('logs-view'),
}),
).toHaveCount(1);
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
// A stale `compositeQuery` silently bypasses card selection, so the cards must
// not be on screen.
await expect(page.locator('[data-testid^="alert-type-card-"]')).toHaveCount(
0,
);
});
test('CD-02 thresholds prefill from JSON, and a malformed value falls back', async ({
authedPage: page,
}) => {
const base = {
alertType: AlertType.LOGS,
ruleType: RuleType.THRESHOLD,
};
await page.goto(
prefillUrl({
...base,
thresholds: JSON.stringify([
urlThreshold({ label: 'page-me', thresholdValue: 42 }),
urlThreshold({
id: 'e2e-url-threshold-2',
label: 'warn-me',
thresholdValue: 7,
}),
]),
}),
);
await expect(thresholdRows(page)).toHaveCount(2);
const names = page.getByTestId('threshold-name-input');
await expect(names.nth(0)).toHaveValue('page-me');
await expect(names.nth(1)).toHaveValue('warn-me');
await expect(page.getByTestId('threshold-value-input').nth(0)).toHaveValue(
'42',
);
// A malformed value is swallowed by `parseThresholds` and the form falls back to
// its own single `critical` row. That path also writes
// `console.error('Error parsing thresholds from URL:', …)`, which is why this
// scenario must never be paired with CE-07's clean-console assertion.
await page.goto(prefillUrl({ ...base, thresholds: 'not-json-at-all' }));
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
});
test('CD-03 matchType and compareOp aliases normalise to the enum', async ({
authedPage: page,
}) => {
// `avg` and `<` are aliases the *backend* accepts (`normalizeMatchType` /
// `normalizeOperator` mirror `pkg/types/ruletypes/{match,compare}.go`), not values
// the UI ever writes — so a producer or a hand-edited link can carry them.
await page.goto(
prefillUrl({
alertType: AlertType.LOGS,
ruleType: RuleType.THRESHOLD,
matchType: 'avg',
compareOp: '<',
}),
);
await expect(
page.getByTestId('alert-threshold-match-type-select'),
).toContainText(ThresholdMatchType.ON_AVERAGE.label);
await expect(
page.getByTestId('alert-threshold-operator-select'),
).toContainText(ThresholdOperator.BELOW.label);
});
test('CD-04 ruleName and yAxisUnit apply once and never stomp an edit', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
const ruleName =
'[ingestion][logs] e2e key has exceeded daily ingestion limit';
// The metering URL shape, verbatim from `MultiIngestionSettings.tsx`.
await page.goto(
prefillUrl({
compositeQuery,
thresholds: JSON.stringify([
urlThreshold({ label: 'critical', thresholdValue: 100, unit: 'bytes' }),
]),
ruleName,
yAxisUnit: 'bytes',
matchType: ThresholdMatchType.IN_TOTAL.value,
evaluationWindowPreset: 'meter',
}),
);
await expect(page.getByTestId('alert-name-input')).toHaveValue(ruleName);
// `yAxisUnit` is what makes the per-threshold unit select usable at all — with no
// unit the control is permanently disabled (CV2-12).
await expect(
page.getByTestId('threshold-unit-select').first(),
).not.toHaveClass(/ant-select-disabled/);
// Now the half the `ruleNameAppliedRef` / `yAxisUnitAppliedRef` guards exist for.
// The prefill effect re-runs on *every* change to location.search, and the query
// builder rewrites it constantly — without the refs, a hand-edited name would be
// silently reverted to the URL's the next time that happened.
const edited = 'e2e-cd-04-renamed-by-hand';
await page.getByTestId('alert-name-input').fill(edited);
// Switching the signal tab is a real user action that rewrites the URL *and*
// changes `alertType`, which is also in the effect's dependency list.
await page.getByTestId('logs-view').click();
await page.waitForURL(/alertType=LOGS_BASED_ALERT/);
await expect(page.getByTestId('alert-name-input')).toHaveValue(edited);
});
test('CD-05 evaluationWindowPreset=meter switches to the cumulative daily window', async ({
authedPage: page,
}) => {
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
await page.goto(
prefillUrl({
compositeQuery,
matchType: ThresholdMatchType.IN_TOTAL.value,
evaluationWindowPreset: 'meter',
}),
);
// `SET_INITIAL_STATE_FOR_METER` is a *cumulative* window starting at midnight
// UTC — not one of the rolling presets — so the trigger button's whole text
// changes shape, type included.
await expect(evaluationSettingsButton(page)).toContainText('Cumulative');
await expect(evaluationSettingsButton(page)).toContainText(
'Current day, starting from 00:00:00 (UTC)',
);
});
test('CD-06 URL prefill is ignored in edit mode', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-cd-06-${Date.now()}`, {
target: 42,
});
await gotoAlertOverview(page, ruleId);
// Append a prefill param to the *edit* URL, which is what a stale link or a copied
// query string produces in practice.
await page.goto(
`${new URL(page.url()).pathname}?${new URLSearchParams({
ruleId,
thresholds: JSON.stringify([
urlThreshold({ label: 'from-url', thresholdValue: 999 }),
]),
}).toString()}`,
);
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
// The effect early-returns in edit mode. Without that return the `RESET` at the
// top of the block would wipe the loaded rule's thresholds every time the query
// builder rewrote location.search.
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-value-input')).toHaveValue('42');
await expect(page.getByTestId('alert-name-input')).not.toHaveValue(
'from-url',
);
});
});

View File

@@ -1,168 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
AlertListTab,
AlertType,
alertTypeCard,
expectAlertTypeCardSet,
createAlertUrl,
gotoAlertTypeSelection,
gotoCreateAlertV1,
gotoCreateAlertV2,
hasAnomalyAlertTypeCard,
RuleType,
STOCK_ALERT_TYPE_CARDS,
v1SaveButton,
} from '../../../helpers/alert-forms';
// CS-01 … CS-08 — the create *shell*: type selection, how a card click writes the
// URL, the breadcrumb, the surrounding alerts tab bar, and the two ways to reach
// the classic form. Nothing here saves a rule, so no scenario needs a channel.
test.describe('Alert create — shell & type selection', () => {
test('CS-01 bare /alerts/new lists exactly the expected alert-type cards', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
await expect(page.getByText('Choose a type for the alert')).toBeVisible();
// The four stock signals are unconditional; anomaly is added only when
// ANOMALY_DETECTION is active. `expectAlertTypeCardSet` pins the exact set *and
// order* for whichever branch applies, so adding a sixth signal still fails.
for (const type of STOCK_ALERT_TYPE_CARDS) {
await expect(alertTypeCard(page, type)).toBeVisible();
}
await expectAlertTypeCardSet(page);
});
test('CS-02 picking a card writes both params and mounts the v2 builder', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
await alertTypeCard(page, AlertType.METRICS).click();
await expect(page.getByTestId('alert-name-input')).toBeVisible();
const params = new URL(page.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
expect(params.get('alertType')).toBe(AlertType.METRICS);
});
test('CS-03 the anomaly card rewrites the rule type, not the alert type', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
test.skip(
!(await hasAnomalyAlertTypeCard(page)),
'ANOMALY_DETECTION feature flag is inactive on this stack (see CS-01)',
);
await alertTypeCard(page, AlertType.ANOMALY).click();
const params = new URL(page.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.ANOMALY);
// The card's own value is deliberately *not* written: `handleSelectType`
// forces the metrics alert type for anomaly rules, and the rendered form
// resolves back to anomaly from `ruleType` alone.
expect(params.get('alertType')).toBe(AlertType.METRICS);
});
test('CS-04 modifier-clicking a card opens the builder in a new tab', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
const [newTab] = await Promise.all([
page.context().waitForEvent('page'),
alertTypeCard(page, AlertType.METRICS).click({
modifiers: ['ControlOrMeta'],
}),
]);
await newTab.waitForLoadState();
const params = new URL(newTab.url()).searchParams;
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
expect(params.get('alertType')).toBe(AlertType.METRICS);
// A modifier click that *also* navigates in place is the regression this half
// guards.
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
await newTab.close();
});
test('CS-05 breadcrumb gains a third crumb after a type is picked', async ({
authedPage: page,
}) => {
await gotoAlertTypeSelection(page);
const breadcrumb = page.locator('.ant-breadcrumb');
await expect(breadcrumb.getByText('Alert Rules')).toBeVisible();
await expect(breadcrumb.getByText('Select Alert Type')).toBeVisible();
await alertTypeCard(page, AlertType.METRICS).click();
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(breadcrumb.getByText('Metric-Based Alert')).toBeVisible();
// The middle crumb is now navigable and goes back to bare /alerts/new.
await breadcrumb.getByRole('button', { name: 'Select Alert Type' }).click();
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
});
test('CS-06 create renders inside the Alert Rules tab and leaving drops subTab/search', async ({
authedPage: page,
}) => {
// `subTab` and `search` are seeded here precisely so their removal is
// observable — `handleTabChange` deletes them while keeping everything else.
await page.goto(
createAlertUrl({
alertType: AlertType.LOGS,
params: { subTab: 'Alert Rules', search: 'stale' },
}),
);
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(page.getByRole('tab', { name: /Alert Rules/ })).toBeVisible();
await page.getByRole('tab', { name: /Triggered Alerts/ }).click();
await page.waitForURL(/\/alerts\?/);
const params = new URL(page.url()).searchParams;
// The param carries the space-less enum value, not the tab's visible label.
expect(params.get('tab')).toBe(AlertListTab.TRIGGERED_ALERTS);
expect(params.get('subTab')).toBeNull();
expect(params.get('search')).toBeNull();
});
test('CS-07 showClassicCreateAlertsPage=true renders the v1 form instead', async ({
authedPage: page,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await expect(v1SaveButton(page)).toBeVisible();
// The clearest v1/v2 discriminator: the v2 header input simply is not there.
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
test('CS-08 Switch to Classic Experience replaces history, so Back does not return to v2', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.METRICS });
await page
.getByRole('button', { name: 'Switch to Classic Experience' })
.click();
await expect(v1SaveButton(page)).toBeVisible();
expect(
new URL(page.url()).searchParams.get('showClassicCreateAlertsPage'),
).toBe('true');
// `safeNavigate(url, { replace: true })` — going back must skip the v2 entry
// entirely rather than bouncing between the two experiences.
await page.goBack();
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
});

View File

@@ -1,524 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
AlertType,
alertTypeCard,
gotoAlertTypeSelection,
gotoCreateAlertV1,
stubNoChannels,
v1BroadcastSwitch,
v1CancelButton,
v1CancelSave,
v1ChannelSelect,
v1ConfirmDialog,
v1ConfirmSave,
v1DescriptionInput,
v1EvalWindowSelect,
v1MatchTypeSelect,
v1NameInput,
v1OperatorSelect,
v1SelectChannel,
v1SelectOption,
v1SelectQueryMode,
v1SeveritySelect,
v1TestButton,
v1ThresholdInput,
v1SaveButton,
} from '../../../helpers/alert-forms';
// CV1-* — the v1 classic create form, reached with
// `showClassicCreateAlertsPage=true`. CE-05 and CE-06 live here too: both are
// PromQL/ClickHouse validation, which only the classic form has.
//
// Three things about this form drive almost every row below, and all three differ
// from v2:
// 1. Save only *opens* a confirm dialog. Both the field validation and the
// request run behind its OK, so a validation message is never visible until
// the dialog has been confirmed.
// 2. The Save/Test buttons are disabled by `isAlertNameMissing ||
// !isChannelConfigurationValid || queryStatus === 'error'`, so a row that
// wants to reach the dialog has to satisfy the name *and* the channels first.
// 3. Validation failures surface as antd **notifications**, not inline errors.
const VALIDATION = {
targetMissing: 'Please enter a threshold to proceed',
promql: 'promql expression is required when query format is set to PromQL',
clickhouse: 'query is required when query format is set to ClickHouse',
} as const;
/**
* Alert type for every row that actually saves.
*
* A metrics-based rule is rejected by the **server** with
* `400 invalid query 'A': metric name is required for aggregation #1`, because the
* metrics default query has no metric selected and picking one is query-builder
* territory. A logs-based alert's default query is valid with no seeded data, which
* is the same reason every `CV2-*` row uses one. Rows that assert on *rendering*
* stay metrics-based, and so does CE-05 — PromQL is offered for metrics only.
*/
const SAVEABLE = AlertType.LOGS;
/** Every v1 save and test goes to the same endpoint the v2 builder uses. */
function isRuleCreate(url: string, method: string): boolean {
return method === 'POST' && new URL(url).pathname === '/api/v2/rules';
}
/**
* Fill the minimum a v1 rule needs before Save stops being disabled: a name, a
* channel and a threshold.
*/
async function fillMinimalV1Rule(
page: import('@playwright/test').Page,
{ name, channelName }: { name: string; channelName: string },
): Promise<void> {
await v1NameInput(page).fill(name);
await v1ThresholdInput(page).fill('5');
await v1SelectChannel(page, channelName);
await expect(v1SaveButton(page)).toBeEnabled();
}
test.describe('Alert create — v1 classic form', () => {
test('CV1-01 the classic form renders its steps and the create-mode labels', async ({
authedPage: page,
}) => {
// Whether the detection-method step renders depends on `ANOMALY_DETECTION`, the
// same flag CS-01 reads off the type-selection page. Read it there rather than
// assuming, so this stays one unconditional assertion whichever way it falls.
await gotoAlertTypeSelection(page);
const anomalyEnabled =
(await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await expect(page.getByText('Metrics Based Alert')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Alert Setup Guide' }),
).toBeVisible();
// `isNewRule` decides both action labels.
await expect(v1SaveButton(page)).toHaveText(/Create Rule/);
await expect(v1CancelButton(page)).toHaveText(/Cancel/);
await expect(page.getByText('Define the metric')).toBeVisible();
await expect(page.getByText('Define Alert Conditions')).toBeVisible();
await expect(page.getByText('Alert Configuration')).toBeVisible();
await expect(page.locator('.detection-method-container')).toHaveCount(
anomalyEnabled ? 1 : 0,
);
});
test('CV1-02 the rendered severity is the default from the rule, not the select', async ({
authedPage: page,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
// Two defaults disagree: `alertDefaults.labels.severity` is `warning` while the
// select's own `defaultValue` prop says `critical`. The antd Form's
// `initialValues` wins, so what a user sees — and what the payload carries — is
// *warning*.
await expect(v1SeveritySelect(page)).toContainText('Warning');
});
test('CV1-03 one keystroke in the name field is enough to enable Save', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
// Satisfy the other two gates first so the name is the only one left.
await v1ThresholdInput(page).fill('5');
await v1SelectChannel(page, alertChannel.name);
await expect(v1SaveButton(page)).toBeDisabled();
await expect(v1TestButton(page)).toBeDisabled();
// `isAlertNameMissing` is `!formInstance.getFieldValue('alert')` read during
// render — not a subscription. It only ever looks fresh because `setAlertDef`
// re-renders on every keystroke, so the realistic failure is the *first*
// character. One character, no second keystroke, no blur.
await v1NameInput(page).pressSequentially('a');
await expect(v1SaveButton(page)).toBeEnabled();
await expect(v1TestButton(page)).toBeEnabled();
});
test('CV1-04 Save stays disabled until the channel configuration resolves', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await v1NameInput(page).fill(`e2e-cv1-04-${Date.now()}`);
await v1ThresholdInput(page).fill('5');
// A new rule starts with the broadcast switch **off** and no preferred channels,
// so `isChannelConfigurationValid` is false and there is no message anywhere —
// just a dead button. That silence is what this row pins.
await expect(v1BroadcastSwitch(page)).toHaveAttribute(
'aria-checked',
'false',
);
await expect(v1SaveButton(page)).toBeDisabled();
await v1SelectChannel(page, alertChannel.name);
await expect(v1SaveButton(page)).toBeEnabled();
});
test('CV1-05 🐞 broadcast-to-all cannot be saved at all', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await v1NameInput(page).fill(`e2e-cv1-05-${Date.now()}`);
await v1ThresholdInput(page).fill('5');
await v1BroadcastSwitch(page).click();
// The select is unmounted, not disabled, so "pick a channel" stops being
// possible rather than becoming optional.
await expect(v1ChannelSelect(page)).toHaveCount(0);
await expect(v1SaveButton(page)).toBeEnabled();
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// 🐞 **"Alert all the configured channels" is broken end to end.**
//
// `preparePostData` blanks `preferredChannels` when `broadcastToAll` is set, but
// `toPostableRuleDTOFromAlertDef` (`types/api/alerts/convert.ts`) never copies
// `broadcastToAll` into the DTO. The request therefore says "no channels, no
// broadcast", and the server refuses it with `400 at least one channel is
// required` — so the switch makes the form **unsaveable** rather than silently
// losing data later. The only working path in v1 is picking channels explicitly.
const body = response.request().postDataJSON();
expect(body.preferredChannels).toEqual([]);
expect(body.broadcastToAll).toBeUndefined();
expect(response.status()).toBe(400);
expect(await response.text()).toContain('at least one channel is required');
await expect(page.locator('.error-modal__wrap')).toBeVisible();
// Still on the form — nothing was created, so there is nothing to clean up.
expect(new URL(page.url()).pathname).toBe('/alerts/new');
});
test('CV1-06 a cleared threshold is coerced to 0, so the required-threshold branch is dead', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await v1NameInput(page).fill(`e2e-cv1-06-${Date.now()}`);
await v1SelectChannel(page, alertChannel.name);
// `Please enter a threshold to proceed` cannot be reached from the UI at all:
//
// 1. The field renders `0`, not empty — on metrics, logs and traces alike.
// 2. `RuleOptions`'s `onChange` writes `Number(value) || 0`, so clearing the
// input stores 0 rather than nothing.
// 3. `validateQBParams` guards `target !== 0 && !target`, i.e. it
// *deliberately* treats 0 as a valid threshold.
//
// What follows asserts the reachable behaviour and pins each link in that chain.
await expect(v1ThresholdInput(page)).toHaveValue('0');
await v1ThresholdInput(page).fill('');
await expect(v1SaveButton(page)).toBeEnabled();
await v1SaveButton(page).click();
await expect(v1ConfirmDialog(page)).toBeVisible();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// A rule saved with a threshold of 0 rather than a validation error.
expect(response.status(), await response.text()).toBe(201);
expect(response.request().postDataJSON().condition.target).toBe(0);
await expect(page.getByText(VALIDATION.targetMissing)).toBeHidden();
});
test('CV1-07 cancelling the confirm dialog does not save', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-07-${Date.now()}`,
channelName: alertChannel.name,
});
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
await v1SaveButton(page).click();
await expect(v1ConfirmDialog(page)).toContainText('Your alert built with');
await v1CancelSave(page);
expect(sawPost).toBe(false);
// Still on the form, nothing lost.
expect(new URL(page.url()).pathname).toBe('/alerts/new');
await expect(v1SaveButton(page)).toBeEnabled();
});
test('CV1-08 the happy path posts the v1 body shape to the shared endpoint', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
const name = `e2e-cv1-08-${Date.now()}`;
await fillMinimalV1Rule(page, { name, channelName: alertChannel.name });
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// The server's message is folded into the assertion: a v1 payload rejection is
// only diagnosable from its text.
expect(response.status(), await response.text()).toBe(201);
// The endpoint is shared with v2 — there is no `/api/v1/rules` client in the
// frontend — so the *body* is the only thing that distinguishes the two forms.
const body = response.request().postDataJSON();
expect(body.condition.target).toBe(5);
expect(body.condition.op).toBe('1');
// `4` is *in total*, not the `defaultMatchType` of `1` (*at least once*): the
// per-signal defaults disagree with the shared one. `logAlertDefaults`,
// `traceAlertDefaults` and `exceptionAlertDefaults` all hardcode `matchType: '4'`
// while `alertDefaults` — metrics — uses `defaultMatchType`.
expect(body.condition.matchType).toBe('4');
expect(body.evalWindow).toBe('5m0s');
expect(body.preferredChannels).toEqual([alertChannel.name]);
expect(body.labels.severity).toBe('warning');
// No v2 threshold envelope and no v2 schema marker anywhere in it.
expect(body.schemaVersion).toBeUndefined();
expect(body.condition.thresholds).toBeUndefined();
// antd notification, not a sonner toast — the other half of the v1/v2 split.
await expect(page.getByText('Rule created successfully')).toBeVisible();
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
test('CV1-09 CV1-10 description, labels and severity all land in the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-09-${Date.now()}`,
channelName: alertChannel.name,
});
await v1DescriptionInput(page).fill('raised by the e2e suite');
// The label editor is a two-phase input — key, ENTER, value, ENTER — sharing one
// field, and it writes the whole label map back.
const labels = page.getByTestId('alert-labels-input-v1');
await labels.fill('team');
await labels.press('Enter');
await labels.fill('payments');
await labels.press('Enter');
// Severity is just another label in the payload, which is why the two halves of
// this row belong together: the editor above must not have dropped it.
await v1SelectOption(page, v1SeveritySelect(page), 'Critical');
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
const body = response.request().postDataJSON();
expect(body.annotations.description).toBe('raised by the e2e suite');
expect(body.labels).toMatchObject({
team: 'payments',
severity: 'critical',
});
});
test('CV1-11 test notification skips the dialog and reports no matching data', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-11-${Date.now()}`,
channelName: alertChannel.name,
});
// `onTestRuleHandler` validates inline — the confirm dialog is the *save* path
// only, so a spec that waits for it here hangs.
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/v2/rules/test') && r.request().method() === 'POST',
),
v1TestButton(page).click(),
]);
expect(response.ok()).toBe(true);
await expect(v1ConfirmDialog(page)).toHaveCount(0);
// A threshold of 5 on a rule nobody is feeding evaluates fine and matches nothing,
// which is an *error* notification rather than a success one. Asserted
// permissively so a stack that happens to have matching data does not flip it.
await expect(
page.getByText(/No alerts found during the evaluation|Success/),
).toBeVisible();
});
test('CV1-12 with no channels the form is a dead end', async ({
authedPage: page,
}) => {
// v2 at least offers routing policies; v1 has no equivalent escape.
await stubNoChannels(page);
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await v1NameInput(page).fill(`e2e-cv1-12-${Date.now()}`);
await v1ThresholdInput(page).fill('5');
// `noChannels` disables the switch, so the one control that could satisfy
// `isChannelConfigurationValid` without picking a channel is gone.
await expect(v1BroadcastSwitch(page)).toBeDisabled();
// And the select that is still on screen offers nothing but a way out of the page.
await v1ChannelSelect(page).click();
await expect(
page.getByText('Create a new channel', { exact: false }),
).toBeVisible();
await page.keyboard.press('Escape');
await expect(
page.getByRole('button', { name: 'Create a notification channel' }),
).toBeVisible();
// ⇒ no reachable state saves this rule.
await expect(v1SaveButton(page)).toBeDisabled();
});
test('CV1-13 Cancel leaves the form without saving', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-13-${Date.now()}`,
channelName: alertChannel.name,
});
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
// A plain click, unlike v2's Discard: v1 has no fixed footer for the side
// navigation to cover (CE-09).
await v1CancelButton(page).click();
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPost).toBe(false);
});
test('CE-05 an empty PromQL expression is rejected behind the dialog', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await fillMinimalV1Rule(page, {
name: `e2e-ce05-${Date.now()}`,
channelName: alertChannel.name,
});
// PromQL is offered for metrics-based alerts only; the logs/traces/exceptions
// tab set has ClickHouse but no PromQL.
await v1SelectQueryMode(page, 'promql');
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
await v1SaveButton(page).click();
await v1ConfirmSave(page);
await expect(page.getByText(VALIDATION.promql)).toBeVisible();
expect(sawPost).toBe(false);
});
test('CE-06 an empty ClickHouse query is rejected behind the dialog', async ({
authedPage: page,
alertChannel,
}) => {
// Metrics-based, and not interchangeable with the logs form: `logAlertDefaults`
// ships a **prefilled** ClickHouse query, so on a logs alert the expression is
// never empty and `chquery_required` cannot fire. Only `alertDefaults` (metrics)
// starts with `query: ''`.
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await fillMinimalV1Rule(page, {
name: `e2e-ce06-${Date.now()}`,
channelName: alertChannel.name,
});
await v1SelectQueryMode(page, 'clickhouse');
let sawPost = false;
page.on('request', (request) => {
if (isRuleCreate(request.url(), request.method())) {
sawPost = true;
}
});
await v1SaveButton(page).click();
await v1ConfirmSave(page);
await expect(page.getByText(VALIDATION.clickhouse)).toBeVisible();
expect(sawPost).toBe(false);
});
test('CV1-14 the condition sentence keeps its selections', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
// The write side of EV1-02's prefill assertions — without it a broken
// `RuleOptions` select would only be caught on the *edit* path.
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
await fillMinimalV1Rule(page, {
name: `e2e-cv1-14-${Date.now()}`,
channelName: alertChannel.name,
});
await v1SelectOption(page, v1OperatorSelect(page), 'below');
await v1SelectOption(page, v1MatchTypeSelect(page), 'all the times');
await v1SelectOption(page, v1EvalWindowSelect(page), '10 mins');
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
v1ConfirmSave(page),
]);
await ownedRules.register(response);
// v1 stores these as the numeric enum strings the legacy validator wants — `2`
// is *below* and `2` is *all the times*, on two different scales.
const body = response.request().postDataJSON();
expect(body.condition.op).toBe('2');
expect(body.condition.matchType).toBe('2');
expect(body.evalWindow).toBe('10m0s');
});
});

View File

@@ -1,564 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
addAlertLabel,
advancedOptionToggle,
AlertType,
evaluationCadenceInput,
expandAdvancedOptions,
gotoCreateAlertV2,
labelPill,
dropdownOption,
openDropdown,
ownDropdown,
selectEvaluationTimeframe,
selectThresholdChannel,
stubNoChannels,
thresholdRows,
ThresholdMatchType,
ThresholdOperator,
v2ClickDiscard,
v2DiscardButton,
v2SaveButton,
v2SaveTooltip,
v2TestButton,
} from '../../../helpers/alert-forms';
// CV2-* — the v2 create builder.
//
// Every scenario uses a **logs**-based alert unless it says otherwise: its default
// query is valid with no seeded metrics. CV2-12 (unit select) and CV2-16 (group-by
// select) are about what that choice costs — both are gated on query state a
// default logs query does not provide, and both assert the gate.
const VALIDATION = {
name: 'Please enter an alert name',
thresholdLabel: 'Please enter a label for each threshold',
channels:
'Please select at least one channel for each threshold or enable routing policies',
} as const;
test.describe('Alert create — v2 builder', () => {
test('CV2-01 initial state: one critical threshold, both actions gated', async ({
authedPage: page,
alertChannel,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
await expect(page.getByTestId('alert-name-input')).toHaveAttribute(
'placeholder',
'Enter alert rule name',
);
// `INITIAL_CRITICAL_THRESHOLD` — label `critical`, value 0, `channels: []`.
// The empty channel list is what makes the save gate reachable at all.
await expect(thresholdRows(page)).toHaveCount(1);
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-value-input')).toHaveValue('0');
await expect(v2SaveButton(page)).toBeDisabled();
await expect(v2TestButton(page)).toBeDisabled();
});
test('CV2-02 the save tooltip walks from the name gate to the channel gate', async ({
authedPage: page,
alertChannel,
}) => {
expect(alertChannel.name).toBeTruthy();
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `validateCreateAlertState` returns the *first* failure, so the message order
// encodes the validation order: name, then per-threshold label, then channels.
expect(await v2SaveTooltip(page)).toBe(VALIDATION.name);
await page.getByTestId('alert-name-input').fill(`e2e-cv2-02-${Date.now()}`);
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
});
test('CV2-03 clearing a threshold label re-gates the save', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-03-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// With a name and a channel the only remaining gate is the label.
await expect(v2SaveButton(page)).toBeEnabled();
await page.getByTestId('threshold-name-input').fill('');
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
});
test('CV2-04 a label added in the header survives the save round-trip', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-cv2-04-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, alertChannel.name);
await addAlertLabel(page, 'team', 'payments');
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// Asserted on the request body rather than on the pill: the pill only proves
// local state, and the defect worth guarding is a label that renders but is
// never posted.
const body = response.request().postDataJSON();
expect(body.labels).toMatchObject({ team: 'payments' });
});
test('CV2-05 a rejected label key surfaces as a notification, not an inline message', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// Both rejection branches in `LabelsInput` — duplicate key and group-by key —
// raise an antd **notification**, so a spec looking for inline text fails. Only
// the duplicate branch is reachable here: the group-by branch needs a query that
// already groups by something, which the default logs query does not.
await addAlertLabel(page, 'team', 'payments');
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
await page.getByTestId('alert-add-label-button').click();
const input = page.getByTestId('alert-add-label-input');
await input.fill('team');
await input.press('Enter');
await expect(
page.getByText('Label with this key already exists'),
).toBeVisible();
// Rejected, so no second pill appeared.
await expect(page.locator('[data-testid^="label-pill-team-"]')).toHaveCount(
1,
);
});
test('CV2-06 CV2-07 the operator and match-type selects offer the documented options', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-threshold-operator-select').click();
// Exact-text matching, because `hasText: 'EQUAL TO'` also matches the
// `NOT EQUAL TO` option and the count assertion then reads 2.
for (const operator of Object.values(ThresholdOperator)) {
await expect(dropdownOption(page, operator.label)).toHaveCount(1);
}
await expect(
openDropdown(page).locator('.ant-select-item-option'),
).toHaveCount(Object.keys(ThresholdOperator).length);
await page.keyboard.press('Escape');
await page.getByTestId('alert-threshold-match-type-select').click();
for (const matchType of Object.values(ThresholdMatchType)) {
await expect(dropdownOption(page, matchType.label)).toHaveCount(1);
}
await expect(
openDropdown(page).locator('.ant-select-item-option'),
).toHaveCount(Object.keys(ThresholdMatchType).length);
});
test('CV2-08 the operator is rule-wide: one change reaches every threshold', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-08-${Date.now()}`);
await page.getByTestId('add-threshold-button').click();
await expect(thresholdRows(page)).toHaveCount(2);
await selectThresholdChannel(page, 0, alertChannel.name);
await selectThresholdChannel(page, 1, alertChannel.name);
await page.getByTestId('alert-threshold-operator-select').click();
await dropdownOption(page, ThresholdOperator.BELOW.label).click();
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// The UI models one operator per rule while the schema stores one per
// threshold, so a single change is fanned out across `spec[]`.
const spec = response.request().postDataJSON().condition.thresholds.spec;
expect(spec).toHaveLength(2);
expect(spec.map((entry: { op: string }) => entry.op)).toEqual([
ThresholdOperator.BELOW.value,
ThresholdOperator.BELOW.value,
]);
});
test('CV2-09 CV2-10 added thresholds take preset tiers, and the first cannot be removed', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `addThreshold` branches on the current count: 2nd ⇒ warning, 3rd ⇒ info,
// 4th and beyond ⇒ an unnamed row with a random colour.
await page.getByTestId('add-threshold-button').click();
await page.getByTestId('add-threshold-button').click();
await page.getByTestId('add-threshold-button').click();
await expect(thresholdRows(page)).toHaveCount(4);
const names = page.getByTestId('threshold-name-input');
await expect(names.nth(0)).toHaveValue('critical');
await expect(names.nth(1)).toHaveValue('warning');
await expect(names.nth(2)).toHaveValue('info');
await expect(names.nth(3)).toHaveValue('');
// `showRemoveButton` is `index !== 0 && length > 1`, so there are three remove
// buttons for four rows and the first row can never be removed.
await expect(page.getByTestId('remove-threshold-button')).toHaveCount(3);
// To see the unnamed row's own gate, the earlier gates have to be satisfied
// first: `validateCreateAlertState` loops thresholds and returns on the first
// failure, checking label *then* channels **per threshold** — so with row 0
// lacking a channel the channel message wins before row 3 is ever examined.
await page.getByTestId('alert-name-input').fill(`e2e-cv2-09-${Date.now()}`);
for (const index of [0, 1, 2, 3]) {
// eslint-disable-next-line no-await-in-loop
await selectThresholdChannel(page, index, alertChannel.name);
}
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
await names.nth(3).fill('page-me');
await expect(v2SaveButton(page)).toBeEnabled();
});
test('CV2-11 a channel on one threshold is not enough — the validator loops all of them', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-11-${Date.now()}`);
await page.getByTestId('add-threshold-button').click();
await selectThresholdChannel(page, 0, alertChannel.name);
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
await selectThresholdChannel(page, 1, alertChannel.name);
await expect(v2SaveButton(page)).toBeEnabled();
});
test('CV2-12 the unit select is disabled while the query has no y-axis unit', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `disabled={units.length === 0}`, and `units` is derived from
// `alertState.yAxisUnit`. A logs alert carries no unit, so the control is dead on
// this path — CD-04 covers a URL that does supply one.
const unitSelect = page.getByTestId('threshold-unit-select').first();
await expect(unitSelect).toHaveClass(/ant-select-disabled/);
});
test('CV2-13 the recovery threshold control is never rendered', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `showRecoveryThreshold` starts false and the only setter is commented out, so
// neither the input nor its remove button can appear.
await expect(page.getByTestId('recovery-threshold-value-input')).toHaveCount(
0,
);
await expect(
page.getByTestId('remove-recovery-threshold-button'),
).toHaveCount(0);
});
test('CV2-14 CV2-15 the evaluation window and cadence reach the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-14-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// Written as one test because the two settings share a payload branch:
// `getEvaluationProps` emits `evalWindow` and `frequency` together, and a
// scenario that changed only one would still pass with the other hardcoded.
await selectEvaluationTimeframe(page, '30m0s');
await expandAdvancedOptions(page);
await evaluationCadenceInput(page).fill('5');
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
const { evaluation } = response.request().postDataJSON();
expect(evaluation.kind).toBe('rolling');
expect(evaluation.spec.evalWindow).toBe('30m0s');
// `getFormattedTimeValue` maps value + unit onto a Go duration; the unit is
// left at its default Minutes, which is what makes `5m` the expected string.
expect(evaluation.spec.frequency).toBe('5m');
});
test('CV2-18 with no channels the dropdown offers only a way to create one', async ({
authedPage: page,
}) => {
// The single deliberate network stub in the alerts suite — its justification is
// in `stubNoChannels`, and the state it produces is the one every fresh install
// starts in.
await stubNoChannels(page);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-18-${Date.now()}`);
const select = page
.getByTestId('threshold-notification-channel-select')
.first();
await select.click();
const dropdown = await ownDropdown(page, select);
// `NotificationChannelsNotFoundContent` branches on the user's role. The harness
// user is an admin, so this is the "create one here" half — asserting the
// non-admin string instead would fail for the wrong reason.
await expect(dropdown.getByText('No channels yet.')).toBeVisible();
await expect(dropdown.getByRole('button', { name: 'here.' })).toBeVisible();
await expect(dropdown.getByRole('button', { name: 'Refresh' })).toBeVisible();
await expect(
dropdown.getByText('Please ask your admin to create one.'),
).toBeHidden();
await page.keyboard.press('Escape');
// With a name filled and no channel selectable, the channel gate is the only
// thing left — and there is no way to satisfy it from this page.
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
await expect(v2SaveButton(page)).toBeDisabled();
});
test('CV2-19 routing policies unlock the save with zero channels', async ({
authedPage: page,
}) => {
await stubNoChannels(page);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-19-${Date.now()}`);
await expect(v2SaveButton(page)).toBeDisabled();
await page.getByTestId('routing-policies-switch').click();
// The validator skips the channel check when `routingPolicies` is on, and the
// threshold row *removes* its channel select rather than disabling it — so this
// is the one route to a saveable rule on a stack with no channels at all.
await expect(
page.getByTestId('threshold-notification-channel-select'),
).toHaveCount(0);
await expect(v2SaveButton(page)).toBeEnabled();
await page.getByTestId('view-routing-policies-button').click();
await page.waitForURL(/subTab=routing-policies/);
const url = new URL(page.url());
expect(url.pathname).toBe('/alerts');
expect(url.searchParams.get('tab')).toBe('Configuration');
});
test('CV2-16 the group-by select is disabled until the query groups by something', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// `isMultipleNotificationsEnabled` is `spaceAggregationOptions.length > 0`, and
// the options come from the query's `groupBy` keys. The default logs query has
// none, so notification grouping is unreachable without editing the query first.
const groupBy = page.getByTestId('multiple-notifications-select');
await expect(groupBy).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByText('No grouping fields available')).toBeVisible();
});
test('CV2-17 repeat notifications enable their inputs and reach the payload', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-17-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
const interval = page.getByTestId('repeat-notifications-time-input');
await expect(interval).toBeDisabled();
await advancedOptionToggle(page, 'repeat-notifications-container').click();
await expect(interval).toBeEnabled();
await interval.fill('45');
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
// `getFormattedTimeValue` turns value+unit into a Go duration.
const renotify = response.request().postDataJSON()
.notificationSettings.renotify;
expect(renotify.enabled).toBe(true);
expect(renotify.interval).toBe('45m');
});
test('CV2-20 happy-path save posts the v2 shape and lands on the list', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
const name = `e2e-cv2-20-${Date.now()}`;
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, alertChannel.name);
// One click — v2 has no confirm dialog, unlike v1.
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
v2SaveButton(page).click(),
]);
await ownedRules.register(response);
expect(response.status()).toBe(201);
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');
expect(body.version).toBe('v5');
expect(body.alert).toBe(name);
expect(body.condition.thresholds.kind).toBe('basic');
expect(body.condition.thresholds.spec[0]).toMatchObject({
name: 'critical',
target: 0,
matchType: ThresholdMatchType.AT_LEAST_ONCE.value,
op: ThresholdOperator.ABOVE.value,
channels: [alertChannel.name],
targetUnit: '',
});
// The payload carries no recovery field at all — see CV2-13.
expect(body.condition.thresholds.spec[0]).not.toHaveProperty(
'recoveryTarget',
);
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
// `safeNavigate('/alerts')`; the list page then appends its own defaults, so
// only the pathname is asserted.
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
});
test('CV2-21 test notification reports that a non-firing rule matched nothing', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-21-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.url().includes('/api/v2/rules/test') && r.request().method() === 'POST',
),
v2TestButton(page).click(),
]);
expect(response.ok()).toBe(true);
// `alertCount === 0` is an *error* toast, not a success one — the rule evaluated
// fine, it just did not fire. Asserted permissively so a stack that happens to
// have matching data does not flip it.
await expect(
page.getByText(/No alerts found during the evaluation|sent successfully/),
).toBeVisible();
});
test('CV2-22 discard leaves without posting and resets the form', async ({
authedPage: page,
alertChannel,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-22-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
let sawPost = false;
page.on('request', (request) => {
if (request.method() === 'POST' && request.url().includes('/api/v2/rules')) {
sawPost = true;
}
});
// dispatchEvent, not click — the side navigation covers the button (CE-09).
await v2ClickDiscard(page);
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPost).toBe(false);
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
await expect(thresholdRows(page)).toHaveCount(1);
});
test('CV2-23 every footer button is disabled while the save is in flight', async ({
authedPage: page,
alertChannel,
ownedRules,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
await page.getByTestId('alert-name-input').fill(`e2e-cv2-23-${Date.now()}`);
await selectThresholdChannel(page, 0, alertChannel.name);
// The in-flight window is a few milliseconds against a local stack, so it is
// widened by *delaying* the request — `route.continue()` still sends it to the
// real backend and the real 201 comes back, so nothing about the response is
// faked.
await page.route('**/api/v2/rules', async (route) => {
if (route.request().method() !== 'POST') {
await route.fallback();
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 2_000);
});
await route.continue();
});
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
);
await v2SaveButton(page).click();
// `disableButtons` is one flag shared by all three, so Discard going disabled is
// what proves a user cannot abandon a half-created rule mid-request.
await expect(page.getByTestId('save-alert-rule-loader-icon')).toBeVisible();
await expect(page.getByTestId('save-alert-rule-check-icon')).toHaveCount(0);
await expect(v2SaveButton(page)).toBeDisabled();
await expect(v2TestButton(page)).toBeDisabled();
await expect(v2DiscardButton(page)).toBeDisabled();
const response = await responsePromise;
await ownedRules.register(response);
expect(response.status()).toBe(201);
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
});
});

View File

@@ -1,96 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_OVERVIEW_PATH,
ALERTS_LIST_PATH,
gotoAlertDetails,
} from '../../../helpers/alerts';
test.describe('Alert details — actions', () => {
test('AD-06 enable/disable toggle changes the rule state', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.logs({
name: `e2e-ad-toggle-${Date.now()}`,
schema: 'v2',
});
await gotoAlertDetails(page, ruleId);
const toggle = page.getByTestId('alert-actions-toggle');
await expect(toggle).toBeVisible();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
toggle.click(),
]);
await expect(page.getByText('Alert has been disabled.')).toBeVisible();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
toggle.click(),
]);
await expect(page.getByText('Alert has been enabled.')).toBeVisible();
});
test('AD-07 Duplicate creates a copy and navigates to overview', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ad-duplicate-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();
const [createResponse] = await Promise.all([
page.waitForResponse(
(res) =>
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
res.request().method() === 'POST',
),
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
]);
await ownedRules.register(createResponse);
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(/[?&]ruleId=/);
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
});
test('AD-08 Delete removes the rule and returns to the list', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ad-delete-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'DELETE',
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
});
});

View File

@@ -1,51 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import { ALERTS_LIST_PATH, gotoAlertHistory } from '../../../helpers/alerts';
test.describe('Alert details — page chrome', () => {
test('AD-09 copy-link button copies the current URL to clipboard', async ({
authedPage: page,
alertHistory,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'clipboard-read permission is Chromium-only in Playwright',
);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoAlertHistory(page, alertHistory.ruleId);
const expected = page.url();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Copied')).toBeVisible();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe(expected);
});
test('AD-10 breadcrumb navigates back to the alert list', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const breadcrumb = page.locator('.ant-breadcrumb');
await expect(breadcrumb).toContainText('Alert Rules');
await expect(breadcrumb).toContainText(alertHistory.ruleId);
await breadcrumb.getByText('Alert Rules').click();
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
});
test('AD-13 document title updates to show the rule name', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect
.poll(() => page.title(), { timeout: 15_000 })
.toContain('e2e-ah-rule-v2');
});
});

View File

@@ -1,55 +0,0 @@
import {
expect,
SEED_C_TEAM_LABEL,
test,
} from '../../../fixtures/alert-history';
import { gotoAlertDetails } from '../../../helpers/alerts';
test.describe('Alert details — header', () => {
test('AD-01 v2 header shows editable name input without Rename menu item', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleId);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v2alpha1',
);
const nameInput = page.getByTestId('alert-name-input');
await expect(nameInput).toBeVisible();
await expect(nameInput).not.toHaveValue('');
await expect(nameInput).toBeEditable();
await page.getByTestId('alert-actions-menu').click();
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toHaveCount(0);
});
test('AD-02 v1 header shows static title with state, severity and labels', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleIdV1);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(page.getByTestId('alert-header-title')).toBeVisible();
await expect(page.getByTestId('alert-header-state')).toBeVisible();
await expect(page.getByTestId('alert-header-severity')).toContainText(
'Warning',
);
await expect(page.getByTestId('alert-header-labels')).toContainText(
SEED_C_TEAM_LABEL,
);
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
await page.getByTestId('alert-actions-menu').click();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeVisible();
});
});

View File

@@ -1,33 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_HISTORY_PATH,
ALERT_OVERVIEW_PATH,
} from '../../../helpers/alerts';
test.describe('Alert details — not found', () => {
test('AD-11 invalid ruleId shows AlertNotFound page', async ({
authedPage: page,
}) => {
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
await page.goto(
`${ALERT_HISTORY_PATH}?ruleId=01920000-0000-7000-8000-000000000000`,
);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
test('AD-12 missing ruleId on overview shows AlertNotFound page', async ({
authedPage: page,
}) => {
await page.goto(ALERT_OVERVIEW_PATH);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
});

View File

@@ -1,74 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERTS_LIST_PATH,
gotoAlertDetails,
gotoAlertHistory,
} from '../../../helpers/alerts';
test.describe('Alert details — rename', () => {
test('AD-03 v1 rename via modal updates the rule name', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v1-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v1' });
await gotoAlertDetails(page, ruleId);
await expect(page.getByTestId('alert-header-title')).toContainText(original);
await page.getByTestId('alert-actions-menu').click();
await page.getByRole('menuitem', { name: 'Rename' }).click();
const modalInput = page.getByTestId('alert-name');
await expect(modalInput).toBeVisible();
await modalInput.fill(renamed);
await page.getByRole('button', { name: 'Rename Alert' }).click();
await expect(page.getByText('Alert renamed successfully')).toBeVisible();
await expect(page.getByTestId('alert-header-title')).toContainText(renamed);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
});
test('AD-04 v2 inline rename saves via Overview footer button', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v2-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v2' });
await gotoAlertDetails(page, ruleId);
const nameInput = page.getByTestId('alert-name-input');
await expect(nameInput).toHaveValue(original);
await nameInput.fill(renamed);
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
['PUT', 'POST', 'PATCH'].includes(res.request().method()),
),
page.getByRole('button', { name: 'Save Alert Rule' }).click(),
]);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
await gotoAlertHistory(page, ruleId);
const historyInput = page.getByTestId('alert-name-input');
await historyInput.fill(`${renamed}-unsaved`);
await expect(
page.getByRole('button', { name: 'Save Alert Rule' }),
).toHaveCount(0);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
await expect(page.getByText(`${renamed}-unsaved`)).toHaveCount(0);
});
});

View File

@@ -1,55 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
ALERT_OVERVIEW_PATH,
DEFAULT_RELATIVE_TIME,
gotoAlertDetails,
gotoAlertHistory,
} from '../../../helpers/alerts';
test.describe('Alert details — tabs', () => {
test('AD-05 Overview/History tabs preserve ruleId and relativeTime', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleId);
await page.getByTestId('alert-details-tab-history').click();
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
await expect(page).toHaveURL(
new RegExp(`relativeTime=${DEFAULT_RELATIVE_TIME}`),
);
await expect(
page.getByTestId('alert-details-tab-history').getByText('Beta'),
).toBeVisible();
await page.getByTestId('alert-details-tab-overview').click();
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
});
test('AD-05b switching to History tab discards other history params', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
page: '2',
order: 'desc',
timelineFilter: 'FIRED',
});
await page.getByTestId('alert-details-tab-overview').click();
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
await page.getByTestId('alert-details-tab-history').click();
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
const search = new URL(page.url()).searchParams;
expect([...search.keys()].sort()).toEqual(['relativeTime', 'ruleId']);
expect(search.get('ruleId')).toBe(alertHistory.ruleId);
});
});

View File

@@ -1,22 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { gotoAlertOverview } from '../../../helpers/alerts';
const TARGET = 245;
test.describe('Alert overview — threshold persistence', () => {
test('TC-02 edit page displays the saved threshold value', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(
`e2e-threshold-persistence-${Date.now()}`,
{ target: TARGET },
);
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
String(TARGET),
);
});
});

View File

@@ -1,54 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { ALERT_EDIT_PATH } from '../../../helpers/alert-forms';
import { ALERT_OVERVIEW_PATH } from '../../../helpers/alerts';
// CE-03 — what the edit routes do with an unknown ruleId.
//
// `pages/EditRules`'s own error branches are unreachable: `/alerts/edit` is a
// legacy alias redirected to `/alerts/overview` before any route matches
// (`AppRoutes/Private.tsx`), and the details shell validates the id before
// rendering the Overview tab. So neither the "Rule Id is required" notification nor
// the `edit-rules-container--error` card can ever render — this file asserts the
// behaviour that replaces them.
const UNKNOWN_RULE_ID = '999999999';
test.describe('Alert edit — routing edges', () => {
test('CE-03 an unknown ruleId shows AlertNotFound on both entry URLs', async ({
authedPage: page,
}) => {
for (const entry of [
`${ALERT_OVERVIEW_PATH}?ruleId=${UNKNOWN_RULE_ID}`,
`${ALERT_EDIT_PATH}?ruleId=${UNKNOWN_RULE_ID}`,
]) {
// eslint-disable-next-line no-await-in-loop
await page.goto(entry);
// `AlertDetails.tsx` returns AlertNotFound before the provider or the editor
// mount, so neither the details root nor the standalone route's error card may
// appear.
// eslint-disable-next-line no-await-in-loop
await expect(page.locator('.alert-not-found')).toBeVisible();
// eslint-disable-next-line no-await-in-loop
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
// eslint-disable-next-line no-await-in-loop
await expect(page.getByTestId('alert-details-root')).toBeHidden();
// eslint-disable-next-line no-await-in-loop
await expect(page.locator('.edit-rules-container--error')).toBeHidden();
}
});
test('CE-03b /alerts/edit with no ruleId also lands on AlertNotFound', async ({
authedPage: page,
}) => {
await page.goto(ALERT_EDIT_PATH);
// The alias redirect fires first, so `pages/EditRules`'s missing-id branch — an
// error notification plus a navigation to `/alerts` — never runs.
await page.waitForURL(/\/alerts\/overview/);
await expect(page.locator('.alert-not-found')).toBeVisible();
await expect(page.getByText('Rule Id is required')).toBeHidden();
});
});

View File

@@ -1,291 +0,0 @@
import { expect, test, type OwnedRules } from '../../../fixtures/alert-rules';
import {
ALERT_EDIT_PATH,
v1CancelButton,
v1ChannelSelect,
v1ConfirmSave,
v1DescriptionInput,
v1EvalWindowSelect,
v1MatchTypeSelect,
v1NameInput,
v1OperatorSelect,
v1SaveButton,
v1SeveritySelect,
v1ThresholdInput,
v1BroadcastSwitch,
selectedTags,
} from '../../../helpers/alert-forms';
import { authToken, watchConsole } from '../../../helpers/common';
import { gotoAlertDetails } from '../../../helpers/alerts';
// EV1-* — editing a rule whose `schemaVersion` is *not* `v2alpha1`.
//
// The classic form is what renders, inside the same details shell the v2 builder
// uses: `container/EditRules/index.tsx` picks the form from the rule, so there is no
// way to open a v1 rule in the v2 builder (and EV1-08 asserts the reverse — editing
// never migrates the schema).
//
// `gotoAlertOverview` cannot be used here: it waits for `threshold-value-input`,
// which only the v2 builder renders.
/**
* SEED-RV1 — every asserted field deliberately differs from `alertDefaults`, so a
* passing prefill assertion cannot be satisfied by the create form's own defaults.
*/
const SEED_RV1 = {
target: 73,
/** `2` is *below*, against the create default of `1` (*above*). */
op: '2',
/** `2` is *all the times*, against the create default of `1` (*at least once*). */
matchType: '2',
evalWindow: '15m0s',
severity: 'error',
} as const;
/** The description every seeded rule carries (`ANNOTATIONS` in `helpers/alerts.ts`). */
const SEEDED_DESCRIPTION =
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
type LogsSeedOverrides = Omit<Parameters<OwnedRules['logs']>[0], 'name'>;
function seedRv1(
ownedRules: OwnedRules,
name: string,
extra: Partial<LogsSeedOverrides> = {},
): Promise<string> {
return ownedRules.logs({
schema: 'v1',
target: SEED_RV1.target,
op: SEED_RV1.op,
matchType: SEED_RV1.matchType,
evalWindow: SEED_RV1.evalWindow,
severity: SEED_RV1.severity,
...extra,
name,
});
}
/** Read a rule straight from the API — EV1-08 needs the stored schema version. */
async function readRule(
page: import('@playwright/test').Page,
ruleId: string,
): Promise<Record<string, unknown>> {
const token = await authToken(page);
const res = await page.request.get(`/api/v2/rules/${ruleId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBe(true);
const json = (await res.json()) as { data: Record<string, unknown> };
return json.data;
}
test.describe('Alert edit — v1 rule', () => {
test('EV1-01 the classic form renders in edit mode inside the details shell', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-shell-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
const root = page.getByTestId('alert-details-root');
// The shell marks the schema on the root node, and only v2 rules get the
// `alert-details-v2` class.
await expect(root).toHaveAttribute('data-schema-version', 'v1');
await expect(root).not.toHaveClass(/alert-details-v2/);
// The classic form, not the builder: v1's own name field exists and the v2
// header's does not.
await expect(v1NameInput(page)).toBeVisible();
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
await expect(
page.locator('.form-alert-rules-container.edit-mode'),
).toBeVisible();
await expect(v1SaveButton(page)).toHaveText(/Save Rule/);
await expect(v1CancelButton(page)).toHaveText(/Discard/);
});
test('EV1-02 every seeded field prefills the form', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev1-prefill-${Date.now()}`;
const ruleId = await seedRv1(ownedRules, name, {
extraLabels: { team: 'payments' },
});
await gotoAlertDetails(page, ruleId);
await expect(v1NameInput(page)).toHaveValue(name);
await expect(v1DescriptionInput(page)).toHaveValue(SEEDED_DESCRIPTION);
await expect(v1SeveritySelect(page)).toContainText('Error');
// The four `RuleOptions` controls — the values CV1-14 writes, read back from the
// other side.
await expect(v1ThresholdInput(page)).toHaveValue(String(SEED_RV1.target));
await expect(v1OperatorSelect(page)).toContainText('below');
await expect(v1MatchTypeSelect(page)).toContainText('all the times');
await expect(v1EvalWindowSelect(page)).toContainText('15 mins');
await expect(page.getByText('team: payments')).toBeVisible();
});
test('EV1-03 preferredChannels decide which channel control is prefilled', async ({
authedPage: page,
ownedRules,
alertChannel,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-channels-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
// `BasicInfo.tsx` reads `preferredChannels` *once*, on mount: a rule that names
// channels gets the switch **off** and the select filled. The tag is counted
// rather than name-matched, since the select truncates at 10 characters.
await expect(v1BroadcastSwitch(page)).toHaveAttribute(
'aria-checked',
'false',
);
await expect(v1ChannelSelect(page)).toBeVisible();
await expect(selectedTags(v1ChannelSelect(page))).toHaveCount(1);
expect(alertChannel.name).toBeTruthy();
});
test('EV1-04 the happy-path update PUTs the v1 body and keeps unrelated params', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev1-update-${Date.now()}`;
const ruleId = await seedRv1(ownedRules, name);
await gotoAlertDetails(page, ruleId);
await v1ThresholdInput(page).fill('81');
await v1SaveButton(page).click();
const [response] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
r.url().includes(`/api/v2/rules/${ruleId}`),
),
v1ConfirmSave(page),
]);
// **`PUT /api/v2/rules/{id}`**, not `/api/v1/rules/{id}`: there is no v1 rules
// client in the frontend at all, so both forms share the endpoint and differ only
// in the body.
expect(response.ok()).toBe(true);
const body = response.request().postDataJSON();
expect(body.condition.target).toBe(81);
expect(body.condition.op).toBe(SEED_RV1.op);
expect(body.evalWindow).toBe(SEED_RV1.evalWindow);
expect(body.schemaVersion).toBeUndefined();
await expect(page.getByText('Rule edited successfully')).toBeVisible();
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
// `saveRule` strips exactly four params on the way out.
const params = new URL(page.url()).searchParams;
expect(params.get('ruleId')).toBeNull();
expect(params.get('compositeQuery')).toBeNull();
await gotoAlertDetails(page, ruleId);
await expect(v1ThresholdInput(page)).toHaveValue('81');
});
test('EV1-05 Discard leaves without a PUT and without changing the rule', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-discard-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
let sawPut = false;
page.on('request', (request) => {
if (request.method() === 'PUT' && request.url().includes('/rules/')) {
sawPut = true;
}
});
await v1ThresholdInput(page).fill('999');
await v1CancelButton(page).click();
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPut).toBe(false);
await gotoAlertDetails(page, ruleId);
await expect(v1ThresholdInput(page)).toHaveValue(String(SEED_RV1.target));
});
test('EV1-06 the header title and the form name field agree', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev1-header-${Date.now()}`;
const ruleId = await seedRv1(ownedRules, name);
await gotoAlertDetails(page, ruleId);
// v1's `AlertHeader` renders the name as static text while the form renders it as
// an input; two sources for one value, so they are asserted together. Renaming
// through the header's modal is AD-03's scenario — this row pins the precondition
// that makes it meaningful.
await expect(page.getByTestId('alert-details-root')).toContainText(name);
await expect(v1NameInput(page)).toHaveValue(name);
});
test('EV1-07 /alerts/edit redirects for a v1 rule exactly as it does for v2', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-alias-${Date.now()}`);
// The alias is resolved by `AppRoutes/Private.tsx` before route matching, so
// `pages/EditRules` never renders standalone and there is no v1/v2 asymmetry.
const watch = watchConsole(page);
await page.goto(`${ALERT_EDIT_PATH}?ruleId=${ruleId}`);
await page.waitForURL(/\/alerts\/overview/);
expect(new URL(page.url()).searchParams.get('ruleId')).toBe(ruleId);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(v1SaveButton(page)).toBeVisible();
expect(watch.errors).toEqual([]);
});
test('EV1-08 editing a v1 rule never migrates it to the v2 schema', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await seedRv1(ownedRules, `e2e-ev1-noupgrade-${Date.now()}`);
await gotoAlertDetails(page, ruleId);
await v1ThresholdInput(page).fill('91');
await v1SaveButton(page).click();
await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
r.url().includes(`/api/v2/rules/${ruleId}`),
),
v1ConfirmSave(page),
]);
await page.waitForURL(/\/alerts(\?|$)/);
// The classic form posts a v1 body and `pages/EditRules` picks the editor from
// the *stored* schema, so a saved v1 rule stays v1. Asserted from the API as well
// as the DOM — the UI could pick the right form off a cached response.
const rule = await readRule(page, ruleId);
expect(rule.schemaVersion).not.toBe('v2alpha1');
await gotoAlertDetails(page, ruleId);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
});
});

View File

@@ -1,420 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_EDIT_PATH,
EVALUATION_WINDOW_PRESETS,
evaluationCadenceInput,
evaluationCadenceUnitSelect,
evaluationSettingsButton,
evaluationWindowOption,
expandAdvancedOptions,
openEvaluationSettings,
thresholdRows,
ThresholdMatchType,
ThresholdOperator,
v2ClickDiscard,
v2SaveButton,
} from '../../../helpers/alert-forms';
import { gotoAlertOverview } from '../../../helpers/alerts';
import { watchConsole } from '../../../helpers/common';
// EV2-* — editing a rule whose `schemaVersion` is `v2alpha1`.
//
// Unless a scenario says otherwise these run through `/alerts/overview?ruleId=`,
// which is the route the rules list's Edit action actually uses. EV2-12 is the
// exception and the reason the distinction matters: the same editor reached
// through `/alerts/edit` has no `CreateAlertProvider` above it.
/**
* SEED-RV2 — a v2 rule whose every asserted field differs from the create-form
* default, so a passing prefill assertion cannot be satisfied by the defaults.
*/
const SEED_RV2 = {
target: 42,
warningTarget: 21,
evalWindow: '10m0s',
/** Deliberately *not* one of the rolling presets — see EV2-05. */
customEvalWindow: '7m0s',
frequency: '5m',
renotifyInterval: '2h',
absentFor: 7,
} as const;
/** PUT for one specific rule. v1 and v2 share this endpoint. */
function isRuleUpdate(url: string, method: string, ruleId: string): boolean {
return method === 'PUT' && url.includes(`/api/v2/rules/${ruleId}`);
}
test.describe('Alert edit — v2 rule', () => {
test('EV2-01 the v2 editor renders inside the details shell', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-shell-${Date.now()}`, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
const root = page.getByTestId('alert-details-root');
await expect(root).toHaveClass(/alert-details-v2/);
await expect(root).toHaveAttribute('data-schema-version', 'v2alpha1');
// `CreateAlertHeader` hides the whole tab bar in edit mode, which removes both
// the "New Alert Rule" chip and the classic-experience escape hatch. The escape
// hatch matters: switching experiences mid-edit would silently drop the loaded
// rule.
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(page.getByText('New Alert Rule')).toBeHidden();
await expect(
page.getByRole('button', { name: 'Switch to Classic Experience' }),
).toBeHidden();
});
test('EV2-02 name and labels prefill from the rule', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-prefill-${Date.now()}`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
labels: { severity: 'critical', team: 'payments' },
});
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
await expect(page.getByTestId('label-pill-severity-critical')).toBeVisible();
await expect(page.getByTestId('label-pill-team-payments')).toBeVisible();
});
test('EV2-03 both thresholds prefill, and the sentence reads spec[0]', async ({
authedPage: page,
ownedRules,
alertChannel,
}) => {
const ruleId = await ownedRules.threshold(
`e2e-ev2-thresholds-${Date.now()}`,
{
thresholds: [
{
name: 'critical',
target: SEED_RV2.target,
op: ThresholdOperator.BELOW.value,
matchType: ThresholdMatchType.ALL_THE_TIME.value,
channels: [alertChannel.name],
},
{
name: 'warning',
target: SEED_RV2.warningTarget,
op: ThresholdOperator.BELOW.value,
matchType: ThresholdMatchType.ALL_THE_TIME.value,
channels: [alertChannel.name],
},
],
},
);
await gotoAlertOverview(page, ruleId);
await expect(thresholdRows(page)).toHaveCount(2);
await expect(page.getByTestId('threshold-name-input').nth(0)).toHaveValue(
'critical',
);
await expect(page.getByTestId('threshold-name-input').nth(1)).toHaveValue(
'warning',
);
await expect(page.getByTestId('threshold-value-input').nth(0)).toHaveValue(
String(SEED_RV2.target),
);
await expect(page.getByTestId('threshold-value-input').nth(1)).toHaveValue(
String(SEED_RV2.warningTarget),
);
// The condition sentence is rule-wide in the UI but per-threshold in the schema,
// and the mapper reads it back from `spec[0]` only. Both seeded thresholds share
// op/matchType so this row stays about prefill.
await expect(
page.getByTestId('alert-threshold-operator-select'),
).toContainText(ThresholdOperator.BELOW.label);
await expect(
page.getByTestId('alert-threshold-match-type-select'),
).toContainText(ThresholdMatchType.ALL_THE_TIME.label);
});
test('EV2-04 the recovery threshold control never renders', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-recovery-${Date.now()}`, {
target: SEED_RV2.target,
recoveryTarget: 10,
});
await gotoAlertOverview(page, ruleId);
// `showRecoveryThreshold` starts false and its only setter is commented out, so a
// seeded `recoveryTarget` has nowhere to land. If either locator ever appears the
// feature was finished, and CV2-13/EV2-04 need rewriting rather than deleting.
await expect(page.getByTestId('recovery-threshold-value-input')).toHaveCount(
0,
);
await expect(
page.getByTestId('remove-recovery-threshold-button'),
).toHaveCount(0);
});
test('EV2-05 the evaluation window prefills, and a non-preset value collapses to custom', async ({
authedPage: page,
ownedRules,
}) => {
const presetRule = await ownedRules.threshold(
`e2e-ev2-window-${Date.now()}`,
{ target: SEED_RV2.target, evalWindow: SEED_RV2.evalWindow },
);
await gotoAlertOverview(page, presetRule);
// The trigger button carries both halves of the window: the timeframe label and
// the window *type*, which comes from the seeded `evaluation.kind`.
await expect(evaluationSettingsButton(page)).toContainText(
EVALUATION_WINDOW_PRESETS[SEED_RV2.evalWindow],
);
await expect(evaluationSettingsButton(page)).toContainText('Rolling');
const customRule = await ownedRules.threshold(
`e2e-ev2-window-custom-${Date.now()}`,
{ target: SEED_RV2.target, evalWindow: SEED_RV2.customEvalWindow },
);
await gotoAlertOverview(page, customRule);
// `getRollingWindowTimeframe` only recognises the seven presets; anything else
// becomes `custom`, and the label is then built from the parsed number + unit. So
// a rule created outside the UI with an odd window keeps its value — it just
// renders through the custom branch.
await expect(evaluationSettingsButton(page)).toContainText('Last 7 Minutes');
await openEvaluationSettings(page);
await expect(evaluationWindowOption(page, 'timeframe', 'custom')).toHaveClass(
/active/,
);
});
test('EV2-06 repeat notifications prefill from the seeded renotify block', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-renotify-${Date.now()}`, {
target: SEED_RV2.target,
renotify: {
enabled: true,
interval: SEED_RV2.renotifyInterval,
alertStates: ['firing'],
},
});
await gotoAlertOverview(page, ruleId);
// Every control in the block is `disabled={!reNotification.enabled}`, so "is it
// enabled" is a stronger read of the toggle than the Switch's own state.
const interval = page.getByTestId('repeat-notifications-time-input');
await expect(interval).toBeEnabled();
// `parseGoTime` splits the Go duration into value + unit.
await expect(interval).toHaveValue('2');
await expect(
page.getByTestId('repeat-notifications-unit-select'),
).toContainText('Hours');
await expect(
page.getByTestId('repeat-notifications-conditions-select'),
).toContainText('Firing');
});
test('EV2-07 alertOnAbsent prefills the advanced options', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-absent-${Date.now()}`, {
target: SEED_RV2.target,
alertOnAbsent: { absentFor: SEED_RV2.absentFor },
});
await gotoAlertOverview(page, ruleId);
await expandAdvancedOptions(page);
const tolerance = page.getByTestId(
'send-notification-if-data-is-missing-input',
);
await expect(tolerance).toBeVisible();
await expect(tolerance).toHaveValue(String(SEED_RV2.absentFor));
// The sibling option was not seeded, so its input stays behind the
// `display: none` its container applies when the toggle is off — asserted so a
// prefill that turned *every* advanced option on would still fail this row.
await expect(
page.getByTestId('enforce-minimum-datapoints-input'),
).toBeHidden();
});
test('EV2-08 the evaluation cadence always reads back in default mode', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(`e2e-ev2-cadence-${Date.now()}`, {
target: SEED_RV2.target,
frequency: SEED_RV2.frequency,
});
await gotoAlertOverview(page, ruleId);
await expandAdvancedOptions(page);
await expect(evaluationCadenceInput(page)).toHaveValue('5');
await expect(evaluationCadenceUnitSelect(page)).toContainText('Minutes');
// `getAdvancedOptionsStateFromAlertDef` hardcodes `mode: 'default'`, and
// `EditCustomSchedule` mounts only when the mode is *not* default — so a schedule
// saved by any other client reopens as a plain interval and the next save
// persists the flattened value. The custom and rrule modes are unreachable from
// the UI (the "Add custom schedule" button is commented out) and unrepresentable
// in the payload, so the flattening itself is what this row asserts; the day the
// custom editor can mount, it fails.
await expect(page.locator('.edit-custom-schedule')).toHaveCount(0);
});
test('EV2-09 changing a threshold PUTs the rule and the change survives a reload', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-update-${Date.now()}`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
await page.getByTestId('threshold-value-input').first().fill('99');
const [response] = await Promise.all([
page.waitForResponse((r) =>
isRuleUpdate(r.url(), r.request().method(), ruleId),
),
v2SaveButton(page).click(),
]);
expect(response.ok()).toBe(true);
const body = response.request().postDataJSON();
expect(body.condition.thresholds.spec[0].target).toBe(99);
// The mapper hardcodes the schema version on every save, so an edit of a v2 rule
// stays v2.
expect(body.schemaVersion).toBe('v2alpha1');
expect(body.alert).toBe(name);
await expect(page.getByText('Alert rule updated successfully')).toBeVisible();
await page.waitForURL(/\/alerts(\?|$)/);
expect(new URL(page.url()).pathname).toBe('/alerts');
// Re-read rather than trust the toast: the footer invalidates the rule and list
// caches, and a stale cache would show the old value here.
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('threshold-value-input').first()).toHaveValue(
'99',
);
});
test('EV2-10 the footer save is what persists a rename made on the Overview tab', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-rename-${Date.now()}`;
const renamed = `${name}-renamed`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
await page.getByTestId('alert-name-input').fill(renamed);
// AD-03/AD-04 cover renaming through the details header's RenameModal, which
// PATCHes on its own. This row is the other path: the editable header field is
// local state until the *Footer* saves it, so the assertion is on the PUT body.
const [response] = await Promise.all([
page.waitForResponse((r) =>
isRuleUpdate(r.url(), r.request().method(), ruleId),
),
v2SaveButton(page).click(),
]);
expect(response.request().postDataJSON().alert).toBe(renamed);
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('alert-name-input')).toHaveValue(renamed);
});
test('EV2-11 Discard leaves without a PUT and without touching the rule', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-ev2-discard-${Date.now()}`;
const ruleId = await ownedRules.threshold(name, {
target: SEED_RV2.target,
});
await gotoAlertOverview(page, ruleId);
let sawPut = false;
page.on('request', (request) => {
if (request.method() === 'PUT' && request.url().includes('/rules/')) {
sawPut = true;
}
});
await page.getByTestId('threshold-value-input').first().fill('999');
// dispatchEvent, not click — the side navigation covers the button (CE-09).
await v2ClickDiscard(page);
await page.waitForURL(/\/alerts(\?|$)/);
expect(sawPut).toBe(false);
// `discardAlertRule` also forces the *context's* alertType back to metrics, which
// is harmless on the way out only if the stored rule is untouched.
await gotoAlertOverview(page, ruleId);
await expect(page.getByTestId('threshold-value-input').first()).toHaveValue(
String(SEED_RV2.target),
);
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
});
test('EV2-12 /alerts/edit is a legacy alias that redirects into the details shell', async ({
authedPage: page,
ownedRules,
}) => {
const ruleId = await ownedRules.threshold(
`e2e-ev2-standalone-${Date.now()}`,
{ target: SEED_RV2.target },
);
// Reading the components suggests this route should crash: `EditAlertV2` renders
// Footer/AlertCondition/NotificationSettings, all of which call
// `useCreateAlertState()`, and the only provider lives in `AlertDetails.tsx`. It
// does not crash, because `/alerts/edit` never renders `pages/EditRules` at all —
// `AppRoutes/Private.tsx` redirects the legacy alias to `/alerts/overview`,
// merging the search params, before route matching.
//
// Worth keeping despite being a redirect: the alias is linked from the AI
// assistant and Metrics Explorer, so a regression in `oldNewRoutesMapping` would
// break real product links.
const watch = watchConsole(page);
await page.goto(`${ALERT_EDIT_PATH}?ruleId=${ruleId}`);
await page.waitForURL(/\/alerts\/overview/);
expect(new URL(page.url()).pathname).toBe('/alerts/overview');
expect(new URL(page.url()).searchParams.get('ruleId')).toBe(ruleId);
// The editor mounted under the shell's provider, so the missing-provider error
// never fires. Asserted explicitly: if someone removes the redirect entry, this
// is the message that would appear.
await expect(thresholdRows(page)).toHaveCount(1);
expect(
watch.errors.filter((message) =>
message.includes('useCreateAlertState must be used within'),
),
).toEqual([]);
});
});

View File

@@ -1,223 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
DEFAULT_RELATIVE_TIME,
encodeTimelineCursor,
gotoAlertHistory,
HISTORY_ENDPOINTS,
isHistoryRequest,
sortTimelineDescending,
statsCard,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
import {
collectRequests,
requestUrl,
watchConsole,
} from '../../../helpers/common';
test.describe('Alert history — cross-cutting', () => {
test('AX-01 full deep-link with all params is honoured in one load', async ({
authedPage: page,
alertHistory,
}) => {
const service = alertHistory.services[8];
await page.goto(
`${ALERT_HISTORY_PATH}?ruleId=${alertHistory.ruleId}` +
`&relativeTime=${DEFAULT_RELATIVE_TIME}` +
`&timelineFilter=FIRED&page=1&order=desc` +
`&alertHistoryExpression=${encodeURIComponent(`service.name = '${service}'`)}` +
`&viewAllTopContributors=true`,
);
await expect(page.getByTestId('timeline-table')).toBeVisible();
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
/selected/,
);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AX-02 page reload preserves all history params', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
page: '2',
order: 'desc',
});
const before = new URL(page.url()).searchParams;
await page.reload();
await expect(page.getByTestId('timeline-table')).toBeVisible();
const after = new URL(page.url()).searchParams;
for (const [key, value] of before) {
expect(after.get(key), `param ${key} survived the reload`).toBe(value);
}
});
test('AX-03 browser back/forward restores correct table state', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await page.getByTestId('timeline-filter-fired').click();
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await page.goBack();
await expect(page).not.toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await page.goForward();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
});
test('AX-04 no unhandled console errors across full history session', async ({
authedPage: page,
alertHistory,
}) => {
const watch = watchConsole(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-filter-fired').click();
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await page.getByTestId('top-contributors-view-all').click();
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AX-05 no request storm on mount (exactly one call per endpoint)', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toBeVisible();
for (const endpoint of HISTORY_ENDPOINTS) {
const calls = requests.filter((req) => isHistoryRequest(req, endpoint));
expect(calls, `${endpoint} called exactly once on mount`).toHaveLength(1);
}
});
test('AX-06 v1 and v2 schema rules both render history correctly', async ({
authedPage: page,
alertHistory,
}) => {
for (const [variant, ruleId, total] of [
['v2', alertHistory.ruleId, alertHistory.total],
['v1', alertHistory.ruleIdV1, alertHistory.totalV1],
] as const) {
await gotoAlertHistory(page, ruleId);
await expect(
page.getByTestId('alert-details-root'),
`${variant} rule renders the details shell`,
).toHaveAttribute(
'data-schema-version',
variant === 'v2' ? 'v2alpha1' : 'v1',
);
await expect(timelineFooterRange(page)).toContainText(`of ${total}`);
}
});
test('AX-07 no legacy v1 history API calls during full session', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-filter-fired').click();
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '6h' });
const legacy = requests.filter((req) =>
/\/api\/v1\/rules\/[^/]+\/history\//.test(req.url()),
);
expect(
legacy.map((req) => req.url()),
'no legacy POST /api/v1/rules/*/history/* calls',
).toEqual([]);
for (const endpoint of HISTORY_ENDPOINTS) {
expect(
requests.filter((req) => isHistoryRequest(req, endpoint)).length,
`v2 ${endpoint} endpoint was used`,
).toBeGreaterThan(0);
}
});
test('AX-08 history API endpoints carry expected params', async ({
authedPage: page,
alertHistory,
}) => {
const requests = collectRequests(page);
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
page: '2',
});
await expect(page.getByTestId('timeline-table')).toBeVisible();
const timeline = requests
.filter((req) => isHistoryRequest(req, 'timeline'))
.pop();
expect(timeline).toBeDefined();
const timelineParams = requestUrl(timeline!).searchParams;
for (const key of ['start', 'end', 'limit', 'order', 'cursor', 'state']) {
expect(timelineParams.get(key), `timeline carries ${key}`).toBeTruthy();
}
expect(timelineParams.get('limit')).toBe(String(TIMELINE_PAGE_SIZE));
expect(timelineParams.get('cursor')).toBe(encodeTimelineCursor(2));
for (const endpoint of [
'stats',
'overall_status',
'top_contributors',
] as const) {
const request = requests
.filter((req) => isHistoryRequest(req, endpoint))
.pop();
expect(request, `${endpoint} was requested`).toBeDefined();
const params = requestUrl(request!).searchParams;
expect(params.get('start'), `${endpoint} carries start`).toBeTruthy();
expect(params.get('end'), `${endpoint} carries end`).toBeTruthy();
}
const keys = requests
.filter((req) => /\/history\/filter_keys/.test(req.url()))
.pop();
expect(keys, 'filter_keys was requested').toBeDefined();
const keyParams = requestUrl(keys!).searchParams;
expect(keyParams.get('startUnixMilli')).toBeTruthy();
expect(keyParams.get('endUnixMilli')).toBeTruthy();
expect(keyParams.get('start')).toBeNull();
});
});

View File

@@ -1,180 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
ALERT_HISTORY_PATH,
createLogsAlertViaApi,
deleteAlertViaApi,
expectFirstPage,
gotoAlertHistory,
runFilterExpression,
setRuleDisabledViaApi,
statsCard,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
waitForHistoryResponse,
} from '../../../helpers/alerts';
import { collectRequests } from '../../../helpers/common';
import { typeExpression } from '../../../helpers/query-builder';
test.describe('Alert history — error and empty states', () => {
test('AE-01 invalid filter expression shows syntax error and recovers on fix', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const responsePromise = waitForHistoryResponse(page, 'timeline', {
filterExpression: 'service.name =',
});
await runFilterExpression(page, 'service.name =');
const response = await responsePromise;
expect(response.status()).toBe(400);
const error = page.getByTestId('timeline-error');
await expect(error).toBeVisible();
await expect(error).toContainText(/syntax error/i);
const fixed = `service.name = '${alertHistory.services[7]}'`;
const fixResponsePromise = waitForHistoryResponse(page, 'timeline', {
status: 200,
filterExpression: fixed,
});
await runFilterExpression(page, fixed);
await fixResponsePromise;
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AE-02 empty filter_keys response still mounts editor (no suggestions)', async ({
authedPage: page,
emptyHistory,
}) => {
const keysPromise = page.waitForResponse((res) =>
/\/history\/filter_keys/.test(res.url()),
);
await gotoAlertHistory(page, emptyHistory.ruleId);
const keysResponse = await keysPromise;
const body = (await keysResponse.json()) as {
data: { keys?: Record<string, unknown> } | null;
};
expect(Object.keys(body.data?.keys ?? {})).toHaveLength(0);
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
await typeExpression(page, 'anything.at.all');
await expect(
page.locator('.query-where-clause-editor .cm-content'),
).toContainText('anything.at.all');
});
test('AE-02b bogus ruleId never reaches history APIs (shows AlertNotFound)', async ({
authedPage: page,
}) => {
const requests = collectRequests(page);
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
expect(requests.filter((req) => /\/history\//.test(req.url()))).toHaveLength(
0,
);
});
test('AE-03 rule with no history renders empty state (not error)', async ({
authedPage: page,
emptyHistory,
}) => {
await gotoAlertHistory(page, emptyHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(0);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
).toHaveText('No Resolutions.');
await expect(page.getByTestId('top-contributors-row')).toHaveCount(0);
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AE-04 time range with no data renders empty state', async ({
authedPage: page,
alertHistory,
}) => {
const end = Date.now() - 24 * 60 * 60 * 1000;
const start = end - 30 * 60 * 1000;
await gotoAlertHistory(page, alertHistory.ruleId, {
startTime: String(start),
endTime: String(end),
});
await expect(timelineRows(page)).toHaveCount(0);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AE-05 time-range change resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await expect(page).toHaveURL(/[?&]page=2/);
await page.locator('.filters input').first().click();
await page.getByText('Last 6 hours', { exact: true }).click();
await expectFirstPage(page);
});
test('AE-06 absurd time range (90d) still renders', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '90d' });
await expect(page.getByTestId('timeline-table')).toBeVisible();
await expect(timelineRows(page).first()).toBeVisible();
await expect(page.getByTestId('timeline-graph-title')).toContainText(
`${alertHistory.total} triggers in`,
);
});
test('AE-07 disabled rule history is still readable', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
});
test('AE-08 deleted rule shows AlertNotFound on revisit', async ({
authedPage: page,
alertHistory,
}) => {
const doomedId = await createLogsAlertViaApi(page, {
name: `e2e-ah-doomed-${Date.now()}`,
marker: alertHistory.marker,
channels: [alertHistory.channelName],
});
await setRuleDisabledViaApi(page, doomedId, true);
await gotoAlertHistory(page, doomedId);
await expect(page.getByTestId('timeline-table')).toBeVisible();
await deleteAlertViaApi(page, doomedId);
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=${doomedId}`);
await expect(
page.getByText("Uh-oh! We couldn't find the given alert rule."),
).toBeVisible();
});
});

View File

@@ -1,286 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
runFilterExpression,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
waitForHistoryResponse,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
import { typeExpression } from '../../../helpers/query-builder';
test.describe('Alert history — expression filter', () => {
test('AF-06 key suggestions load on page load', async ({
authedPage: page,
alertHistory,
}) => {
const keysPromise = page.waitForResponse((res) =>
/\/history\/filter_keys/.test(res.url()),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const keysResponse = await keysPromise;
const body = (await keysResponse.json()) as {
data: { keys: Record<string, { name: string }[]> } | null;
};
expect(Object.keys(body.data?.keys ?? {}).sort()).toEqual([
'service.name',
'severity',
'threshold.name',
]);
const params = new URL(keysResponse.url()).searchParams;
expect(params.get('startUnixMilli')).toBeTruthy();
expect(params.get('endUnixMilli')).toBeTruthy();
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
});
test('AF-07 value suggestions fetch from filter_values endpoint', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const valuesPromise = page.waitForResponse((res) =>
/\/history\/filter_values/.test(res.url()),
);
await typeExpression(page, "service.name = '");
const valuesResponse = await valuesPromise;
const params = new URL(valuesResponse.url()).searchParams;
expect(params.get('name')).toBe('service.name');
const body = (await valuesResponse.json()) as {
data: { values?: { stringValues?: string[] }; complete?: boolean } | null;
};
expect(body.data?.values?.stringValues).toEqual(
[...alertHistory.services].sort(),
);
expect(body.data?.complete).toBe(true);
});
test('AF-08 value suggestions filter client-side as user types', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await typeExpression(page, "service.name = 'svc-1");
const options = page.locator('.cm-tooltip-autocomplete li');
await expect(options.first()).toBeVisible();
const texts = await options.allInnerTexts();
expect(texts.length).toBeGreaterThan(0);
expect(texts.length).toBeLessThan(alertHistory.services.length);
for (const text of texts) {
expect(text).toContain('svc-1');
}
});
test('AF-09 running equality expression filters the table', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const service = alertHistory.services[3];
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await runFilterExpression(page, `service.name = '${service}'`);
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('filterExpression')).toBe(
`service.name = '${service}'`,
);
await expect(timelineRows(page)).toHaveCount(1);
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
});
test('AF-10 running expression resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
const responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(
page,
`service.name = '${alertHistory.services[0]}'`,
);
await responsePromise;
await expect(page).not.toHaveURL(/[?&]page=2/);
await expectFirstPage(page);
});
test('AF-11 Run button re-fetches unchanged expression', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const expression = `service.name = '${alertHistory.services[1]}'`;
const firstResponsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, expression);
await firstResponsePromise;
await expect(timelineRows(page)).toHaveCount(1);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByRole('button', { name: /run query/i }).click();
await requestPromise;
await expect(timelineRows(page)).toHaveCount(1);
});
test('AF-12 in-flight query can be cancelled', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.route(
(url) => /\/history\/timeline/.test(url.pathname),
async (route) => {
await new Promise((resolve) => {
setTimeout(resolve, 3_000);
});
await route.continue();
},
);
await typeExpression(page, `service.name = '${alertHistory.services[2]}'`);
await page.getByRole('button', { name: /run query/i }).click();
const cancel = page.getByRole('button', { name: 'Cancel' });
await expect(cancel).toBeVisible();
await cancel.click();
await page.unrouteAll({ behavior: 'ignoreErrors' });
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /run query/i })).toBeVisible();
});
test('AF-13 threshold.name and severity keys filter correctly', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
let responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'critical'`);
await responsePromise;
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `severity = 'critical'`);
await responsePromise;
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
await gotoAlertHistory(page, alertHistory.ruleIdV1);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'warning'`);
await responsePromise;
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.totalV1}`,
);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'critical'`);
await responsePromise;
await expect(timelineRows(page)).toHaveCount(0);
});
test('AF-14 unknown key returns 200 with zero rows (not 500)', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `nonexistent.key = 'x'`);
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(timelineRows(page)).toHaveCount(0);
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
});
test('AF-15 expression is lost on Overview→History round-trip (known bug)', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const expression = `service.name = '${alertHistory.services[4]}'`;
const responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, expression);
await responsePromise;
await expect(timelineRows(page)).toHaveCount(1);
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
await page.getByTestId('alert-details-tab-overview').click();
await page.getByTestId('alert-details-tab-history').click();
await expect(page.getByTestId('timeline-table')).toBeVisible();
expect(new URL(page.url()).searchParams.has('alertHistoryExpression')).toBe(
false,
);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
});
test('AF-16 expression and state filter compose in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
const service = alertHistory.services[5];
await runFilterExpression(page, `service.name = '${service}'`);
const request = await requestPromise;
const params = requestUrl(request).searchParams;
expect(params.get('state')).toBe('firing');
expect(params.get('filterExpression')).toBe(`service.name = '${service}'`);
await expect(timelineRows(page)).toHaveCount(1);
});
test('AF-17 clearing expression restores full unfiltered list', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const firstResponsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(
page,
`service.name = '${alertHistory.services[6]}'`,
);
await firstResponsePromise;
await expect(timelineRows(page)).toHaveCount(1);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await runFilterExpression(page, '');
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('filterExpression')).toBeNull();
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.total}`,
);
});
});

View File

@@ -1,112 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
timelineRows,
waitForHistoryResponse,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
test.describe('Alert history — state filter', () => {
test('AF-01 All filter sends no state param in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-all').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=ALL/);
expect(requestUrl(request).searchParams.get('state')).toBeNull();
});
test('AF-02 Fired filter sends state=firing in request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-fired').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
expect(requestUrl(request).searchParams.get('state')).toBe('firing');
await expect(timelineRows(page).first()).toBeVisible();
});
test('AF-03 Resolved filter shows empty for rule with no resolutions', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-filter-resolved').click();
const request = await requestPromise;
await expect(page).toHaveURL(/[?&]timelineFilter=RESOLVED/);
expect(requestUrl(request).searchParams.get('state')).toBe('inactive');
await expect(timelineRows(page)).toHaveCount(0);
});
test('AF-03b Resolved filter shows rows for rule with resolutions', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId, {
timelineFilter: 'RESOLVED',
});
await expect(timelineRows(page)).toHaveCount(resolvedHistory.resolvedCount);
for (const text of await timelineRows(page)
.getByTestId('timeline-row-state')
.allInnerTexts()) {
expect(text).toBe('Resolved');
}
const responsePromise = waitForHistoryResponse(page, 'timeline');
await page.getByTestId('timeline-filter-fired').click();
await responsePromise;
await expect(timelineRows(page)).toHaveCount(resolvedHistory.firingCount);
});
test('AF-04 deep-link ?timelineFilter=FIRED starts on Fired tab', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
timelineFilter: 'FIRED',
});
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
/selected/,
);
await expect(timelineRows(page).first()).toBeVisible();
});
test('AF-05 changing state filter resets pagination to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
const responsePromise = waitForHistoryResponse(page, 'timeline');
await page.getByTestId('timeline-filter-fired').click();
await responsePromise;
await expect(page).not.toHaveURL(/[?&]page=2/);
await expectFirstPage(page);
});
});

View File

@@ -1,125 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
gotoAlertHistory,
statsCard,
timelineRows,
} from '../../../helpers/alerts';
test.describe('Alert history — statistics', () => {
test('AS-01 Total Triggered card shows the firing count', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Total Triggered');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute('data-empty', 'false');
await expect(card.getByTestId('stats-card-value')).toHaveText(
String(alertHistory.total),
);
});
test('AS-02 Avg. Resolution Time card shows "No Resolutions." when none exist', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Avg. Resolution Time');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute('data-empty', 'true');
const value = card.getByTestId('stats-card-value');
await expect(value).toHaveText('No Resolutions.');
await expect(value).not.toHaveText(/NaN/);
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
test('AS-03 empty stats card never renders a sparkline', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const empty = statsCard(page, 'Avg. Resolution Time');
await expect(empty).toHaveAttribute('data-empty', 'true');
await expect(empty.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AS-03b sparkline present with a multi-point series', async () => {
test.skip(
true,
'Not deterministic on SEED-A: whether `currentTriggersSeries` lands in ' +
'one stats bucket or two depends on where the ~2-minute seed falls ' +
'relative to the bucket boundary, so the `timeSeries.length > 1` gate ' +
'flips between runs (observed both ways). Needs a fixture that ' +
'guarantees ≥2 points — see coverage doc §3.5.',
);
});
test('AS-04 change-vs-past indicator shows "no previous data" when unavailable', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = statsCard(page, 'Total Triggered');
await expect(card.getByTestId('stats-card-change')).toHaveText(
'no previous data',
);
});
test('AS-09 stats update when time range changes', async ({
authedPage: page,
alertHistory,
}) => {
const end = Date.now() - 24 * 60 * 60 * 1000;
const start = end - 30 * 60 * 1000;
await gotoAlertHistory(page, alertHistory.ruleId, {
startTime: String(start),
endTime: String(end),
});
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText('None Triggered.');
await expect(
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
).toHaveText('No Resolutions.');
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText(String(alertHistory.total));
});
test('AS-11 Avg. Resolution Time shows formatted duration when resolutions exist', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
const card = statsCard(page, 'Avg. Resolution Time');
await expect(card).toHaveAttribute('data-empty', 'false');
await expect(card.getByTestId('stats-card-value')).not.toHaveText(
'No Resolutions.',
);
await expect(card.getByTestId('stats-card-value')).not.toHaveText('');
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
});
test('AS-12 Total Triggered counts only firing rows (not resolved)', async ({
authedPage: page,
resolvedHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
await expect(
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
).toHaveText(String(resolvedHistory.firingCount));
await expect(timelineRows(page)).toHaveCount(
resolvedHistory.firingCount + resolvedHistory.resolvedCount,
);
});
});

View File

@@ -1,99 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
DEFAULT_RELATIVE_TIME,
gotoAlertHistory,
isHistoryRequest,
} from '../../../helpers/alerts';
import { watchConsole } from '../../../helpers/common';
test.describe('Alert history — timeline graph', () => {
test('AT-03 renders canvas with two segments (inactive→firing)', async ({
authedPage: page,
alertHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as {
data: { state: string }[] | null;
};
const states = (body.data ?? []).map((segment) => segment.state);
expect(states).toEqual(['inactive', 'firing']);
await expect(page.getByTestId('timeline-graph')).toBeVisible();
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
await expect(page.getByTestId('timeline-graph-title')).toHaveText(
`${alertHistory.total} triggers in ${DEFAULT_RELATIVE_TIME}`,
);
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AT-03b renders canvas with three segments (inactive→firing→inactive)', async ({
authedPage: page,
resolvedHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, resolvedHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as { data: { state: string }[] | null };
expect((body.data ?? []).map((s) => s.state)).toEqual([
'inactive',
'firing',
'inactive',
]);
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AT-19 handles nodata state without console errors', async ({
authedPage: page,
noDataHistory,
}) => {
const watch = watchConsole(page);
const statusPromise = page.waitForResponse((res) =>
isHistoryRequest(res.request(), 'overall_status'),
);
await gotoAlertHistory(page, noDataHistory.ruleId);
const status = await statusPromise;
const body = (await status.json()) as { data: { state: string }[] | null };
const KNOWN_STATES = [
'firing',
'inactive',
'pending',
'nodata',
'recovering',
'disabled',
];
const states = (body.data ?? []).map((segment) => segment.state);
expect(states.length).toBeGreaterThan(0);
for (const state of states) {
expect(KNOWN_STATES).toContain(state);
}
await expect(
page.getByTestId('timeline-graph').locator('canvas'),
).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
});

View File

@@ -1,172 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
encodeTimelineCursor,
expectFirstPage,
gotoAlertHistory,
isHistoryRequest,
sortTimelineDescending,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRowLabels,
timelineRows,
} from '../../../helpers/alerts';
import { requestUrl } from '../../../helpers/common';
test.describe('Alert history — timeline pagination', () => {
test('AT-06 next page sends cursor and shows different rows', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const firstPageLabels = await timelineRowLabels(page);
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-next-page').click();
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('cursor')).toBe(
encodeTimelineCursor(2),
);
await expect(page).toHaveURL(/[?&]page=2/);
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await expect(timelineFooterRange(page)).toHaveText(
`${TIMELINE_PAGE_SIZE + 1}${alertHistory.total} of ${alertHistory.total}`,
);
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
expect(await timelineRowLabels(page)).not.toEqual(firstPageLabels);
});
test('AT-07 prev page drops the cursor from request', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await page.getByTestId('timeline-prev-page').click();
const request = await requestPromise;
expect(requestUrl(request).searchParams.get('cursor')).toBeNull();
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-08 pagination buttons disable at first and last page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
await expect(page.getByTestId('timeline-next-page')).toBeEnabled();
await page.getByTestId('timeline-next-page').click();
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
});
test('AT-09 browser back after paging returns to previous page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('timeline-next-page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await page.goBack();
await expect(page).not.toHaveURL(/[?&]page=2/);
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-10 deep-link ?page=2 loads second page directly', async ({
authedPage: page,
alertHistory,
}) => {
const requestPromise = page.waitForRequest(
(req) =>
isHistoryRequest(req, 'timeline') &&
requestUrl(req).searchParams.get('cursor') === encodeTimelineCursor(2),
);
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await requestPromise;
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
});
test('AT-11 default sort order is ascending', async ({
authedPage: page,
alertHistory,
}) => {
const requestPromise = page.waitForRequest((req) =>
isHistoryRequest(req, 'timeline'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
const request = await requestPromise;
expect(new URL(page.url()).searchParams.get('order')).toBeNull();
expect(requestUrl(request).searchParams.get('order')).toBe('asc');
});
test('AT-12 sorting toggles order and resets to first page', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
await sortTimelineDescending(page);
await expect(page).toHaveURL(/[?&]order=desc/);
await expectFirstPage(page);
});
test('AT-13 single page disables both pagination buttons', async ({
authedPage: page,
metricsHistory,
}) => {
await gotoAlertHistory(page, metricsHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(metricsHistory.total);
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${metricsHistory.total} of ${metricsHistory.total}`,
);
});
test('AT-21 all pages together cover the complete row set', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const pageOne = await timelineRowLabels(page);
await page.getByTestId('timeline-next-page').click();
await expect(timelineRows(page)).toHaveCount(
alertHistory.total - TIMELINE_PAGE_SIZE,
);
await expect(timelineFooterRange(page)).toHaveText(
`${TIMELINE_PAGE_SIZE + 1}${alertHistory.total} of ${alertHistory.total}`,
);
const pageTwo = await timelineRowLabels(page);
const union = new Set([...pageOne, ...pageTwo]);
expect(pageOne.length + pageTwo.length).toBe(alertHistory.total);
expect(union.size).toBe(alertHistory.total);
});
});

View File

@@ -1,198 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import {
firstTimelineRowCreatedAt,
gotoAlertHistory,
TIMELINE_PAGE_SIZE,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts';
test.describe('Alert history — timeline table', () => {
test('AT-01 timeline section renders all chrome elements', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.locator('.timeline__title')).toHaveText('Timeline');
await expect(page.getByTestId('timeline-tab-overall-status')).toBeVisible();
await expect(page.getByTestId('timeline-filter-all')).toBeVisible();
await expect(page.getByTestId('timeline-filter-fired')).toBeVisible();
await expect(page.getByTestId('timeline-filter-resolved')).toBeVisible();
await expect(page.getByTestId('timeline-graph')).toBeVisible();
await expect(page.getByTestId('timeline-table')).toBeVisible();
});
test('AT-02 Top 5 Contributors tab is disabled with Coming Soon indicator', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const tab = page.getByTestId('timeline-tab-top-contributors');
await expect(tab).toBeDisabled();
await expect(tab).toContainText('Coming Soon');
});
test('AT-04 table rows display state, labels and formatted timestamp', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
const first = timelineRows(page).first();
await expect(first.getByTestId('timeline-row-state')).toHaveText('Firing');
await expect(first.getByTestId('timeline-row-labels')).toContainText(
'service.name',
);
await expect(first.getByTestId('timeline-row-created-at')).toHaveText(
/^[A-Z][a-z]{2} \d{1,2}, \d{4} ⎯ \d{2}:\d{2}:\d{2}$/,
);
});
test('AT-05 footer shows correct row range', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineFooterRange(page)).toHaveText(
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
);
});
test('AT-14 row click does not navigate away', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const before = page.url();
await timelineRows(page).first().click();
expect(page.url()).toBe(before);
await expect(page.getByTestId('timeline-table')).toBeVisible();
});
test('AT-15 row actions link navigates to logs explorer', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await timelineRows(page).first().getByTestId('timeline-row-actions').click();
const viewLogs = page.getByTestId('alert-popover-view-logs');
await expect(viewLogs).toBeVisible();
await viewLogs.click();
await expect(page).toHaveURL(/\/logs\/logs-explorer/);
const search = new URL(page.url()).searchParams;
expect(search.get('startTime')).toBeTruthy();
expect(search.get('endTime')).toBeTruthy();
expect(page.url()).toContain('compositeQuery');
});
test('AT-15b row actions link navigates to traces explorer', async ({
authedPage: page,
tracesHistory,
}) => {
await gotoAlertHistory(page, tracesHistory.ruleId);
await timelineRows(page).first().getByTestId('timeline-row-actions').click();
// A traces rule yields a traces link only — the backend returns one or the
// other, never both, so "View Logs" must be absent.
const viewTraces = page.getByTestId('alert-popover-view-traces');
await expect(viewTraces).toBeVisible();
await expect(page.getByTestId('alert-popover-view-logs')).toHaveCount(0);
await viewTraces.click();
await expect(page).toHaveURL(/\/traces-explorer/);
const search = new URL(page.url()).searchParams;
expect(search.get('startTime')).toBeTruthy();
expect(search.get('endTime')).toBeTruthy();
expect(page.url()).toContain('compositeQuery');
});
test('AT-16 metrics rule rows show disabled action (no related links)', async ({
authedPage: page,
metricsHistory,
}) => {
await gotoAlertHistory(page, metricsHistory.ruleId);
const action = timelineRows(page).first().getByTestId('timeline-row-actions');
await expect(action).toBeDisabled();
await expect(page.getByTestId('alert-popover-view-logs')).toHaveCount(0);
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
});
test('AT-17 CREATED AT column respects app timezone setting', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const utcCell = await firstTimelineRowCreatedAt(page);
await page.addInitScript(() =>
localStorage.setItem('PREFERRED_TIMEZONE', 'Asia/Kolkata'),
);
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
expect(await firstTimelineRowCreatedAt(page)).not.toBe(utcCell);
});
test('AT-18 state cell renders Firing, Resolved, and No Data correctly', async ({
authedPage: page,
resolvedHistory,
noDataHistory,
}) => {
await gotoAlertHistory(page, resolvedHistory.ruleId);
await expect(timelineRows(page).first()).toBeVisible();
const stateCells = timelineRows(page).getByTestId('timeline-row-state');
const labels = [...new Set(await stateCells.allInnerTexts())];
expect(labels).toContain('Firing');
expect(labels).toContain('Resolved');
await gotoAlertHistory(page, noDataHistory.ruleId);
await expect(
timelineRows(page).getByTestId('timeline-row-state').first(),
).toHaveText('No Data');
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-18b pending/recovering states render blank (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): `pending` and `recovering` are ' +
'transient states no cheap fixture produces. AlertState.tsx has no ' +
'`case` for either, so both hit `default` and the STATE cell renders ' +
'empty — write this as a bug-catch once the seeder endpoint exists.',
);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-18c disabled state renders as "Muted" (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): a `disabled` history row is ' +
'policy-driven and the ruler never writes one for a rule we disable ' +
'(verified: disabling appends no row).',
);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('AT-20 time-range boundaries inclusive/exclusive (coverage gap)', async () => {
test.skip(
true,
'Needs SEED-D (coverage doc §3.5): asserting a row exactly at `start` ' +
'and one at `start-1ms` requires controlling the row timestamps, and ' +
'evaluation times are whatever the ruler chose.',
);
});
});

View File

@@ -1,118 +0,0 @@
import { expect, test } from '../../../fixtures/alert-history';
import { gotoAlertHistory } from '../../../helpers/alerts';
test.describe('Alert history — top contributors', () => {
test('AS-05 card displays max 3 rows with count ratios', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const card = page.getByTestId('top-contributors-card');
await expect(card).toBeVisible();
await expect(card).toContainText('top contributors');
const rows = card.getByTestId('top-contributors-row');
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i += 1) {
await expect(
rows.nth(i).getByTestId('top-contributors-row-count'),
).toHaveText(`1/${alertHistory.total}`);
await expect(rows.nth(i)).toContainText('service.name');
}
});
// AS-05 reads the `count/total` text, which a *different* column renders than
// the bar does. Dropping the `/ total * 100` scaling from the bar leaves that
// text untouched, so the width needs its own assertion. Radix surfaces the
// clamped percent as `aria-valuenow`.
test('AS-13 contributor bar width is the count as a percentage of the total', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
const rows = page
.getByTestId('top-contributors-card')
.getByTestId('top-contributors-row');
await expect(rows).toHaveCount(3);
// Every SEED-A contributor fired exactly once out of `total`.
const percent = String((1 / alertHistory.total) * 100);
for (let i = 0; i < 3; i += 1) {
await expect(rows.nth(i).getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
percent,
);
}
});
test('AS-06 "View all" button only appears when more than 3 contributors', async ({
authedPage: page,
alertHistory,
metricsHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await expect(page.getByTestId('top-contributors-view-all')).toBeVisible();
await gotoAlertHistory(page, metricsHistory.ruleId);
await expect(page.getByTestId('top-contributors-card')).toBeVisible();
await expect(page.getByTestId('top-contributors-view-all')).toHaveCount(0);
});
test('AS-07 View-all drawer shows paginated list of all contributors', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('top-contributors-view-all').click();
const drawer = page.getByTestId('top-contributors-drawer');
await expect(drawer).toBeVisible();
await expect(page.getByText('Viewing All Contributors')).toBeVisible();
await expect(drawer.getByTestId('top-contributors-row')).toHaveCount(10);
await expect(drawer.locator('.total')).toHaveText(
` of ${alertHistory.total}`,
);
});
test('AS-07b drawer opens from deep link with ?viewAllTopContributors=true', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId, {
viewAllTopContributors: 'true',
});
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
});
test('AS-08 View-all click adds ?viewAllTopContributors=true to URL', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page.getByTestId('top-contributors-view-all').click();
await expect(page).toHaveURL(/[?&]viewAllTopContributors=true/);
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
});
test('AS-10 contributor rows show related-logs link for logs-based rules', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertHistory(page, alertHistory.ruleId);
await page
.getByTestId('top-contributors-card')
.getByTestId('top-contributors-row')
.first()
.getByTestId('top-contributors-row-count')
.click();
await expect(page.getByTestId('alert-popover-view-logs')).toBeVisible();
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
});
});

View File

@@ -1,74 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
gotoAlertList,
} from '../../../helpers/alerts';
test.describe('Alert rules list — columns', () => {
test('LR-01 renders all default columns (Status, Alert Name, Severity, Labels, Actions)', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
for (const header of ['Status', 'Alert Name', 'Severity', 'Labels']) {
await expect(page.getByRole('columnheader', { name: header })).toBeVisible();
}
await expect(
page.getByRole('columnheader', { name: 'Actions' }),
).toBeVisible();
for (const hidden of [
'Created At',
'Created By',
'Updated At',
'Updated By',
]) {
await expect(page.getByRole('columnheader', { name: hidden })).toHaveCount(
0,
);
}
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await expect(page.getByTestId('alert-columns-button')).toBeVisible();
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('LR-02 shows empty state when no rules exist', async () => {
test.skip(
true,
'AlertsEmptyState needs a zero-rule workspace; unreachable without ' +
'tearing down SEED-B mid-suite. Covered by the component test.',
);
});
test('LR-10 column selector hides and shows a column', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toBeVisible();
await page.getByTestId('alert-columns-button').click();
await page.getByText('Toggle Columns').waitFor();
const severityToggle = page.locator('label', { hasText: 'Severity' });
await severityToggle.click();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toHaveCount(0);
await page.reload();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toHaveCount(0);
await page.getByTestId('alert-columns-button').click();
await page.locator('label', { hasText: 'Severity' }).click();
await expect(
page.getByRole('columnheader', { name: 'Severity' }),
).toBeVisible();
});
});

View File

@@ -1,88 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
test.describe('Alert rules list — navigation', () => {
test('LR-11 row click opens the overview page', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await alertRuleRows(page).first().click();
await expect(page).toHaveURL(/\/alerts\/overview\?/);
await expect(page).toHaveURL(/[?&]ruleId=/);
await expect(page).toHaveURL(/[?&]compositeQuery=/);
await expect(page).toHaveURL(/[?&]panelTypes=/);
});
test('LR-12 ctrl/cmd-click opens the overview in a new tab', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
// Bounded on purpose. This scenario is intermittently red (~1 in 5 at
// --workers=1, worse under parallel load): the ctrl+click lands, one or
// more popups open, and the `page` event never arrives. Unbounded, the
// wait burns the whole test timeout — 120s in a mutation run — which made
// it the single largest item on the critical path. The flake is *not*
// fixed by this; it just costs seconds instead of minutes when it trips.
const [newPage] = await Promise.all([
page.context().waitForEvent('page', { timeout: 15_000 }),
alertRuleRows(page)
.first()
.click({ modifiers: ['ControlOrMeta'] }),
]);
await newPage.waitForLoadState();
expect(newPage.url()).toContain('/alerts/overview');
expect(newPage.url()).toContain('ruleId=');
await newPage.close();
});
test('LR-13 actions menu Edit and Edit in New Tab navigate correctly', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await page.getByRole('menuitem', { name: 'Edit in New Tab' }).waitFor();
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
page.getByRole('menuitem', { name: 'Edit in New Tab' }).click(),
]);
await newPage.waitForLoadState();
expect(newPage.url()).toContain('/alerts/overview');
await newPage.close();
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await expect(page).toHaveURL(/\/alerts\/overview\?/);
await expect(page).toHaveURL(/[?&]ruleId=/);
});
test('LR-17 New Alert button navigates to alert creation', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await page.getByTestId('list-alerts-new-alert-button').click();
await expect(page).toHaveURL(/\/alerts\/new/);
});
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
test('LR-18 shows ErrorEmptyState when list fails to load', async () => {
test.skip(
true,
'Not covered: the suite never stubs network, and there is no server-side ' +
'way to make GET /api/v1/rules fail on demand. Left explicitly ' +
'untested rather than mocked.',
);
});
});

View File

@@ -1,58 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
gotoAlertList,
} from '../../../helpers/alerts';
test.describe('Alert rules list — pagination and sorting', () => {
test('LR-07 navigates between pages', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(page.getByTestId('pagination-total-count')).toContainText(
`of ${alertList.count}`,
);
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await page.getByLabel('Go to next page').click();
await expect(page).toHaveURL(/[?&]page=2/);
await expect(alertRuleRows(page)).toHaveCount(
alertList.count - ALERT_LIST_PAGE_SIZE,
);
await expect(page.getByTestId('pagination-total-count')).toContainText(
`${ALERT_LIST_PAGE_SIZE + 1} - ${alertList.count}`,
);
});
test('LR-08 changes page size', async ({ authedPage: page, alertList }) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
await page.getByTestId('pagination-page-size').click();
await page.getByRole('option', { name: '20', exact: true }).click();
await expect(page).toHaveURL(/[?&]limit=20/);
await expect(alertRuleRows(page)).toHaveCount(alertList.count);
});
test('LR-09 sorts by column header click', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
const firstCell = alertRuleRows(page).first();
const ascFirst = await firstCell.textContent();
await page.getByRole('button', { name: 'Alert Name' }).click();
await expect(page).toHaveURL(/[?&]orderBy=/);
await page.getByRole('button', { name: 'Alert Name' }).click();
await expect(page).toHaveURL(/[?&]orderBy=/);
await expect(alertRuleRows(page).first()).not.toHaveText(ascFirst ?? '');
});
});

View File

@@ -1,92 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
test.describe('Alert rules list — row actions', () => {
test('LR-14 Disable then Enable toggles the rule state', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-toggle-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
const row = alertRuleRows(page).first();
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
await row.getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
page.getByRole('menuitem', { name: 'Disable' }).click(),
]);
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText(
'Disabled',
);
await row.getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
page.getByRole('menuitem', { name: 'Enable' }).click(),
]);
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
});
test('LR-15 Clone creates a copy and shows success toast', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-clone-${Date.now()}`;
await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
const [createResponse] = await Promise.all([
page.waitForResponse(
(res) =>
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
res.request().method() === 'POST',
),
page.getByRole('menuitem', { name: 'Clone' }).click(),
]);
await ownedRules.register(createResponse);
await expect(page.getByText('Alert cloned successfully')).toBeVisible();
await gotoAlertList(page, { search: name });
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
});
test('LR-16 Delete removes the rule and shows success toast', async ({
authedPage: page,
ownedRules,
}) => {
const name = `e2e-alert-row-delete-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
await gotoAlertList(page, { search: name });
await alertRuleRows(page).first().getByTestId('alert-actions').click();
await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'DELETE',
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByText('Alert deleted successfully')).toBeVisible();
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
});
});

View File

@@ -1,113 +0,0 @@
import { expect, test } from '../../../fixtures/alert-rules';
import {
ALERT_LIST_PAGE_SIZE,
alertRuleRows,
expectFirstPage,
gotoAlertList,
SEED_B_SEVERITIES,
} from '../../../helpers/alerts';
test.describe('Alert rules list — search', () => {
test('LR-03 filters by name', async ({ authedPage: page, alertList }) => {
await gotoAlertList(page);
await page
.getByTestId('list-alerts-search-input')
.fill(`${alertList.namePrefix}-03`);
await expect(page).toHaveURL(new RegExp(`search=${alertList.namePrefix}-03`));
await expect(alertRuleRows(page)).toHaveCount(1);
await expect(page.getByText(`${alertList.namePrefix}-03`)).toBeVisible();
});
test('LR-04 filters by severity and by label', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page);
const search = page.getByTestId('list-alerts-search-input');
const perSeverity = Math.floor(alertList.count / SEED_B_SEVERITIES.length);
await search.fill('warning');
await expect(page).toHaveURL(/search=warning/);
await expect(alertRuleRows(page)).not.toHaveCount(0);
for (const row of await alertRuleRows(page).all()) {
await expect(row).toContainText('warning');
}
expect(await alertRuleRows(page).count()).toBeGreaterThanOrEqual(perSeverity);
await search.fill(alertList.paymentsLabel);
await expect(page).toHaveURL(new RegExp(`search=${alertList.paymentsLabel}`));
await expect(alertRuleRows(page)).toHaveCount(alertList.count / 2);
});
test('LR-05 shows no-results state with clear button', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { search: alertList.namePrefix });
await page
.getByTestId('list-alerts-search-input')
.fill('no-such-alert-anywhere');
await expect(page.getByText('No matching alert rules')).toBeVisible();
await page.getByRole('button', { name: 'Clear Search' }).click();
await expect(page).not.toHaveURL(/search=/);
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
});
test('LR-06 resets pagination when searching', async ({
authedPage: page,
alertList,
}) => {
await gotoAlertList(page, { page: '2' });
await expect(page).toHaveURL(/[?&]page=2/);
await page.getByTestId('list-alerts-search-input').fill(alertList.namePrefix);
await expectFirstPage(page);
});
test('LR-19 state and severity filters intersect, they do not union', async ({
authedPage: page,
alertList,
}) => {
const perSeverity = alertList.count / SEED_B_SEVERITIES.length;
const scoped = { search: alertList.namePrefix };
// One filter kind: a third of the batch.
await gotoAlertList(page, {
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning']),
});
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
for (const row of await alertRuleRows(page).all()) {
await expect(row).toContainText('warning');
}
// Both kinds, both satisfied — seeded rules never fire, so they sit in
// `inactive`. The intersection is unchanged.
await gotoAlertList(page, {
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:inactive']),
});
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
// Both kinds, only one satisfied. Under AND this is empty; under OR the
// severity match alone would still surface all `perSeverity` rules.
await gotoAlertList(
page,
{
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:firing']),
},
{ expectRows: false },
);
await expect(alertRuleRows(page)).toHaveCount(0);
await expect(page.getByText('No matching alert rules')).toBeVisible();
});
});

View File

@@ -1,130 +0,0 @@
import { expect, test } from '../../fixtures/alert-rules';
import { ALERTS_LIST_PATH } from '../../helpers/alerts';
import { watchConsole } from '../../helpers/common';
// AL-* — the Alerts page shell: the four top-level tabs, how they map to
// `?tab=`, and a navigation smoke check per tab. Tab *internals* (channel CRUD,
// planned downtime, routing policies) are deliberately out of scope — each
// deserves its own spec file.
const TAB_NAMES = {
triggered: /triggered alerts/i,
rules: /alert rules/i,
channels: /notification channels/i,
configuration: /configuration/i,
};
test.describe('Alerts page shell', () => {
test('AL-01 all four top-level tabs render', async ({ authedPage: page }) => {
await page.goto(ALERTS_LIST_PATH);
for (const name of Object.values(TAB_NAMES)) {
await expect(page.getByRole('tab', { name })).toBeVisible();
}
});
test('AL-02 default tab is Alert Rules', async ({ authedPage: page }) => {
await page.goto(ALERTS_LIST_PATH);
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
await expect(page).toHaveURL(ALERTS_LIST_PATH);
await expect(
page.getByRole('tab', { name: TAB_NAMES.rules }),
).toHaveAttribute('aria-selected', 'true');
// This spec seeds no rules, so the pane may render either the table (with
// its search input) or `AlertsEmptyState` depending on what neighbouring
// specs have in flight. Assert the pane itself, not one of its variants —
// `ListAlertRules` hides the search input entirely in the empty state.
await expect(
page
.getByRole('heading', { name: 'Alert Rules' })
.or(page.getByTestId('list-alerts-search-input')),
).toBeVisible();
});
test('AL-03 tab switch writes ?tab= and clears subTab', async ({
authedPage: page,
}) => {
await page.goto(ALERTS_LIST_PATH);
await page.getByRole('tab', { name: TAB_NAMES.triggered }).click();
await expect(page).toHaveURL(/[?&]tab=TriggeredAlerts/);
await page.getByRole('tab', { name: TAB_NAMES.channels }).click();
await expect(page).toHaveURL(/[?&]tab=Channels/);
// Entering Configuration adds the default subTab...
await page.getByRole('tab', { name: TAB_NAMES.configuration }).click();
await expect(page).toHaveURL(/[?&]tab=Configuration/);
await expect(page).toHaveURL(/[?&]subTab=planned-downtime/);
// ...and leaving it drops subTab entirely (onChange rebuilds the search
// from scratch rather than mutating the existing params).
await page.getByRole('tab', { name: TAB_NAMES.rules }).click();
await expect(page).toHaveURL(/[?&]tab=AlertRules/);
await expect(page).not.toHaveURL(/subTab=/);
});
test('AL-04 Configuration deep-link', async ({ authedPage: page }) => {
// Deep-linking without subTab: the inner Tabs falls back to
// planned-downtime for its activeKey without writing it to the URL, so
// assert the rendered tab, not the param.
await page.goto(`${ALERTS_LIST_PATH}?tab=Configuration`);
await expect(
page.getByRole('tab', { name: /planned downtime/i }),
).toHaveAttribute('aria-selected', 'true');
await page.goto(
`${ALERTS_LIST_PATH}?tab=Configuration&subTab=routing-policies`,
);
await expect(
page.getByRole('tab', { name: /routing policies/i }),
).toHaveAttribute('aria-selected', 'true');
});
test('AL-05 Triggered Alerts tab smoke', async ({ authedPage: page }) => {
// Known application defect, out of scope here: an icon on this tab renders
// with a NaN dimension ("<svg> attribute viewBox: Expected number, \"0 0 32
// NaN\""). Ignore that one string so the rest of the console guard still
// has teeth.
const watch = watchConsole(page, {
ignore: ['attribute viewBox: Expected number'],
});
await page.goto(`${ALERTS_LIST_PATH}?tab=TriggeredAlerts`);
// A fresh stack has no firing instances, so either the table or the empty
// state is correct — what matters is that the tab mounts and its controls
// render.
await expect(page.getByTestId('triggered-alerts-search-input')).toBeVisible();
expect(watch.errors).toEqual([]);
expect(watch.failedResponses).toEqual([]);
});
test('AL-06 Notification Channels tab smoke', async ({
authedPage: page,
alertChannel,
}) => {
// The worker's channel gives this tab a row to render.
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(page.getByText(alertChannel.name)).toBeVisible();
await expect(
page.getByRole('button', { name: /new alert channel/i }),
).toBeVisible();
});
test('AL-07 tab state survives reload', async ({ authedPage: page }) => {
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
await expect(
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
await page.reload();
await expect(page).toHaveURL(/[?&]tab=Channels/);
await expect(
page.getByRole('tab', { name: TAB_NAMES.channels }),
).toHaveAttribute('aria-selected', 'true');
});
});