Compare commits

...

16 Commits

Author SHA1 Message Date
Vinícius Lourenço
7a167ddeac refactor(alerts): cleanup comments / reduce flakyness 2026-07-31 17:41:13 -03:00
Vinícius Lourenço
97557d0145 feat(alerts-create-edit): add initial unfiltered tests 2026-07-31 16:24:00 -03:00
Vinícius Lourenço
bf247a000a feat(alerts-v1): add test ids 2026-07-31 16:23:11 -03:00
Vinícius Lourenço
720ccfde5e fix(alerts): continue more fixes to prevent flaky 2026-07-31 14:52:25 -03:00
Vinícius Lourenço
5c95bf63da fix(alerts): prevent more flaky tests 2026-07-31 10:58:07 -03:00
Vinícius Lourenço
2f257fa04f fix(fmt): lint issue 2026-07-30 17:59:02 -03:00
Vinícius Lourenço
c08eb2f70a perf(with-web-dockerfile): skip build with race 2026-07-30 17:03:05 -03:00
Vinícius Lourenço
a185a504f8 fix(timeline-pagination): improve flaky test 2026-07-30 17:01:08 -03:00
Vinícius Lourenço
7e62d1edf7 chore(package): add few more scripts 2026-07-30 16:09:05 -03:00
Vinícius Lourenço
221063c91d docs(e2e): fix path for alerts 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
99f1c8333e refactor(alerts): make it more resilient 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
dd502444d0 refactor(auth): cleanups on auth due to mutating test locallly 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
f5e1c25b23 refactor(alerts): clean and re-organize the tests 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
eea7b3eba5 feat(alerts): add initial e2e 2026-07-30 16:09:04 -03:00
Vinícius Lourenço
cbbd96ab50 chore(alert): add test ids 2026-07-30 16:09:04 -03:00
Vinicius Lourenço
ee06d549e6 chore(alert-history): use v2 apis (#12132)
* feat(query-search): add support for custom value fetcher

* feat(alert-history): use v2 apis

* refactor(api): remove types and old apis

* fix(rule-history): not accepting severity/threshold.name as filter

* fix(cursor-pagination): simplify pagination

* fix(timeline-table): keep as asc

No idea why it was preferred this way

* fix(hooks): reset page after filter change

* refactor(alert-history): removed unused code

* fix(alert-history-table): add more safe checks

* fix(pr): address comments

* fix(pr): fallback to known existing keys on error for keys

* fix(pr): reset page on change time

* fix(query-search): pass complete prop

* fix(query-search): clean the code a little bit

* fix(filter-suggestions): remove limit

* feat(links): add back the links

* fix(table): remove type hack with new support for links from backend

* fix(alert-history-suggestions): render empty keys when api call fails

And also shows good loading feedback

* fix(query-search): backdrop-filter leaking visual artifacts for some reason

I was only able to reproduce this on alert-history,
not sure why other places don't have the same issue

* fix(alert-history-filter-suggestions): broken types after openapi update

* Revert "fix(query-search): backdrop-filter leaking visual artifacts for some reason"

This reverts commit c8b28c81ef.

* fix(alert-history-query-search): a simple fix for the issue of the visual artifacts

* fix(store): be extract safe in case fieldKeys is nil

* refactor(graph): improve a little bit the state colors for recovering/pending

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-07-30 17:25:17 +00:00
86 changed files with 8703 additions and 741 deletions

View File

@@ -38,7 +38,8 @@ 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-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

View File

@@ -227,11 +227,14 @@ 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/alerts.spec.ts --project=chromium
npx playwright test tests/alerts/page.spec.ts --project=chromium
# Single test by title grep
npx playwright test --project=chromium -g "TC-01"
npx playwright test --project=chromium -g "AL-01"
```
### Iterative modes
@@ -265,7 +268,14 @@ 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. |
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.
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.
### Playwright options

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
const ruleStats = async (
props: RuleStatsProps,
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
try {
const response = await axios.post(`/rules/${props.id}/history/stats`, {
start: props.start,
end: props.end,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default ruleStats;

View File

@@ -1,33 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
const timelineGraph = async (
props: GetTimelineGraphRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/overall_status`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineGraph;

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
const timelineTable = async (
props: GetTimelineTableRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
start: props.start,
end: props.end,
offset: props.offset,
limit: props.limit,
order: props.order,
state: props.state,
// TODO(shaheer): implement filters
filters: props.filters,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineTable;

View File

@@ -1,33 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
import { TopContributorsProps } from 'types/api/alerts/topContributors';
const topContributors = async (
props: TopContributorsProps,
): Promise<
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/top_contributors`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default topContributors;

View File

@@ -102,6 +102,15 @@ interface QuerySearchProps {
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
valueSuggestionsOverride?: (
key: string,
searchText: string,
) => Promise<{
stringValues: string[];
numberValues: number[];
complete: boolean;
}>;
}
function QuerySearch({
@@ -115,6 +124,7 @@ function QuerySearch({
showFilterSuggestionsWithoutMetric,
initialExpression,
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -484,13 +494,24 @@ function QuerySearch({
const sanitizedSearchText = searchText ? searchText?.trim() : '';
try {
const response = await getValueSuggestions({
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
});
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await getValueSuggestions({
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
const responseData = response.data as any;
const data = responseData.data || {};
const values = data.values || {};
return {
stringValues: values.stringValues || [],
numberValues: values.numberValues || [],
complete: data.complete ?? false,
};
});
// Skip updates if component unmounted or key changed
if (
@@ -502,8 +523,6 @@ function QuerySearch({
}
// Process the response data
const responseData = response.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
@@ -584,6 +603,7 @@ function QuerySearch({
debouncedMetricName,
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
],
);

View File

@@ -20,10 +20,6 @@ export const REACT_QUERY_KEY = {
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
GET_ALL_ALERTS: 'GET_ALL_ALERTS',

View File

@@ -29,6 +29,7 @@ function PopoverContent({
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-logs"
>
<div className="icon">
<LogsIcon />
@@ -40,6 +41,7 @@ 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,7 +26,10 @@ function ChangePercentage({
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div className="change-percentage change-percentage--success">
<div
className="change-percentage change-percentage--success"
data-testid="stats-card-change"
>
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
</div>
@@ -38,7 +41,10 @@ function ChangePercentage({
}
if (direction < 0) {
return (
<div className="change-percentage change-percentage--error">
<div
className="change-percentage change-percentage--error"
data-testid="stats-card-change"
>
<div className="change-percentage__icon">
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
</div>
@@ -50,7 +56,10 @@ function ChangePercentage({
}
return (
<div className="change-percentage change-percentage--no-previous-data">
<div
className="change-percentage change-percentage--no-previous-data"
data-testid="stats-card-change"
>
<div className="change-percentage__label">no previous data</div>
</div>
);
@@ -103,7 +112,12 @@ function StatsCard({
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
<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__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
@@ -123,7 +137,7 @@ function StatsCard({
</div>
<div className="stats-card__stats">
<div className="count-label">
<div className="count-label" data-testid="stats-card-value">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>

View File

@@ -81,7 +81,11 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
);
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<div
style={{ height: '100%', width: '100%' }}
ref={graphRef}
data-testid="stats-card-sparkline"
>
<Uplot data={[xData, yData]} options={options} />
</div>
);

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { StatsTimeSeriesItem } from 'types/api/alerts/def';
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
import StatsCard from '../StatsCard/StatsCard';
@@ -25,24 +26,59 @@ type StatsCardsRendererProps = {
};
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
type AdaptedStatsData = {
totalCurrentTriggers: number;
totalPastTriggers: number;
currentAvgResolutionTime: string;
pastAvgResolutionTime: string;
currentTriggersSeries: StatsTimeSeriesItem[];
currentAvgResolutionTimeSeries: StatsTimeSeriesItem[];
};
function StatsCardsRenderer({
setTotalCurrentTriggers,
}: StatsCardsRendererProps): JSX.Element {
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsStats();
useEffect(() => {
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
const adaptedData = useMemo((): AdaptedStatsData | null => {
if (!data?.data) {
return null;
}
}, [data, setTotalCurrentTriggers]);
const statsData = data.data;
const adaptTimeSeries = (
series: typeof statsData.currentTriggersSeries,
): StatsTimeSeriesItem[] =>
series?.values?.map((item) => ({
timestamp: item.timestamp ?? 0,
value: String(item.value ?? 0),
})) ?? [];
return {
totalCurrentTriggers: statsData.totalCurrentTriggers,
totalPastTriggers: statsData.totalPastTriggers,
currentAvgResolutionTime: String(statsData.currentAvgResolutionTime),
pastAvgResolutionTime: String(statsData.pastAvgResolutionTime),
currentTriggersSeries: adaptTimeSeries(statsData.currentTriggersSeries),
currentAvgResolutionTimeSeries: adaptTimeSeries(
statsData.currentAvgResolutionTimeSeries,
),
};
}, [data?.data]);
useEffect(() => {
if (adaptedData?.totalCurrentTriggers !== undefined) {
setTotalCurrentTriggers(adaptedData.totalCurrentTriggers);
}
}, [adaptedData, setTotalCurrentTriggers]);
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={data?.payload?.data || null}
data={adaptedData}
>
{(data): JSX.Element => {
const {
@@ -60,7 +96,7 @@ function StatsCardsRenderer({
<TotalTriggeredCard
totalCurrentTriggers={totalCurrentTriggers}
totalPastTriggers={totalPastTriggers}
timeSeries={currentTriggersSeries?.values}
timeSeries={currentTriggersSeries}
/>
) : (
<StatsCard
@@ -77,7 +113,7 @@ function StatsCardsRenderer({
<AverageResolutionCard
currentAvgResolutionTime={currentAvgResolutionTime}
pastAvgResolutionTime={pastAvgResolutionTime}
timeSeries={currentAvgResolutionTimeSeries?.values}
timeSeries={currentAvgResolutionTimeSeries}
/>
) : (
<StatsCard

View File

@@ -48,11 +48,16 @@ function TopContributorsCard({
return (
<>
<div className="top-contributors-card">
<div className="top-contributors-card" data-testid="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}>
<Button
type="text"
className="view-all"
onClick={toggleViewAllDrawer}
data-testid="top-contributors-view-all"
>
<div className="label">View all</div>
<div className="icon">
<ArrowRight

View File

@@ -68,7 +68,10 @@ function TopContributorsRows({
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="total-contribution">
<div
className="total-contribution"
data-testid="top-contributors-row-count"
>
{count}/{totalCurrentTriggers}
</div>
</ConditionalAlertPopover>
@@ -78,7 +81,10 @@ function TopContributorsRows({
const handleRowClick = (
record: AlertRuleTopContributors,
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'top-contributors-row',
onClick: (): void => {
logEvent('Alert history: Top contributors row: Clicked', {
labels: record.labels,

View File

@@ -31,7 +31,10 @@ function ViewAllDrawer({
}}
title="Viewing All Contributors"
>
<div className="top-contributors-card--view-all">
<div
className="top-contributors-card--view-all"
data-testid="top-contributors-drawer"
>
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData}

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleStats } from 'types/api/alerts/def';
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
@@ -13,15 +15,27 @@ function TopContributorsRenderer({
}: TopContributorsRendererProps): JSX.Element {
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTopContributors();
const response = data?.payload?.data;
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
const adaptedData = useMemo((): AlertRuleTopContributors[] | null => {
if (!data?.data) {
return null;
}
return data.data.map((contributor) => ({
fingerprint: contributor.fingerprint,
count: contributor.count,
labels: labelsArrayToObject(contributor.labels),
relatedLogsLink: contributor.relatedLogsLink ?? '',
relatedTracesLink: contributor.relatedTracesLink ?? '',
}));
}, [data?.data]);
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={response || null}
data={adaptedData}
>
{(topContributorsData): JSX.Element => (
<TopContributorsCard

View File

@@ -1,12 +1,18 @@
import { Color } from '@signozhq/design-tokens';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
export const ALERT_STATUS: { [key: string]: number } = {
firing: 0,
inactive: 1,
export const ALERT_STATUS: Record<RuletypesAlertStateDTO, number> & {
[key: string]: number;
} = {
[RuletypesAlertStateDTO.firing]: 0,
[RuletypesAlertStateDTO.inactive]: 1,
normal: 1,
'no-data': 2,
disabled: 3,
muted: 4,
[RuletypesAlertStateDTO.pending]: 2,
[RuletypesAlertStateDTO.recovering]: 2,
'no-data': 3,
[RuletypesAlertStateDTO.nodata]: 3,
[RuletypesAlertStateDTO.disabled]: 4,
muted: 5,
};
export const STATE_VS_COLOR: {
@@ -16,9 +22,10 @@ export const STATE_VS_COLOR: {
{
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
2: { stroke: Color.BG_AMBER_500, fill: Color.BG_AMBER_500 },
3: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
4: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
5: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
},
];

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
import Graph from '../Graph/Graph';
@@ -18,30 +20,20 @@ function GraphWrapper({
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineGraphData();
// TODO(shaheer): uncomment when the API is ready for
// const { startTime } = useAlertHistoryQueryParams();
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
// useEffect(() => {
// const checkVerticalGraph = (): void => {
// if (startTime) {
// const startTimeDate = dayjs(Number(startTime));
// const twentyFourHoursAgo = dayjs().subtract(
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
// DAYJS_MANIPULATE_TYPES.HOUR,
// );
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
// }
// };
// checkVerticalGraph();
// }, [startTime]);
const adaptedData = useMemo((): AlertRuleTimelineGraphResponse[] | null => {
if (!data?.data) {
return null;
}
return data.data.map((item) => ({
start: item.start,
end: item.end,
state: item.state as AlertRuleTimelineGraphResponse['state'],
}));
}, [data?.data]);
return (
<div className="timeline-graph">
<div className="timeline-graph__title">
<div className="timeline-graph" data-testid="timeline-graph">
<div className="timeline-graph__title" data-testid="timeline-graph-title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">
@@ -49,7 +41,7 @@ function GraphWrapper({
isLoading={isLoading}
isError={isError || !isValidRuleId || !ruleId}
isRefetching={isRefetching}
data={data?.payload?.data || null}
data={adaptedData}
>
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
</DataStateRenderer>

View File

@@ -1,11 +1,35 @@
.timeline-table {
border-top: 1px solid var(--l1-border);
border-radius: 6px;
overflow: hidden;
margin-top: 4px;
min-height: 600px;
&__filter {
padding: 12px 16px;
background: var(--l1-background);
border-bottom: 1px solid var(--l1-border);
}
&__filter-row {
display: flex;
align-items: center;
gap: 8px;
}
&__filter-search {
flex: 1;
}
&__filter--loading,
&__filter--loading-skeleton {
width: 100% !important;
}
.ant-table {
background: var(--l1-background);
&-placeholder {
background: var(--l1-background) !important;
}
&-cell {
padding: 12px 16px !important;
vertical-align: baseline;
@@ -23,6 +47,9 @@
&-tbody > tr > td {
border: none;
}
&-footer {
background-color: var(--l1-background);
}
}
.label-filter {
@@ -86,4 +113,38 @@
background: var(--l2-background);
}
}
&__error {
display: flex;
align-self: flex-start;
justify-content: flex-start;
text-align: left;
}
&__pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-6) var(--spacing-8);
background: var(--l1-background);
.pagination-controls {
display: flex;
gap: 4px;
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
min-width: 24px;
height: 24px;
&:disabled {
opacity: 0.4;
}
}
}
}
}

View File

@@ -1,52 +1,129 @@
import { HTMLAttributes, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Table } from 'antd';
import { HTMLAttributes, useCallback, useMemo } from 'react';
import { Button, Skeleton, Table } from 'antd';
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { initialFilters } from 'constants/queryBuilder';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import {
QuerySearchV2Provider,
useExpression,
useInputExpression,
useQuerySearchOnChange,
useQuerySearchOnRun,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import {
useGetAlertRuleDetailsTimelineTable,
useTimelineTable,
} from 'pages/AlertDetails/hooks';
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
import { useTimezone } from 'providers/Timezone';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useAlertHistoryFilterSuggestions } from './useAlertHistoryFilterSuggestions';
import { timelineTableColumns } from './useTimelineTable';
import './Table.styles.scss';
function TimelineTable(): JSX.Element {
const [filters, setFilters] = useState<TagFilter>(initialFilters);
export const ALERT_HISTORY_EXPRESSION_KEY = 'alertHistoryExpression';
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineTable({ filters });
function TimelineTableContent(): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const {
isLoading,
isRefetching,
isError,
data,
error,
ruleId,
refetch,
cancel,
} = useGetAlertRuleDetailsTimelineTable({ filterExpression: expression });
const apiError = useMemo(() => convertToApiError(error), [error]);
const { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys } =
useAlertHistoryFilterSuggestions(ruleId ?? null);
const { timelineData, totalItems, nextCursor } = useMemo(() => {
const response = data?.data;
const items: AlertRuleTimelineTableResponse[] | undefined =
response?.items?.map((item) => {
return {
ruleID: item.ruleId,
ruleName: item.ruleName,
overallState: item.overallState as string,
overallStateChanged: item.overallStateChanged,
state: item.state as string,
stateChanged: item.stateChanged,
unixMilli: item.unixMilli,
fingerprint: item.fingerprint,
value: item.value,
labels: labelsArrayToObject(item.labels),
relatedLogsLink: item.relatedLogsLink,
relatedTracesLink: item.relatedTracesLink,
};
});
const { timelineData, totalItems, labels } = useMemo(() => {
const response = data?.payload?.data;
return {
timelineData: response?.items,
totalItems: response?.total,
labels: response?.labels,
timelineData: items,
totalItems: response?.total ?? 0,
nextCursor: response?.nextCursor,
};
}, [data?.payload?.data]);
}, [data?.data]);
const { paginationConfig, onChangeHandler } = useTimelineTable({
const {
paginationConfig,
onChangeHandler,
handleNextPage,
handlePrevPage,
hasNextPage,
hasPrevPage,
} = useTimelineTable({
totalItems: totalItems ?? 0,
nextCursor,
});
const { t } = useTranslation('common');
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (isError || !isValidRuleId || !ruleId) {
return <div>{t('something_went_wrong')}</div>;
}
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const nextExpression = updatedExpression ?? inputExpression;
querySearchOnRun(nextExpression);
if (nextExpression === expression) {
refetch();
}
},
[querySearchOnRun, refetch, inputExpression, expression],
);
const queryData = useMemo(
() => ({
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
filter: { expression },
expression,
}),
[expression],
);
const handleRowClick = (
record: AlertRuleTimelineTableResponse,
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'timeline-row',
onClick: (): void => {
logEvent('Alert history: Timeline table row: Clicked', {
void logEvent('Alert history: Timeline table row: Clicked', {
ruleId: record.ruleID,
labels: record.labels,
});
@@ -54,24 +131,114 @@ function TimelineTable(): JSX.Element {
});
return (
<div className="timeline-table">
<div className="timeline-table" data-testid="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"
>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
hardcodedAttributeKeys={hardcodedAttributeKeys}
valueSuggestionsOverride={valueSuggestionsOverride}
/>
</div>
<RunQueryBtn
isLoadingQueries={isLoading || isRefetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
</div>
) : (
<div className="timeline-table__filter timeline-table__filter--loading">
<Skeleton.Input
className="timeline-table__filter--loading-skeleton"
active
data-testid="timeline-filter-skeleton"
/>
</div>
)}
<Table
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
columns={timelineTableColumns({
filters,
labels: labels ?? {},
setFilters,
formatTimezoneAdjustedTimestamp,
})}
onRow={handleRowClick}
dataSource={timelineData}
pagination={paginationConfig}
pagination={false}
size="middle"
onChange={onChangeHandler}
loading={isLoading || isRefetching}
locale={{
emptyText:
isError && apiError ? (
<div className="timeline-table__error" data-testid="timeline-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"
>
{paginationConfig.showTotal?.(totalItems, [
totalItems === 0
? 0
: ((paginationConfig.current ?? 1) - 1) *
(paginationConfig.pageSize ?? 10) +
1,
Math.min(
(paginationConfig.current ?? 1) * (paginationConfig.pageSize ?? 10),
totalItems,
),
])}
</div>
<div className="pagination-controls">
<Button
type="text"
size="small"
disabled={!hasPrevPage}
onClick={handlePrevPage}
data-testid="timeline-prev-page"
>
<ChevronLeft size={14} />
</Button>
<Button
type="text"
size="small"
disabled={!hasNextPage}
onClick={handleNextPage}
data-testid="timeline-next-page"
>
<ChevronRight size={14} />
</Button>
</div>
</div>
)}
/>
</div>
);
}
function TimelineTable(): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={ALERT_HISTORY_EXPRESSION_KEY}
initialExpression=""
persistOnUnmount
>
<TimelineTableContent />
</QuerySearchV2Provider>
);
}
export default TimelineTable;

View File

@@ -0,0 +1,100 @@
import { useCallback, useMemo } from 'react';
import {
getRuleHistoryFilterValues,
useGetRuleHistoryFilterKeys,
} from 'api/generated/services/rules';
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { fieldContextToSuggestionContext } from 'container/AlertHistory/Timeline/Table/utils';
export interface AlertHistoryFilterSuggestions {
hardcodedAttributeKeys: QueryKeyDataSuggestionsProps[];
valueSuggestionsOverride: (
key: string,
searchText: string,
) => Promise<{
stringValues: string[];
numberValues: number[];
complete: boolean;
}>;
isLoadingKeys: boolean;
}
export function useAlertHistoryFilterSuggestions(
ruleId: string | null,
): AlertHistoryFilterSuggestions {
const { startTime, endTime } = useAlertHistoryQueryParams();
const { data: filterKeysData, isLoading: isLoadingKeys } =
useGetRuleHistoryFilterKeys(
{ id: ruleId ?? '' },
{ startUnixMilli: startTime, endUnixMilli: endTime },
{
query: {
enabled: !!ruleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
const hardcodedAttributeKeys = useMemo((): QueryKeyDataSuggestionsProps[] => {
const keys = filterKeysData?.data?.keys;
if (!keys) {
// by default, when QuerySearch keys fails, we don't render fallback keys
// we just return empty to let user write whatever they want with no
// key suggestion
return [];
}
return Object.values(keys).flatMap((items) =>
items.map(
(item) =>
({
label: item.name,
name: item.name,
type: item.fieldDataType || 'string',
signal: 'logs' as const,
fieldDataType: item.fieldDataType,
fieldContext: fieldContextToSuggestionContext(item.fieldContext),
}) satisfies QueryKeyDataSuggestionsProps,
),
);
}, [filterKeysData]);
const valueSuggestionsOverride = useCallback(
async (
key: string,
searchText: string,
): Promise<{
stringValues: string[];
numberValues: number[];
complete: boolean;
}> => {
if (!ruleId) {
return {
stringValues: [],
numberValues: [],
complete: true,
};
}
const response = await getRuleHistoryFilterValues(
{ id: ruleId },
{
name: key,
searchText,
startUnixMilli: startTime,
endUnixMilli: endTime,
},
);
const values = response.data?.values;
return {
stringValues: values?.stringValues ?? [],
numberValues: values?.numberValues ?? [],
complete: response.data?.complete ?? false,
};
},
[ruleId, startTime, endTime],
);
return { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys };
}

View File

@@ -1,83 +1,15 @@
import { useMemo } from 'react';
import { Ellipsis, Search } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, TableColumnsType as ColumnsType } from 'antd';
import ClientSideQBSearch, {
AttributeKey,
} from 'components/ClientSideQBSearch/ClientSideQBSearch';
import { Ellipsis } from '@signozhq/icons';
import { Button, TableColumnsType as ColumnsType, Tooltip } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import AlertLabels, {
AlertLabelsProps,
} from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
const transformLabelsToQbKeys = (
labels: AlertRuleTimelineTableResponse['labels'],
): AttributeKey[] => Object.keys(labels).flatMap((key) => [{ key }]);
function LabelFilter({
filters,
setFilters,
labels,
}: {
setFilters: (filters: TagFilter) => void;
filters: TagFilter;
labels: AlertLabelsProps['labels'];
}): JSX.Element | null {
const isDarkMode = useIsDarkMode();
const { transformedKeys, attributesMap } = useMemo(
() => ({
transformedKeys: transformLabelsToQbKeys(labels || {}),
attributesMap: transformKeyValuesToAttributeValuesMap(labels),
}),
[labels],
);
const handleSearch = (tagFilters: TagFilter): void => {
const tagFiltersLength = tagFilters.items.length;
if (
(!tagFiltersLength && (!filters || !filters.items.length)) ||
tagFiltersLength === filters?.items.length
) {
return;
}
setFilters(tagFilters);
};
return (
<ClientSideQBSearch
onChange={handleSearch}
filters={filters}
className="alert-history-label-search"
attributeKeys={transformedKeys}
attributeValuesMap={attributesMap}
suffixIcon={
<Search
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
}
/>
);
}
export const timelineTableColumns = ({
filters,
labels,
setFilters,
formatTimezoneAdjustedTimestamp,
}: {
filters: TagFilter;
labels: AlertLabelsProps['labels'];
setFilters: (filters: TagFilter) => void;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
@@ -89,18 +21,16 @@ export const timelineTableColumns = ({
sorter: true,
width: 140,
render: (value): JSX.Element => (
<div className="alert-rule-state">
<div className="alert-rule-state" data-testid="timeline-row-state">
<AlertState state={value} showLabel />
</div>
),
},
{
title: (
<LabelFilter setFilters={setFilters} filters={filters} labels={labels} />
),
title: 'LABELS',
dataIndex: 'labels',
render: (labels): JSX.Element => (
<div className="alert-rule-labels">
<div className="alert-rule-labels" data-testid="timeline-row-labels">
<AlertLabels labels={labels} />
</div>
),
@@ -110,7 +40,10 @@ export const timelineTableColumns = ({
dataIndex: 'unixMilli',
width: 200,
render: (value): JSX.Element => (
<div className="alert-rule__created-at">
<div
className="alert-rule__created-at"
data-testid="timeline-row-created-at"
>
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
</div>
),
@@ -119,15 +52,27 @@ export const timelineTableColumns = ({
title: 'ACTIONS',
width: 140,
align: 'right',
render: (record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<Button type="text" ghost>
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</ConditionalAlertPopover>
),
render: (_, record): JSX.Element => {
if (!record.relatedTracesLink && !record.relatedLogsLink) {
return (
<Tooltip title="No links available for this item">
<Button type="text" ghost disabled data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</Tooltip>
);
}
return (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink ?? ''}
relatedLogsLink={record.relatedLogsLink ?? ''}
>
<Button type="text" ghost data-testid="timeline-row-actions">
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</ConditionalAlertPopover>
);
},
},
];

View File

@@ -0,0 +1,53 @@
import {
Options,
parseAsInteger,
parseAsStringLiteral,
useQueryState,
UseQueryStateReturn,
} from 'nuqs';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
const defaultNuqsOptions: Options = {
history: 'push',
};
export const TIMELINE_TABLE_PARAMS = {
PAGE: 'page',
ORDER: 'order',
} as const;
const ORDER_VALUES = ['asc', 'desc'] as const;
export type OrderDirection = (typeof ORDER_VALUES)[number];
export const useTimelineTablePage = (): UseQueryStateReturn<number, number> =>
useQueryState(
TIMELINE_TABLE_PARAMS.PAGE,
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
);
export const useTimelineTableOrder = (): UseQueryStateReturn<
OrderDirection,
OrderDirection
> =>
useQueryState(
TIMELINE_TABLE_PARAMS.ORDER,
parseAsStringLiteral(ORDER_VALUES)
.withDefault('asc')
.withOptions(defaultNuqsOptions),
);
export function encodeCursor(page: number, limit: number): string | undefined {
if (page <= 1) {
return undefined;
}
const offset = (page - 1) * limit;
// Backend uses base64.RawURLEncoding (URL-safe, no padding)
return btoa(JSON.stringify({ offset, limit }))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export function computeCursorForPage(page: number): string | undefined {
return encodeCursor(page, TIMELINE_TABLE_PAGE_SIZE);
}

View File

@@ -0,0 +1,26 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
const fieldContextToSuggestionMap: Record<
TelemetrytypesFieldContextDTO,
QueryKeyDataSuggestionsProps['fieldContext']
> = {
[TelemetrytypesFieldContextDTO.resource]: 'resource',
[TelemetrytypesFieldContextDTO.span]: 'span',
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,
[TelemetrytypesFieldContextDTO['']]: undefined,
};
export function fieldContextToSuggestionContext(
fc: TelemetrytypesFieldContextDTO | undefined,
): QueryKeyDataSuggestionsProps['fieldContext'] {
if (fc === undefined) {
return undefined;
}
return fieldContextToSuggestionMap[fc];
}

View File

@@ -23,6 +23,7 @@ function TimelineTabs(): JSX.Element {
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
testId: 'timeline-tab-overall-status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
@@ -33,6 +34,7 @@ function TimelineTabs(): JSX.Element {
</div>
),
disabled: true,
testId: 'timeline-tab-top-contributors',
},
];
@@ -57,14 +59,17 @@ 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

@@ -0,0 +1,19 @@
import type { Querybuildertypesv5LabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { Labels } from 'types/api/alerts/def';
export function labelsArrayToObject(
labels: Querybuildertypesv5LabelDTO[] | null | undefined,
): Labels {
if (!labels) {
return {};
}
return labels.reduce<Labels>((acc, label) => {
const key = label.key?.name ?? '';
const value = String(label.value ?? '');
if (key) {
acc[key] = value;
}
return acc;
}, {});
}

View File

@@ -34,6 +34,7 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
testId="send-notification-if-data-is-missing-input"
/>
<Typography.Text>Minutes</Typography.Text>
</div>
@@ -66,6 +67,7 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
testId="enforce-minimum-datapoints-input"
/>
<Typography.Text>Datapoints</Typography.Text>
</div>

View File

@@ -66,6 +66,7 @@ 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,6 +186,7 @@ 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} />
@@ -218,6 +219,7 @@ 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} />
@@ -249,6 +251,7 @@ function Footer(): JSX.Element {
color="secondary"
onClick={handleDiscard}
disabled={disableButtons}
testId="discard-alert-rule-button"
>
<X size={14} /> Discard
</Button>

View File

@@ -119,6 +119,7 @@ function BasicInfo({
<SeveritySelect
getPopupContainer={popupContainer}
defaultValue="critical"
data-testid="alert-severity-select"
onChange={(value: unknown | string): void => {
const s = (value as string) || 'critical';
setAlertDef({
@@ -147,6 +148,7 @@ function BasicInfo({
]}
>
<InputSmall
data-testid="alert-name-input-v1"
onChange={(e): void => {
setAlertDef({
...alertDef,
@@ -161,6 +163,7 @@ 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">
<Button className="nav-btns" data-testid="query-builder-tab">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
@@ -122,7 +122,11 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -162,7 +166,11 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -180,7 +188,11 @@ function QuerySection({
: 'PromQL'
}
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="promql-tab"
>
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>

View File

@@ -80,6 +80,7 @@ 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) || '';
@@ -116,6 +117,7 @@ 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>
@@ -177,6 +179,7 @@ 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>
@@ -194,6 +197,7 @@ 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>
@@ -395,6 +399,7 @@ 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,8 +844,6 @@ function FormAlertRules({
return (
<>
{Element}
<div
id="top"
className={`form-alert-rules-container ${
@@ -968,6 +966,7 @@ function FormAlertRules({
!isChannelConfigurationValid ||
queryStatus === 'error'
}
data-testid="alert-save-button"
>
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
@@ -981,6 +980,7 @@ function FormAlertRules({
}
type="default"
onClick={onTestRuleHandler}
data-testid="alert-test-button"
>
{' '}
{t('button_testrule')}
@@ -989,6 +989,7 @@ function FormAlertRules({
disabled={loading || false}
type="default"
onClick={onCancelHandler}
data-testid="alert-cancel-button"
>
{isNewRule && t('button_cancelchanges')}
{ruleId && !isEmpty(ruleId) && t('button_discard')}
@@ -998,6 +999,7 @@ function FormAlertRules({
</div>
<ConfirmDialog
testId="alert-save-confirm-dialog"
open={isConfirmSaveOpen}
onOpenChange={setIsConfirmSaveOpen}
title={t('confirm_save_title')}

View File

@@ -174,6 +174,7 @@ 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,6 +94,8 @@ 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,7 +117,11 @@ function AlertActionButtons({
<div className="alert-action-buttons">
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
<Switch
onChange={toggleAlertRule}
value={!isAlertRuleDisabled}
testId="alert-actions-toggle"
/>
)}
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
@@ -129,6 +133,7 @@ function AlertActionButtons({
<Tooltip title="More options">
<Button
type="text"
data-testid="alert-actions-menu"
icon={
<Ellipsis
size={16}

View File

@@ -47,21 +47,29 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={alertRuleState ?? state ?? ''} />
<div className="alert-title">
<div data-testid="alert-header-state">
<AlertState state={alertRuleState ?? state ?? ''} />
</div>
<div className="alert-title" data-testid="alert-header-title">
<LineClampedText text={displayName || ''} />
</div>
</div>
</div>
<div className="bottom-section">
{labels?.severity && <AlertSeverity severity={labels.severity} />}
{labels?.severity && (
<div data-testid="alert-header-severity">
<AlertSeverity severity={labels.severity} />
</div>
)}
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus
status="firing"
timestamp={dayjs().subtract(1, 'd').valueOf()}
/> */}
<AlertLabels labels={labelsWithoutSeverity} />
<div data-testid="alert-header-labels">
<AlertLabels labels={labelsWithoutSeverity} />
</div>
</div>
</div>
);

View File

@@ -1,11 +1,12 @@
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { BellOff, CircleCheck, CircleOff, Flame } from '@signozhq/icons';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import './AlertState.styles.scss';
type AlertStateProps = {
state: string;
state: RuletypesAlertStateDTO | string;
showLabel?: boolean;
};
@@ -17,7 +18,7 @@ export default function AlertState({
let label;
const isDarkMode = useIsDarkMode();
switch (state) {
case 'nodata':
case RuletypesAlertStateDTO.nodata:
icon = (
<CircleOff
size={18}
@@ -28,7 +29,7 @@ export default function AlertState({
label = <span style={{ color: Color.BG_SIENNA_400 }}>No Data</span>;
break;
case 'disabled':
case RuletypesAlertStateDTO.disabled:
icon = (
<BellOff
size={18}
@@ -38,15 +39,16 @@ export default function AlertState({
);
label = <span style={{ color: Color.BG_VANILLA_400 }}>Muted</span>;
break;
case 'firing':
case RuletypesAlertStateDTO.firing:
icon = (
<Flame size={18} fill={Color.BG_CHERRY_500} color={Color.BG_CHERRY_500} />
);
label = <span style={{ color: Color.BG_CHERRY_500 }}>Firing</span>;
break;
case 'normal':
case 'inactive':
case RuletypesAlertStateDTO.inactive:
case 'normal': // legacy
icon = (
<CircleCheck
size={18}

View File

@@ -1,28 +1,34 @@
import { useCallback, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { generatePath, useLocation } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from 'react-query';
import { generatePath } from 'react-router-dom';
import { TablePaginationConfig, TableProps } from 'antd';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import ruleStats from 'api/alerts/ruleStats';
import timelineGraph from 'api/alerts/timelineGraph';
import timelineTable from 'api/alerts/timelineTable';
import topContributors from 'api/alerts/topContributors';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
invalidateGetRuleByID,
invalidateListRules,
updateRuleByID,
useGetRuleByID,
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
useGetRuleHistoryTopContributors,
useListRules,
} from 'api/generated/services/rules';
import type {
GetRuleByID200,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
import {
Querybuildertypesv5OrderDirectionDTO,
RuletypesAlertStateDTO,
type GetRuleByID200,
type GetRuleHistoryOverallStatus200,
type GetRuleHistoryStats200,
type GetRuleHistoryTimeline200,
type GetRuleHistoryTopContributors200,
type RenderErrorResponseDTO,
type RuletypesPostableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { TabRoutes } from 'components/RouteTab/types';
@@ -31,35 +37,27 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import {
computeCursorForPage,
useTimelineTableOrder,
useTimelineTablePage,
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { urlKey } from 'container/AllError/utils';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import GetMinMax from 'lib/getMinMax';
import history from 'lib/history';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import { OrderPreferenceItems } from 'pages/Logs/config';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
import {
AlertDef,
AlertRuleStatsPayload,
AlertRuleTimelineGraphResponsePayload,
AlertRuleTimelineTableResponse,
AlertRuleTimelineTableResponsePayload,
AlertRuleTopContributorsPayload,
} from 'types/api/alerts/def';
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { nanoToMilli } from 'utils/timeUtils';
import { Typography } from '@signozhq/ui/typography';
export const useAlertHistoryQueryParams = (): {
ruleId: string | null;
@@ -129,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: EditRules,
name: (
<div className="tab-item">
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
@@ -140,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
{
Component: AlertHistory,
name: (
<div className="tab-item">
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />
@@ -201,10 +199,7 @@ type GetAlertRuleDetailsApiProps = {
};
type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleStatsPayload, unknown>
| ErrorResponse
| undefined;
data: GetRuleHistoryStats200 | undefined;
};
export const useGetAlertRuleDetailsStats =
@@ -213,18 +208,15 @@ export const useGetAlertRuleDetailsStats =
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime],
const { isLoading, isRefetching, isError, data } = useGetRuleHistoryStats(
{ id: ruleId || '' },
{ start: startTime, end: endTime },
{
queryFn: () =>
ruleStats({
id: ruleId || '',
start: startTime,
end: endTime,
}),
enabled: isValidRuleId && !!startTime && !!endTime,
refetchOnMount: false,
refetchOnWindowFocus: false,
query: {
enabled: isValidRuleId && !!startTime && !!endTime,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
@@ -232,10 +224,7 @@ export const useGetAlertRuleDetailsStats =
};
type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleTopContributorsPayload, unknown>
| ErrorResponse
| undefined;
data: GetRuleHistoryTopContributors200 | undefined;
};
export const useGetAlertRuleDetailsTopContributors =
@@ -244,90 +233,128 @@ export const useGetAlertRuleDetailsTopContributors =
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime],
{
queryFn: () =>
topContributors({
id: ruleId || '',
start: startTime,
end: endTime,
}),
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
const { isLoading, isRefetching, isError, data } =
useGetRuleHistoryTopContributors(
{ id: ruleId || '' },
{ start: startTime, end: endTime },
{
query: {
enabled: isValidRuleId && !!startTime && !!endTime,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};
type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleTimelineTableResponsePayload, unknown>
| ErrorResponse
| undefined;
data: GetRuleHistoryTimeline200 | undefined;
error: AxiosError<RenderErrorResponseDTO> | null;
refetch: () => void;
cancel: () => void;
};
export const useGetAlertRuleDetailsTimelineTable = ({
filters,
filterExpression,
}: {
filters: TagFilter;
filterExpression: string;
}): GetAlertRuleDetailsTimelineTableProps => {
const queryClient = useQueryClient();
const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams();
const { updatedOrder, offset } = useMemo(
() => ({
updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC,
offset: parseInt(params.get(urlKey.offset) ?? '0', 10),
}),
[params],
const [page, setPage] = useTimelineTablePage();
const [order] = useTimelineTableOrder();
const updatedOrder = useMemo(
() =>
order === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
[order],
);
const timelineFilter = params.get('timelineFilter');
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const hasStartAndEnd = startTime !== null && endTime !== null;
const { isLoading, isRefetching, isError, data } = useQuery(
[
REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE,
ruleId,
startTime,
endTime,
timelineFilter,
updatedOrder,
offset,
JSON.stringify(filters.items),
],
const stateFilter = useMemo(() => {
if (!timelineFilter || timelineFilter === TimelineFilter.ALL) {
return undefined;
}
return timelineFilter === TimelineFilter.FIRED
? RuletypesAlertStateDTO.firing
: RuletypesAlertStateDTO.inactive;
}, [timelineFilter]);
const filtersKey = `${filterExpression}|${stateFilter ?? ''}|${startTime}|${endTime}`;
const prevFiltersKeyRef = useRef(filtersKey);
const filtersChanged = prevFiltersKeyRef.current !== filtersKey;
const cursor = computeCursorForPage(filtersChanged ? 1 : page);
useEffect(() => {
if (prevFiltersKeyRef.current !== filtersKey) {
prevFiltersKeyRef.current = filtersKey;
if (page > 1) {
void setPage(1);
}
}
}, [filtersKey, page, setPage]);
const queryParams = useMemo(
() => ({
start: startTime,
end: endTime,
limit: TIMELINE_TABLE_PAGE_SIZE,
order: updatedOrder,
cursor,
filterExpression: filterExpression || undefined,
state: stateFilter,
}),
[startTime, endTime, updatedOrder, cursor, filterExpression, stateFilter],
);
const queryOptions = getGetRuleHistoryTimelineQueryOptions(
{ id: ruleId || '' },
queryParams,
{
queryFn: () =>
timelineTable({
id: ruleId || '',
start: startTime,
end: endTime,
limit: TIMELINE_TABLE_PAGE_SIZE,
order: updatedOrder,
offset,
filters,
...(timelineFilter && timelineFilter !== TimelineFilter.ALL
? {
state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal',
}
: {}),
}),
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
query: {
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
const { isLoading, isRefetching, isError, data, error, refetch } =
useQuery(queryOptions);
const queryKeyRef = useRef(queryOptions.queryKey);
queryKeyRef.current = queryOptions.queryKey;
const cancel = useCallback(() => {
void queryClient.cancelQueries({ queryKey: queryKeyRef.current });
}, [queryClient]);
return {
isLoading,
isRefetching,
isError,
data,
error: error as AxiosError<RenderErrorResponseDTO> | null,
isValidRuleId,
ruleId,
refetch,
cancel,
};
};
export const useTimelineTable = ({
totalItems,
nextCursor,
}: {
totalItems: number;
nextCursor?: string;
}): {
paginationConfig: TablePaginationConfig;
onChangeHandler: (
@@ -336,16 +363,13 @@ export const useTimelineTable = ({
filters: any,
extra: any,
) => void;
handleNextPage: () => void;
handlePrevPage: () => void;
hasNextPage: boolean;
hasPrevPage: boolean;
} => {
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const { search } = useLocation();
const params = useMemo(() => new URLSearchParams(search), [search]);
const offset = params.get('offset') ?? '0';
const [page, setPage] = useTimelineTablePage();
const [, setOrder] = useTimelineTableOrder();
const onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'] =
useCallback(
@@ -357,38 +381,52 @@ export const useTimelineTable = ({
| SorterResult<AlertRuleTimelineTableResponse>,
) => {
if (!Array.isArray(sorter)) {
const { pageSize = 0, current = 0 } = pagination;
const { order } = sorter;
const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
const params = new URLSearchParams(window.location.search);
safeNavigate(
`${pathname}?${createQueryParams({
...Object.fromEntries(params),
order: updatedOrder,
offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE,
pageSize,
})}`,
);
void Promise.all([setOrder(updatedOrder), setPage(1)]);
}
},
[pathname, safeNavigate],
[setOrder, setPage],
);
const offsetInt = parseInt(offset, 10);
const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE);
const pageSizeInt = parseInt(pageSize, 10);
const handleNextPage = useCallback(() => {
if (!nextCursor) {
return;
}
void setPage(page + 1);
}, [nextCursor, page, setPage]);
const handlePrevPage = useCallback(() => {
if (page <= 1) {
return;
}
void setPage(page - 1);
}, [page, setPage]);
const paginationConfig: TablePaginationConfig = {
pageSize: pageSizeInt,
showTotal: PaginationInfoText,
current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1,
pageSize: TIMELINE_TABLE_PAGE_SIZE,
showTotal: (total, [start, end]) => (
<span>
<Typography.Text size="small">
{start} &#8212; {end}
</Typography.Text>
<Typography.Text size="small"> of {total}</Typography.Text>
</span>
),
current: page,
showSizeChanger: false,
hideOnSinglePage: true,
total: totalItems,
};
return { paginationConfig, onChangeHandler };
return {
paginationConfig,
onChangeHandler,
handleNextPage,
handlePrevPage,
hasNextPage: !!nextCursor,
hasPrevPage: page > 1,
};
};
export const useAlertRuleStatusToggle = ({
@@ -581,10 +619,7 @@ export const useAlertRuleDelete = ({
};
type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleTimelineGraphResponsePayload, unknown>
| ErrorResponse
| undefined;
data: GetRuleHistoryOverallStatus200 | undefined;
};
export const useGetAlertRuleDetailsTimelineGraphData =
@@ -594,20 +629,18 @@ export const useGetAlertRuleDetailsTimelineGraphData =
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const hasStartAndEnd = startTime !== null && endTime !== null;
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime],
{
queryFn: () =>
timelineGraph({
id: ruleId || '',
start: startTime,
end: endTime,
}),
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
const { isLoading, isRefetching, isError, data } =
useGetRuleHistoryOverallStatus(
{ id: ruleId || '' },
{ start: startTime, end: endTime },
{
query: {
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};

View File

@@ -13,6 +13,8 @@ interface Tab {
disabled?: boolean;
icon?: string | JSX.Element;
isBeta?: boolean;
/** Optional `data-testid` for the tab button. */
testId?: string;
}
interface TimelineTabsProps {
@@ -63,6 +65,7 @@ function Tabs2({
disabled={tab.disabled}
icon={tab.icon}
style={{ minWidth: buttonMinWidth }}
data-testid={tab.testId}
>
{tab.label}

View File

@@ -1,4 +1,4 @@
import { AlertLabelsProps } from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
// default match type for threshold
@@ -73,10 +73,6 @@ export interface StatsTimeSeriesItem {
value: string;
}
export type AlertRuleStatsPayload = {
data: AlertRuleStats;
};
export interface AlertRuleTopContributors {
fingerprint: number;
labels: Labels;
@@ -84,9 +80,6 @@ export interface AlertRuleTopContributors {
relatedLogsLink: string;
relatedTracesLink: string;
}
export type AlertRuleTopContributorsPayload = {
data: AlertRuleTopContributors[];
};
export interface AlertRuleTimelineTableResponse {
ruleID: string;
@@ -99,24 +92,12 @@ export interface AlertRuleTimelineTableResponse {
labels: Labels;
fingerprint: number;
value: number;
relatedTracesLink: string;
relatedLogsLink: string;
relatedLogsLink?: string;
relatedTracesLink?: string;
}
export type AlertRuleTimelineTableResponsePayload = {
data: {
items: AlertRuleTimelineTableResponse[];
total: number;
labels: AlertLabelsProps['labels'];
};
};
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
export interface AlertRuleTimelineGraphResponse {
start: number;
end: number;
state: AlertState;
state: RuletypesAlertStateDTO;
}
export type AlertRuleTimelineGraphResponsePayload = {
data: AlertRuleTimelineGraphResponse[];
};

View File

@@ -1,7 +0,0 @@
import { AlertDef } from './def';
export interface RuleStatsProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -1,7 +0,0 @@
import { AlertDef } from './def';
export interface GetTimelineGraphRequestProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -1,13 +0,0 @@
import { TagFilter } from '../queryBuilder/queryBuilderData';
import { AlertDef } from './def';
export interface GetTimelineTableRequestProps {
id: AlertDef['id'];
start: number;
end: number;
offset: number;
limit: number;
order: string;
filters?: TagFilter;
state?: string;
}

View File

@@ -1,7 +0,0 @@
import { AlertDef } from './def';
export interface TopContributorsProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -485,9 +485,11 @@ func (s *store) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter
}
fieldKeys, _, err := s.telemetryMetadataStore.GetKeysMulti(ctx, orgID, selectors)
if err != nil || len(fieldKeys) == 0 {
if err != nil || fieldKeys == nil {
fieldKeys = map[string][]*telemetrytypes.TelemetryFieldKey{}
for _, sel := range selectors {
}
for _, sel := range selectors {
if len(fieldKeys[sel.Name]) == 0 {
fieldKeys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
Name: sel.Name,
Signal: sel.Signal,

View File

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

@@ -0,0 +1,214 @@
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,81 +1,11 @@
import {
test as base,
expect,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
import { test as base, expect, type Page } from '@playwright/test';
export type User = { email: string; password: string };
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
// 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()}`,
);
}
}
// 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 };
export const test = base.extend<{
/**
@@ -95,7 +25,7 @@ export const test = base.extend<{
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {
const storageState = await storageFor(browser, user);
const storageState = await storageStateFor(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

@@ -0,0 +1,732 @@
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,34 +1,123 @@
import type { Browser, BrowserContext } from '@playwright/test';
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!,
};
/**
* 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.
* `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.
*
* 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.
* 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.
*/
export async function newAdminContext(
browser: Browser,
): Promise<BrowserContext> {
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;
return browser.newContext({
...contextDefaults,
storageState: await storageStateFor(browser, ADMIN),
});
}

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
@@ -18,6 +18,108 @@ 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,7 +5,12 @@
"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,15 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import fs from 'fs';
import os from 'os';
import path from 'path';
// .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 });
// 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;
}
}
}
export default defineConfig({
testDir: './tests',
@@ -33,8 +55,17 @@ export default defineConfig({
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Workers
workers: process.env.CI ? 2 : undefined,
// 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))),
// The SPA hydrates slowly on CI, so the 5s expect default fires mid-load.
expect: { timeout: 15_000 },

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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