Compare commits

...

15 Commits

Author SHA1 Message Date
Ashu Ghildiyal
ce15fe69d5 test(tanstack-table): cover last column fill hand-off through the table 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
31d33bc79f fix(notification-channels): scroll channel forms inside configuration and pin actions to the bottom 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
bcd2d0c888 fix(alerts): fix table search navigation and silenced rules display
- Preserve table sort and pagination params when typing in alerts search (Bug 54)
- Cancel pending search execution on unmount to prevent navigation rollback (Bug 55)
- Branch on alertIds length, show loading skeleton, and fallback to Unknown rule (Bug 56)
2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
8b80aa1a10 fix(components): handle empty items and isolate +N badge max-width in OverflowBadges 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
928ade763a feat(planned-downtime): show timezone and duration columns by default 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
d7919cd0b7 test(storybook): overflow channels and silenced rules in alert stories 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
4504f15055 fix(components): fit OverflowBadges to the cell width
Badges are measured once per item set, so the hidden measuring row never duplicates labels in
the DOM. The truncation width goes through --badge-max-width because the global override
outranks Badge's maxWidth prop.
2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
daf926c806 feat(routing-policies): show channels as the second column 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
c9b9901b75 fix(alerts): resize routing policy and planned downtime columns from the right edge
The column before Actions now fills instead of the name column, so widening a
column pushes its right edge rather than shrinking the name column.
2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
96e5242efa feat(tanstack-table): let a trailing column pass the fill to the previous one 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
8fb97aad1b fix(alerts): collapse overflowing channel and alert badges into a +N chip 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
6bd5a930c7 feat(components): add OverflowBadges with +N hover chip 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
883adf0566 fix(alerts): drop native title on badge cells that already show a tooltip 2026-11-03 09:48:48 +05:30
Ashu Ghildiyal
f30e5a6900 fix(alerts): title-case create buttons for routing policies and planned downtime 2026-11-03 09:48:47 +05:30
Ashu Ghildiyal
4c36c4aeb1 fix(alerts): title routing policies and planned downtime as All lists 2026-11-03 09:48:47 +05:30
35 changed files with 808 additions and 131 deletions

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
interface ItemBadgeProps {
children: ReactNode;
testId?: string;
}
function ItemBadge({ children, testId }: ItemBadgeProps): JSX.Element {
return (
<Badge textTransform="none" variant="solid" color="secondary" testId={testId}>
{children}
</Badge>
);
}
export default ItemBadge;

View File

@@ -0,0 +1,32 @@
.badges {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.overflow {
flex-shrink: 0;
--badge-max-width: none;
}
/* Lays every badge out at its natural width, off-screen, to size the visible row. */
.measure {
position: absolute;
top: 0;
left: 0;
display: flex;
width: max-content;
visibility: hidden;
pointer-events: none;
}
.extraBadges {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
max-width: 320px;
}

View File

@@ -0,0 +1,75 @@
import { type CSSProperties, useRef } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import ItemBadge from './ItemBadge';
import type { OverflowBadgeItem } from './types';
import { useOverflowBadgesLayout } from './useOverflowBadgesLayout';
import styles from './OverflowBadges.module.scss';
interface OverflowBadgesProps {
items: OverflowBadgeItem[];
}
/**
* A row of badges: as many as fit the width inline, the rest revealed on hover
* of a "+N" badge. A lone badge too wide to fit truncates instead.
*/
function OverflowBadges({ items }: OverflowBadgesProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const { visibleCount, firstMaxWidth, isMeasuring } = useOverflowBadgesLayout(
items,
containerRef,
measureRef,
);
const visible = items.slice(0, visibleCount);
const extra = items.slice(visibleCount);
// The app sets `--badge-max-width` globally, which outranks Badge's `maxWidth` prop.
const containerStyle =
firstMaxWidth === undefined
? undefined
: ({ '--badge-max-width': `${firstMaxWidth}px` } as CSSProperties);
return (
<div ref={containerRef} className={styles.badges} style={containerStyle}>
{visible.map(({ key, label }) => (
<ItemBadge key={key} testId="overflow-badges-item">
{label}
</ItemBadge>
))}
{extra.length > 0 && (
<Tooltip
side="bottom"
title={
<div className={styles.extraBadges}>
{extra.map(({ key, label }) => (
<ItemBadge key={key}>{label}</ItemBadge>
))}
</div>
}
>
{/* Stop the click so the "+N" badge doesn't trigger the row's click. */}
<span
role="presentation"
className={styles.overflow}
onClick={(e): void => e.stopPropagation()}
>
<ItemBadge testId="overflow-badges-more">+{extra.length}</ItemBadge>
</span>
</Tooltip>
)}
{isMeasuring && (
<div ref={measureRef} className={styles.measure} aria-hidden>
{items.map(({ key, label }) => (
<ItemBadge key={key}>{label}</ItemBadge>
))}
<ItemBadge>+{items.length}</ItemBadge>
</div>
)}
</div>
);
}
export default OverflowBadges;

View File

@@ -0,0 +1,74 @@
import { render, screen } from 'tests/test-utils';
import OverflowBadges from '../OverflowBadges';
import { computeOverflowBadgesLayout } from '../utils';
const items = ['alpha', 'beta', 'gamma', 'delta'].map((label) => ({
key: label,
label,
}));
describe('computeOverflowBadgesLayout', () => {
it('fits as many badges as the width allows, keeping room for +N', () => {
// 50 + 4 + 50 + 4 + 30 (+N) = 138 fits; a third badge would not.
expect(
computeOverflowBadgesLayout(140, 4, [50, 50, 50, 50], 30),
).toStrictEqual({ visibleCount: 2 });
});
it('shows every badge without +N when all fit', () => {
expect(computeOverflowBadgesLayout(200, 4, [50, 50, 50], 30)).toStrictEqual({
visibleCount: 3,
});
});
it('truncates the first badge when even it does not fit', () => {
expect(computeOverflowBadgesLayout(100, 4, [300, 50], 30)).toStrictEqual({
visibleCount: 1,
firstMaxWidth: 66,
});
});
it('gives a lone too-wide badge the full width', () => {
expect(computeOverflowBadgesLayout(100, 4, [300], 30)).toStrictEqual({
visibleCount: 1,
firstMaxWidth: 100,
});
});
it('returns visibleCount 0 when badgeWidths is empty', () => {
expect(computeOverflowBadgesLayout(100, 4, [], 30)).toStrictEqual({
visibleCount: 0,
});
});
});
describe('OverflowBadges', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('collapses the badges that do not fit into +N', () => {
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(160);
jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 50 } as DOMRect);
render(<OverflowBadges items={items} />);
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(2);
expect(screen.getByTestId('overflow-badges-more')).toHaveTextContent('+2');
});
it('omits +N when every badge fits', () => {
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 50 } as DOMRect);
render(<OverflowBadges items={items} />);
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(4);
expect(screen.queryByTestId('overflow-badges-more')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
import type { Key, ReactNode } from 'react';
export interface OverflowBadgeItem {
key: Key;
label: ReactNode;
}
export interface OverflowBadgesLayout {
visibleCount: number;
/** Set when even the first badge does not fit and must truncate. */
firstMaxWidth?: number;
}
export interface OverflowBadgesMeasurement {
/** The items these widths were measured for. */
itemsKey: string;
badgeWidths: number[];
overflowWidth: number;
gap: number;
}

View File

@@ -0,0 +1,71 @@
import { type RefObject, useLayoutEffect, useMemo, useState } from 'react';
import type {
OverflowBadgeItem,
OverflowBadgesLayout,
OverflowBadgesMeasurement,
} from './types';
import { computeOverflowBadgesLayout } from './utils';
/**
* Fits the badges to the container's width, re-fitting on resize. Badge widths
* are measured once per set of items, from a measuring row holding every badge
* at its natural width followed by "+N", rendered only while `isMeasuring`.
*/
export function useOverflowBadgesLayout(
items: OverflowBadgeItem[],
containerRef: RefObject<HTMLElement>,
measureRef: RefObject<HTMLElement>,
): OverflowBadgesLayout & { isMeasuring: boolean } {
const itemsKey = items.map((item) => String(item.key)).join('\u0000');
const [measurement, setMeasurement] =
useState<OverflowBadgesMeasurement | null>(null);
const [available, setAvailable] = useState(0);
const isMeasuring = items.length > 0 && measurement?.itemsKey !== itemsKey;
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!isMeasuring || !container || !measure) {
return;
}
// Rounded up so sub-pixel widths never add up past the container.
const badgeWidths = Array.from(measure.children, (el) =>
Math.ceil(el.getBoundingClientRect().width),
);
const overflowWidth = badgeWidths.pop() ?? 0;
setMeasurement({
itemsKey,
badgeWidths,
overflowWidth,
gap: parseFloat(getComputedStyle(container).columnGap) || 0,
});
}, [isMeasuring, itemsKey, containerRef, measureRef]);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const update = (): void => setAvailable(container.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return (): void => observer.disconnect();
}, [containerRef]);
const layout = useMemo(
(): OverflowBadgesLayout =>
isMeasuring || !measurement || available === 0
? { visibleCount: items.length }
: computeOverflowBadgesLayout(
available,
measurement.gap,
measurement.badgeWidths,
measurement.overflowWidth,
),
[isMeasuring, measurement, available, items.length],
);
return { ...layout, isMeasuring };
}

View File

@@ -0,0 +1,33 @@
import type { OverflowBadgesLayout } from './types';
/**
* How many badges fit `available` px, keeping room for the "+N" badge while any
* are left out. Always shows at least one, capped to truncate when too wide.
*/
export const computeOverflowBadgesLayout = (
available: number,
gap: number,
badgeWidths: number[],
overflowWidth: number,
): OverflowBadgesLayout => {
if (badgeWidths.length === 0) {
return { visibleCount: 0 };
}
let used = 0;
let visibleCount = 0;
for (let i = 0; i < badgeWidths.length; i++) {
const next = used + (i > 0 ? gap : 0) + badgeWidths[i];
const reserve = i < badgeWidths.length - 1 ? gap + overflowWidth : 0;
if (next + reserve > available) {
break;
}
used = next;
visibleCount++;
}
if (visibleCount > 0) {
return { visibleCount };
}
const reserve = badgeWidths.length > 1 ? gap + overflowWidth : 0;
return { visibleCount: 1, firstMaxWidth: Math.max(available - reserve, 0) };
};

View File

@@ -31,7 +31,7 @@ type TanStackHeaderRowProps<TData = unknown> = {
onRemoveColumn?: (columnId: string) => void;
orderBy?: SortState | null;
onSort?: (sort: SortState | null) => void;
/** Last column cannot be resized */
/** The fill column and any after it cannot be resized */
isLastColumn?: boolean;
};

View File

@@ -53,7 +53,11 @@ import { useFlatItems } from './useFlatItems';
import { useResetScroll } from './useResetScroll';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
import {
buildPageSizeItems,
buildTanstackColumnDef,
getFillColumnIndex,
} from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
@@ -428,6 +432,9 @@ function TanStackTableInner<TData, TItemKey = string>(
);
const tableHeader = useCallback(() => {
const fillColumnIndex = getFillColumnIndex(
flatHeaders.map((header) => columnsById.get(header.id)),
);
return (
<DndContext
sensors={sensors}
@@ -453,7 +460,7 @@ function TanStackTableInner<TData, TItemKey = string>(
canRemoveColumn={canRemoveColumn}
orderBy={orderBy}
onSort={setOrderBy}
isLastColumn={index === flatHeaders.length - 1}
isLastColumn={index >= fillColumnIndex}
/>
);
})}

View File

@@ -1,7 +1,7 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
import { getColumnWidthStyle, getFillColumnIndex } from './utils';
export function VirtuosoTableColGroup<TData>({
columns,
@@ -13,6 +13,9 @@ export function VirtuosoTableColGroup<TData>({
const visibleTanstackColumns = table.getVisibleFlatColumns();
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
const columnSizing = table.getState().columnSizing;
const fillColumnIndex = getFillColumnIndex(
visibleTanstackColumns.map((c) => columnDefsById.get(c.id)),
);
return (
<colgroup>
@@ -22,7 +25,7 @@ export function VirtuosoTableColGroup<TData>({
return <col key={tanstackCol.id} />;
}
const persistedWidth = columnSizing[tanstackCol.id];
const isLastColumn = index === visibleTanstackColumns.length - 1;
const isLastColumn = index === fillColumnIndex;
return (
<col
key={tanstackCol.id}

View File

@@ -0,0 +1,51 @@
import { screen } from '@testing-library/react';
import type { TableColumnDef } from '../types';
import { defaultColumns, renderTanStackTable, type TestRow } from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
const actionsColumn: TableColumnDef<TestRow> = {
id: 'actions',
header: 'Actions',
cell: (): string => 'act',
width: { fixed: '96px', passLastColumnFill: true },
};
const colWidths = (container: HTMLElement): string[] =>
Array.from(container.querySelectorAll('colgroup col')).map(
(col) => (col as HTMLElement).style.width,
);
const isResizable = (headerText: string): boolean =>
Boolean(
screen
.getByText(headerText)
.closest('th')
?.querySelector('[title="Drag to resize column"]'),
);
describe('TanStackTable column fill', () => {
it('fills and locks the last column by default', () => {
const { container } = renderTanStackTable();
expect(colWidths(container).at(-1)).toBe('100%');
expect(isResizable('Name')).toBe(true);
expect(isResizable('Value')).toBe(false);
});
it('hands the fill to the previous column when the last passes it on', () => {
const { container } = renderTanStackTable({
props: { columns: [...defaultColumns, actionsColumn] },
});
const widths = colWidths(container);
expect(widths.at(-2)).toBe('100%');
expect(widths.at(-1)).toBe('96px');
expect(isResizable('Name')).toBe(true);
expect(isResizable('Value')).toBe(false);
expect(isResizable('Actions')).toBe(false);
});
});

View File

@@ -0,0 +1,28 @@
import type { TableColumnDef } from '../types';
import { getFillColumnIndex } from '../utils';
const column = (
id: string,
width?: TableColumnDef<unknown>['width'],
): TableColumnDef<unknown> => ({
id,
header: id,
cell: (): null => null,
width,
});
describe('getFillColumnIndex', () => {
it('fills the last column by default', () => {
expect(getFillColumnIndex([column('a'), column('b')])).toBe(1);
});
it('fills the previous column when the last passes it on', () => {
expect(
getFillColumnIndex([
column('a'),
column('b'),
column('actions', { fixed: '96px', passLastColumnFill: true }),
]),
).toBe(1);
});
});

View File

@@ -74,6 +74,8 @@ export type TableColumnDef<
default?: number | string;
max?: number | string;
ignoreLastColumnFill?: boolean;
/** When last, hand the fill to the previous column instead of taking it. */
passLastColumnFill?: boolean;
};
};

View File

@@ -63,6 +63,19 @@ export const getColumnWidthStyle = <TData>(
};
};
/**
* Index of the column that fills spare width: the last one, or the one before
* it when the last sets `width.passLastColumnFill`.
*/
export const getFillColumnIndex = <TData>(
columns: (TableColumnDef<TData> | undefined)[],
): number => {
const lastIndex = columns.length - 1;
return columns[lastIndex]?.width?.passLastColumnFill
? lastIndex - 1
: lastIndex;
};
const isSkeletonRow = (row: unknown): boolean => {
const r = row as Record<string, unknown>;
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');

View File

@@ -121,13 +121,6 @@
font-size: 13px;
}
.badges {
display: flex;
gap: var(--spacing-2);
overflow: hidden;
white-space: nowrap;
}
.timeText {
display: inline-block;
white-space: nowrap;

View File

@@ -5,6 +5,7 @@ import React, {
useMemo,
useState,
} from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
@@ -53,6 +54,7 @@ export function PlannedDowntime(): JSX.Element {
data: alertsData,
isError,
isLoading,
isFetching: isFetchingAlerts,
} = useListRules({
query: { cacheTime: 0 },
});
@@ -60,6 +62,7 @@ export function PlannedDowntime(): JSX.Element {
const [form] = Form.useForm();
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const location = useAppLocation();
const urlQuery = useUrlQuery();
const [initialValues, setInitialValues] =
@@ -83,20 +86,31 @@ export function PlannedDowntime(): JSX.Element {
}
}, [form, isOpen]);
const [searchValue, setSearchValue] = React.useState<string>(
urlQuery.get('search') || '',
);
const [searchValue, setSearchValue] = React.useState<string>(() => {
const searchFromUrlQuery = urlQuery.get('search');
if (searchFromUrlQuery) {
return searchFromUrlQuery;
}
const search =
(typeof window !== 'undefined' && window.location.search) ||
location.search ||
'';
return new URLSearchParams(search).get('search') || '';
});
const [deleteData, setDeleteData] = useState<{ id: string; name: string }>();
const [isEditMode, setEditMode] = useState<boolean>(false);
const updateUrlWithSearch = useDebouncedFn((value) => {
const searchValue = value as string;
if (searchValue) {
urlQuery.set('search', searchValue);
const nextSearch = value as string;
const search =
(typeof window !== 'undefined' && window.location.search) || location.search;
const params = new URLSearchParams(search);
if (nextSearch) {
params.set('search', nextSearch);
} else {
urlQuery.delete('search');
params.delete('search');
}
const url = `/alerts?${urlQuery.toString()}`;
const url = `/alerts?${params.toString()}`;
navigate(url, { replace: true });
}, 300);
@@ -127,9 +141,11 @@ export function PlannedDowntime(): JSX.Element {
const canEdit = user?.role !== USER_ROLES.VIEWER;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const isAlertsLoading = isLoading || (!alertsData && isFetchingAlerts);
const columns = React.useMemo(
() => getPlannedDowntimeColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
() =>
getPlannedDowntimeColumns(formatTimezoneAdjustedTimestamp, isAlertsLoading),
[formatTimezoneAdjustedTimestamp, isAlertsLoading],
);
const handleCreate = useCallback((): void => {
@@ -204,7 +220,7 @@ export function PlannedDowntime(): JSX.Element {
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
Planned Downtime
All planned downtimes
</Typography.Title>
<span className={styles.countPill}>{totalDowntimes}</span>
</div>
@@ -226,7 +242,7 @@ export function PlannedDowntime(): JSX.Element {
disabledTooltip="You need edit permissions to create a planned downtime"
testId="planned-downtime-create"
>
New downtime
New Downtime
</Button>
<TextToolTip
text="More details on how to schedule planned downtime"

View File

@@ -39,7 +39,7 @@ function PlannedDowntimeList({
<span className={styles.actionsHeader}>Actions</span>
),
accessorKey: 'id',
width: { fixed: '96px', ignoreLastColumnFill: true },
width: { fixed: '96px', passLastColumnFill: true },
enableSort: false,
enableResize: false,
enableRemove: false,

View File

@@ -10,8 +10,7 @@ import {
AlertmanagertypesRepeatTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { mockLocation } from 'container/RoutingPolicies/__tests__/testUtils';
import { mockAppLocation } from 'tests/router-mocks';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { render, userEvent } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
@@ -102,8 +101,6 @@ const mockDowntimeQueryResult: Partial<DowntimeQueryResult> = {
let mockUrlQuery: URLSearchParams;
mockAppLocation({ pathname: '/alerts' });
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
@@ -127,29 +124,44 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
isLoading: false,
}),
}));
let mockRulesState: {
data?: { data: Array<{ id: number | string; alert: string }> };
isError: boolean;
isLoading: boolean;
isFetching: boolean;
} = {
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
};
jest.mock('api/generated/services/rules', () => ({
useListRules: (): {
data: { data: [] };
isError: false;
isLoading: false;
} => ({
data: { data: [] },
isError: false,
isLoading: false,
}),
useListRules: (): typeof mockRulesState => mockRulesState,
}));
describe('PlannedDowntime Component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlQuery = new URLSearchParams();
mockLocation('/alerts');
mockAppLocation({
pathname: '/alerts',
search: '',
});
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
mockRulesState = {
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
};
});
it('renders the header, search and create action', () => {
renderPage();
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
expect(screen.getByText('All planned downtimes')).toBeInTheDocument();
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
'aria-disabled',
@@ -193,14 +205,14 @@ describe('PlannedDowntime Component', () => {
expect(screen.getByText(MOCK_DOWNTIME_3_NAME)).toBeInTheDocument();
});
it('shows the key schedule details and hides secondary columns by default', async () => {
it('shows the key schedule details, timezone and duration, and hides secondary columns by default', async () => {
renderPage();
await screen.findByText(MOCK_DOWNTIME_1_NAME);
expect(screen.getByText(/\(Asia\/Kolkata\)$/)).toBeInTheDocument();
expect(screen.getByText('Weekly on Mon · 2h')).toBeInTheDocument();
expect(screen.queryByText('Asia/Kolkata')).not.toBeInTheDocument();
expect(screen.queryByText('2h')).not.toBeInTheDocument();
expect(screen.getAllByText('Asia/Kolkata').length).toBeGreaterThan(0);
expect(screen.getAllByText('2h').length).toBeGreaterThan(0);
expect(screen.queryByText('Database maintenance')).not.toBeInTheDocument();
expect(screen.queryByText('creator@signoz.io')).not.toBeInTheDocument();
expect(screen.queryByText('editor@signoz.io')).not.toBeInTheDocument();
@@ -208,7 +220,7 @@ describe('PlannedDowntime Component', () => {
expect(screen.getAllByText('Active')).toHaveLength(3);
});
it('lets every data column be resized', async () => {
it('lets every data column but the last, which fills, be resized', async () => {
renderPage();
await screen.findByText(MOCK_DOWNTIME_1_NAME);
@@ -219,8 +231,9 @@ describe('PlannedDowntime Component', () => {
'Downtime',
'Status',
'Schedule',
'Timezone',
'Repeats',
'Alerts Silenced',
'Duration',
]);
});
@@ -290,4 +303,116 @@ describe('PlannedDowntime Component', () => {
await screen.findByText(MOCK_DOWNTIME_1_NAME);
expect(screen.queryByTestId('downtime-actions-1')).not.toBeInTheDocument();
});
it('preserves existing query parameters such as sort and pagination when typing in search (Bug 54)', async () => {
jest.useFakeTimers();
mockAppLocation({
pathname: '/alerts',
search:
'?tab=Configuration&subTab=planned-downtime&orderBy=name&order=asc&page=2&limit=20',
});
const navigateSpy = mockNavigate();
renderPage();
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
target: { value: 'query' },
});
jest.advanceTimersByTime(350);
expect(navigateSpy).toHaveBeenCalled();
const callArg = navigateSpy.mock.calls[0][0];
expect(callArg).toContain('tab=Configuration');
expect(callArg).toContain('subTab=planned-downtime');
expect(callArg).toContain('orderBy=name');
expect(callArg).toContain('order=asc');
expect(callArg).toContain('page=2');
expect(callArg).toContain('limit=20');
expect(callArg).toContain('search=query');
jest.useRealTimers();
});
it('shows skeleton while alert rules are loading for scoped downtime (Bug 56)', async () => {
mockRulesState = {
data: undefined,
isError: false,
isLoading: true,
isFetching: true,
};
const scopedDowntime = createMockDowntime({
id: 'scoped-1',
name: 'Scoped Downtime Loading',
schedule: buildSchedule({ startTime: MOCK_DATE_1 }),
alertIds: ['rule-1'],
});
mockDowntimeQueryResult.data = {
data: [scopedDowntime],
status: 'success',
};
renderPage();
await screen.findByText('Scoped Downtime Loading');
expect(screen.getByTestId('alerts-silenced-skeleton')).toBeInTheDocument();
expect(screen.queryByText('All alert rules')).not.toBeInTheDocument();
});
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
mockRulesState = {
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
};
const scopedDowntime = createMockDowntime({
id: 'scoped-2',
name: 'Scoped Downtime Deleted Rule',
schedule: buildSchedule({ startTime: MOCK_DATE_1 }),
alertIds: ['deleted-rule-id'],
});
mockDowntimeQueryResult.data = {
data: [scopedDowntime],
status: 'success',
};
renderPage();
await screen.findByText('Scoped Downtime Deleted Rule');
expect(screen.getByText('Unknown rule')).toBeInTheDocument();
expect(screen.queryByText('All alert rules')).not.toBeInTheDocument();
});
it('shows resolved alert rule name when scoped rule ID exists', async () => {
mockRulesState = {
data: {
data: [
{
id: 'rule-42',
alert: 'Disk Space High',
} as any,
],
},
isError: false,
isLoading: false,
isFetching: false,
};
const scopedDowntime = createMockDowntime({
id: 'scoped-3',
name: 'Scoped Downtime Resolved',
schedule: buildSchedule({ startTime: MOCK_DATE_1 }),
alertIds: ['rule-42'],
});
mockDowntimeQueryResult.data = {
data: [scopedDowntime],
status: 'success',
};
renderPage();
await screen.findByText('Scoped Downtime Resolved');
expect(screen.getByText('Disk Space High')).toBeInTheDocument();
expect(screen.queryByText('All alert rules')).not.toBeInTheDocument();
});
});

View File

@@ -1,6 +1,8 @@
import { Badge } from '@signozhq/ui/badge';
import { Skeleton } from '@signozhq/ui/skeleton';
import { AlertmanagertypesMaintenanceStatusDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnDef } from 'components/TanStackTableView';
import OverflowBadges from 'components/OverflowBadges/OverflowBadges';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relativeTime';
@@ -33,6 +35,7 @@ const STATUS_BADGE: Record<
export function getPlannedDowntimeColumns(
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
isAlertsLoading?: boolean,
): TableColumnDef<DowntimeSchedulesTableData>[] {
const textCell = (value: string | undefined): JSX.Element => (
<TanStackTable.Text title={value || undefined}>
@@ -66,7 +69,7 @@ export function getPlannedDowntimeColumns(
id: 'name',
header: 'Downtime',
accessorKey: 'name',
width: { default: '100%', min: '200px' },
width: { default: '240px', min: '200px' },
enableSort: true,
enableResize: true,
enableRemove: false,
@@ -114,7 +117,6 @@ export function getPlannedDowntimeColumns(
header: 'Timezone',
accessorFn: (row) => row.schedule?.timezone,
width: { default: '130px', min: '110px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
enableMove: false,
@@ -135,7 +137,6 @@ export function getPlannedDowntimeColumns(
header: 'Duration',
accessorFn: (row) => row.schedule?.recurrence?.duration,
width: { default: '110px', min: '90px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
enableMove: false,
@@ -150,32 +151,36 @@ export function getPlannedDowntimeColumns(
enableSort: false,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element =>
row.alertOptions.length ? (
<div
className={styles.badges}
title={row.alertOptions
.flatMap((alert) =>
typeof alert.label === 'string' ? [alert.label] : [],
)
.join(', ')}
>
{row.alertOptions.map((alert) => (
<Badge
key={alert.value}
textTransform="none"
variant="solid"
color="secondary"
>
{alert.label}
</Badge>
))}
</div>
) : (
<Badge textTransform="none" variant="solid" color="secondary">
All alert rules
</Badge>
),
cell: ({ row }): JSX.Element => {
const hasScopedAlerts = Boolean(row.alertIds && row.alertIds.length > 0);
if (!hasScopedAlerts) {
return (
<Badge textTransform="none" variant="solid" color="secondary">
All alert rules
</Badge>
);
}
if (isAlertsLoading) {
return (
<Skeleton.Input
testId="alerts-silenced-skeleton"
active
size="small"
style={{ width: 100, height: 22 }}
/>
);
}
return (
<OverflowBadges
items={row.alertOptions.map((alert) => ({
key: String(alert.value),
label: alert.label,
}))}
/>
);
},
},
{
id: 'description',

View File

@@ -5,7 +5,6 @@ import type { SortState } from 'components/TanStackTableView/types';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import { getAlertOptionsFromIds } from './PlannedDowntimeutils';
import { DowntimeAlertOption, DowntimeSchedulesTableData } from './types';
export const PLANNED_DOWNTIME_QUERY_PARAMS = {
@@ -72,16 +71,38 @@ function usePlannedDowntimeTable(
const filteredDowntimes = useMemo(() => {
const search = searchValue.toLowerCase();
const alertOptionsMap = new Map<string, DowntimeAlertOption>();
alertOptions.forEach((option) => {
if (option?.value != null) {
alertOptionsMap.set(String(option.value), option);
}
});
return downtimes
.filter(
(downtime) =>
downtime.name.toLowerCase().includes(search) ||
downtime.id === searchValue,
)
.map((downtime) => ({
...downtime,
alertOptions: getAlertOptionsFromIds(downtime.alertIds || [], alertOptions),
}));
.map((downtime) => {
const resolvedAlertOptions: DowntimeAlertOption[] = (
downtime.alertIds || []
).map((id) => {
const found = alertOptionsMap.get(String(id));
if (found) {
return found;
}
return {
label: 'Unknown rule',
value: id,
};
});
return {
...downtime,
alertOptions: resolvedAlertOptions,
};
});
}, [downtimes, alertOptions, searchValue]);
const pagedDowntimes = useMemo(() => {

View File

@@ -121,13 +121,6 @@
font-size: 13px;
}
.badges {
display: flex;
gap: var(--spacing-2);
overflow: hidden;
white-space: nowrap;
}
.timeText {
display: inline-block;
white-space: nowrap;

View File

@@ -102,7 +102,7 @@ function RoutingPolicies(): JSX.Element {
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
Routing Policies
All routing policies
</Typography.Title>
<span className={styles.countPill}>{routingPoliciesData.length}</span>
</div>
@@ -124,7 +124,7 @@ function RoutingPolicies(): JSX.Element {
disabledTooltip="You need edit permissions to create a routing policy"
testId="routing-policies-create"
>
New routing policy
New Routing Policy
</Button>
<TextToolTip
text="More details on how to configure routing policies"

View File

@@ -38,7 +38,7 @@ function RoutingPolicyList({
<span className={styles.actionsHeader}>Actions</span>
),
accessorKey: 'id',
width: { fixed: '96px', ignoreLastColumnFill: true },
width: { fixed: '96px', passLastColumnFill: true },
enableSort: false,
enableResize: false,
enableRemove: false,

View File

@@ -50,7 +50,7 @@ describe('RoutingPolicies', () => {
it('should render components properly', () => {
render(<RoutingPolicies />);
expect(screen.getByText('Routing Policies')).toBeInTheDocument();
expect(screen.getByText('All routing policies')).toBeInTheDocument();
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(CREATE_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId('routing-policy-list')).toBeInTheDocument();

View File

@@ -64,14 +64,14 @@ describe('RoutingPolicyList', () => {
).not.toBeInTheDocument();
});
it('lets every data column be resized', async () => {
it('lets every data column but the last, which fills, be resized', async () => {
renderList();
await screen.findByText(MOCK_ROUTING_POLICY_1.name);
const resizable = Array.from(document.querySelectorAll('th'))
.filter((th) => th.querySelector('[title="Drag to resize column"]'))
.map((th) => th.textContent?.trim());
expect(resizable).toStrictEqual(['Routing Policy', 'Expression', 'Channels']);
expect(resizable).toStrictEqual(['Routing Policy', 'Channels']);
});
it('opens the policy on row click', async () => {

View File

@@ -202,6 +202,29 @@ describe('useRoutingPolicies', () => {
expect(callArg).toContain('search=test+search');
});
it('should preserve existing query parameters such as sort and pagination when updating search (Bug 54)', async () => {
const { result } = renderHookWithWrapper(
'/alerts?tab=Configuration&subTab=routing-policies&orderBy=name&order=asc&page=2&limit=20',
);
act(() => {
result.current.setSearchTerm('policy');
});
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalled();
});
const callArg = mockNavigate.mock.calls[0][0];
expect(callArg).toContain('tab=Configuration');
expect(callArg).toContain('subTab=routing-policies');
expect(callArg).toContain('orderBy=name');
expect(callArg).toContain('order=asc');
expect(callArg).toContain('page=2');
expect(callArg).toContain('limit=20');
expect(callArg).toContain('search=policy');
});
it('should remove search param from URL when search is cleared', async () => {
const { result } = renderHookWithWrapper('/alerts?search=existing');

View File

@@ -1,5 +1,5 @@
import { Badge } from '@signozhq/ui/badge';
import type { TableColumnDef } from 'components/TanStackTableView';
import OverflowBadges from 'components/OverflowBadges/OverflowBadges';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relativeTime';
@@ -42,7 +42,7 @@ export function getRoutingPolicyColumns(
id: 'name',
header: 'Routing Policy',
accessorKey: 'name',
width: { default: '100%', min: '220px' },
width: { default: '320px', min: '220px' },
enableSort: true,
enableResize: true,
enableRemove: false,
@@ -56,6 +56,26 @@ export function getRoutingPolicyColumns(
</TanStackTable.Text>
),
},
{
id: 'channels',
header: 'Channels',
accessorKey: 'channels',
width: { default: '220px', min: '140px' },
enableSort: false,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element =>
row.channels?.length ? (
<OverflowBadges
items={row.channels.map((channel) => ({
key: channel,
label: channel,
}))}
/>
) : (
<TanStackTable.Text>-</TanStackTable.Text>
),
},
{
id: 'expression',
header: 'Expression',
@@ -70,32 +90,6 @@ export function getRoutingPolicyColumns(
</TanStackTable.Text>
),
},
{
id: 'channels',
header: 'Channels',
accessorKey: 'channels',
width: { default: '220px', min: '140px' },
enableSort: false,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element =>
row.channels?.length ? (
<div className={styles.badges} title={row.channels.join(', ')}>
{row.channels.map((channel) => (
<Badge
key={channel}
textTransform="none"
variant="solid"
color="secondary"
>
{channel}
</Badge>
))}
</div>
) : (
<TanStackTable.Text>-</TanStackTable.Text>
),
},
{
id: 'createdBy',
header: 'Created By',

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { toast } from '@signozhq/ui/sonner';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
@@ -30,9 +31,20 @@ import './styles.scss';
function useRoutingPolicies(): UseRoutingPoliciesReturn {
const queryClient = useQueryClient();
const urlQuery = useUrlQuery();
const location = useAppLocation();
// Local state
const [searchTerm, setSearchTerm] = useState(urlQuery.get('search') || '');
const [searchTerm, setSearchTerm] = useState(() => {
const searchFromUrlQuery = urlQuery.get('search');
if (searchFromUrlQuery) {
return searchFromUrlQuery;
}
const search =
(typeof window !== 'undefined' && window.location.search) ||
location.search ||
'';
return new URLSearchParams(search).get('search') || '';
});
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [policyDetailsModalState, setPolicyDetailsModalState] =
useState<PolicyDetailsModalState>({
@@ -44,12 +56,15 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
const updateUrlWithSearch = useDebouncedFn((value) => {
const searchValue = value as string;
const search =
(typeof window !== 'undefined' && window.location.search) || location.search;
const params = new URLSearchParams(search);
if (searchValue) {
urlQuery.set('search', searchValue);
params.set('search', searchValue);
} else {
urlQuery.delete('search');
params.delete('search');
}
const url = `/alerts?${urlQuery.toString()}`;
const url = `/alerts?${params.toString()}`;
navigate(url, { replace: true });
}, 300);

View File

@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react';
import useDebouncedFn from '../useDebouncedFunction';
describe('useDebouncedFn', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should debounce function execution', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebouncedFn(callback, 300));
result.current('first');
result.current('second');
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(299);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('second');
});
it('should cancel pending execution when unmounted (Bug 55)', () => {
const callback = jest.fn();
const { result, unmount } = renderHook(() => useDebouncedFn(callback, 300));
result.current('pending');
expect(callback).not.toHaveBeenCalled();
unmount();
jest.advanceTimersByTime(500);
expect(callback).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import debounce from 'lodash-es/debounce';
export interface DebouncedFunc<T extends (...args: unknown[]) => unknown> {
@@ -28,10 +28,19 @@ const useDebouncedFn = <T extends (...args: Array<unknown>) => unknown>(
const fnRef = useRef(fn);
fnRef.current = fn;
return useMemo(
const debounced = useMemo(
() => debounce(((...args) => fnRef.current(...args)) as T, wait, options),
[options, wait],
);
useEffect(
() => (): void => {
debounced.cancel();
},
[debounced],
);
return debounced;
};
export default useDebouncedFn;

View File

@@ -111,7 +111,7 @@ export const NewDowntimeRecurring: Story = {
await screen.findByRole('combobox', { name: 'Repeats every' }),
);
await userEvent.click(await screen.findByText('Weekly'));
await screen.findByText('Duration');
await within(await screen.findByRole('dialog')).findByText('Duration');
},
};

View File

@@ -47,7 +47,15 @@ const SEEDS: DowntimeSeed[] = [
timezone: 'UTC',
startsInMs: -2 * HOUR,
lastsMs: 6 * HOUR,
alertIds: ['rule-5', 'rule-3'],
alertIds: [
'rule-5',
'rule-3',
'rule-1',
'rule-2',
'rule-4',
'rule-6',
'rule-7',
],
},
{
name: 'Nightly ETL window',

View File

@@ -26,7 +26,7 @@ const SEEDS: PolicySeed[] = [
name: 'Critical production to on-call',
description: 'Anything critical in prod pages whoever is on call.',
expression: 'severity = "critical" AND env = "prod"',
channels: [1, 0],
channels: [1, 0, 3, 4, 2, 5],
},
{
name: 'Payments team ownership',

View File

@@ -3,5 +3,8 @@
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--spacing-4) var(--spacing-6) 0;
}

View File

@@ -3,5 +3,8 @@
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--spacing-4) var(--spacing-6) 0;
}