mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-03 15:00:53 +00:00
Compare commits
16 Commits
fix/ai-ass
...
issue_577
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405e8b346b | ||
|
|
afbe1ac7a6 | ||
|
|
2103d6c7b7 | ||
|
|
efb2100e69 | ||
|
|
ce44f1a9cf | ||
|
|
4d3b84b10e | ||
|
|
c0a8c2e09a | ||
|
|
285edf0442 | ||
|
|
43cee83b85 | ||
|
|
7fbf97429c | ||
|
|
0c6ac740a6 | ||
|
|
411717b72b | ||
|
|
3deb3351bb | ||
|
|
ddc8a5ecb7 | ||
|
|
55d892c075 | ||
|
|
89df02dac7 |
7
.github/workflows/e2eci.yaml
vendored
7
.github/workflows/e2eci.yaml
vendored
@@ -2,6 +2,9 @@ name: e2eci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -13,8 +16,12 @@ jobs:
|
||||
outputs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
steps:
|
||||
- name: merge-queue-pass
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "::notice::Merge queue run. E2E already gated the PR, so the required e2e check auto-passes."
|
||||
- name: changes
|
||||
id: changes
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
17
frontend/src/components/OverflowBadges/ItemBadge.tsx
Normal file
17
frontend/src/components/OverflowBadges/ItemBadge.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
75
frontend/src/components/OverflowBadges/OverflowBadges.tsx
Normal file
75
frontend/src/components/OverflowBadges/OverflowBadges.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
20
frontend/src/components/OverflowBadges/types.ts
Normal file
20
frontend/src/components/OverflowBadges/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
33
frontend/src/components/OverflowBadges/utils.ts
Normal file
33
frontend/src/components/OverflowBadges/utils.ts
Normal 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) };
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
@@ -31,7 +32,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;
|
||||
};
|
||||
|
||||
@@ -89,6 +90,19 @@ function TanStackHeaderRow<TData>({
|
||||
): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// TanStack starts the drag from the configured size, which an auto or
|
||||
// redistributed column does not render at.
|
||||
const renderedWidth = Math.round(
|
||||
event.currentTarget.closest('th')?.getBoundingClientRect().width ?? 0,
|
||||
);
|
||||
if (header && renderedWidth > 0 && renderedWidth !== header.getSize()) {
|
||||
flushSync(() => {
|
||||
header.getContext().table.setColumnSizing((sizing) => ({
|
||||
...sizing,
|
||||
[header.column.id]: renderedWidth,
|
||||
}));
|
||||
});
|
||||
}
|
||||
resizeHandler?.(event);
|
||||
};
|
||||
const {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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-');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
@@ -219,7 +231,9 @@ describe('PlannedDowntime Component', () => {
|
||||
'Downtime',
|
||||
'Status',
|
||||
'Schedule',
|
||||
'Timezone',
|
||||
'Repeats',
|
||||
'Duration',
|
||||
'Alerts Silenced',
|
||||
]);
|
||||
});
|
||||
@@ -290,4 +304,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -31,8 +33,11 @@ const STATUS_BADGE: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const COLUMN_WIDTH = { default: 'auto', min: '120px' };
|
||||
|
||||
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 +71,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'name',
|
||||
header: 'Downtime',
|
||||
accessorKey: 'name',
|
||||
width: { default: '100%', min: '200px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableRemove: false,
|
||||
@@ -84,7 +89,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
width: { default: '110px', min: '100px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -103,7 +108,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'window',
|
||||
header: 'Schedule',
|
||||
accessorFn: (row) => row.schedule?.startTime,
|
||||
width: { default: '340px', min: '220px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -113,8 +118,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'timezone',
|
||||
header: 'Timezone',
|
||||
accessorFn: (row) => row.schedule?.timezone,
|
||||
width: { default: '130px', min: '110px' },
|
||||
defaultVisibility: false,
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -124,7 +128,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'repeats',
|
||||
header: 'Repeats',
|
||||
accessorFn: (row) => row.schedule?.recurrence?.repeatType,
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -134,8 +138,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
accessorFn: (row) => row.schedule?.recurrence?.duration,
|
||||
width: { default: '110px', min: '90px' },
|
||||
defaultVisibility: false,
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -146,42 +149,46 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'alertsSilenced',
|
||||
header: 'Alerts Silenced',
|
||||
accessorKey: 'alertIds',
|
||||
width: { default: '200px', min: '140px' },
|
||||
width: COLUMN_WIDTH,
|
||||
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',
|
||||
header: 'Description',
|
||||
accessorKey: 'description',
|
||||
width: { default: '200px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -192,7 +199,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'createdBy',
|
||||
header: 'Created By',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -203,7 +210,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
@@ -214,7 +221,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -225,7 +232,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('RoutingPolicyList', () => {
|
||||
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', 'Expression']);
|
||||
});
|
||||
|
||||
it('opens the policy on row click', async () => {
|
||||
|
||||
@@ -202,6 +202,39 @@ 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',
|
||||
);
|
||||
// nuqs writes sort and pagination to the URL before the router location catches up
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/alerts?tab=Configuration&subTab=routing-policies&orderBy=name&order=asc&page=2&limit=20',
|
||||
);
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
result.current.setSearchTerm('policy');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
window.history.replaceState(null, '', '/');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -7,6 +7,8 @@ import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relat
|
||||
import styles from './RoutingPolicies.module.scss';
|
||||
import { RoutingPolicy } from './types';
|
||||
|
||||
const COLUMN_WIDTH = { default: 'auto', min: '120px' };
|
||||
|
||||
export function getRoutingPolicyColumns(
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
): TableColumnDef<RoutingPolicy>[] {
|
||||
@@ -42,7 +44,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'name',
|
||||
header: 'Routing Policy',
|
||||
accessorKey: 'name',
|
||||
width: { default: '100%', min: '220px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableRemove: false,
|
||||
@@ -56,11 +58,31 @@ export function getRoutingPolicyColumns(
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channels',
|
||||
header: 'Channels',
|
||||
accessorKey: 'channels',
|
||||
width: COLUMN_WIDTH,
|
||||
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',
|
||||
accessorKey: 'expression',
|
||||
width: { default: '260px', min: '160px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -70,37 +92,11 @@ 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',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -111,7 +107,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
@@ -122,7 +118,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -133,7 +129,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
43
frontend/src/hooks/__tests__/useDebouncedFunction.test.ts
Normal file
43
frontend/src/hooks/__tests__/useDebouncedFunction.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
@@ -19,9 +22,15 @@ const TAB_ITEMS = [
|
||||
];
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const { id: traceId = '' } = useAppParams<'id'>();
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.DetailsTabChanged, {
|
||||
[TraceDetailEventKeys.From]: tab,
|
||||
[TraceDetailEventKeys.To]: value,
|
||||
});
|
||||
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
|
||||
void setTab(value as TraceDetailsTab, { history: 'push' });
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
import { useTraceSummary } from '../useTraceSummary';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
jest.mock('../useTraceSummary', () => ({
|
||||
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
|
||||
}));
|
||||
@@ -416,4 +421,23 @@ describe('TraceDetailsHeader – tabs', () => {
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the tab change with the previous and next tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
jest.mocked(logEvent).mockClear();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-overview'));
|
||||
expect(logEvent).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(logEvent).toHaveBeenCalledWith(TraceDetailEvents.DetailsTabChanged, {
|
||||
view: 'v3',
|
||||
traceId: 'trace-123',
|
||||
from: TraceDetailsTab.Overview,
|
||||
to: TraceDetailsTab.Thread,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,9 @@ import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchPa
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useSpanDetailsOpenedEvent } from '../hooks/useSpanDetailsOpenedEvent';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
@@ -106,6 +108,12 @@ function TraceDetailsOverview({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
useSpanDetailsOpenedEvent({
|
||||
isOpen: panelState.isOpen,
|
||||
spanId: selectedSpan?.span_id,
|
||||
detailsTab: TraceDetailsTab.Overview,
|
||||
logTraceEvent,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
@@ -199,6 +207,7 @@ function TraceDetailsOverview({
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Overview,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { back, hasInAppHistory } from 'lib/router/navigation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useSpanDetailsOpenedEvent } from '../hooks/useSpanDetailsOpenedEvent';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { useThreadDataLoadedEvent } from './hooks/useThreadDataLoadedEvent';
|
||||
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
|
||||
import { useThreadView } from './hooks/useThreadView';
|
||||
import { useTraceThread } from './hooks/useTraceThread';
|
||||
@@ -23,7 +27,14 @@ import styles from './TraceDetailsThread.module.scss';
|
||||
|
||||
const INITIAL_LOADER_COUNT = 4;
|
||||
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
interface TraceDetailsThreadProps {
|
||||
// Owned by the page so the load event fires once per trace, not per tab visit.
|
||||
dataLoadedFiredForRef: MutableRefObject<string>;
|
||||
}
|
||||
|
||||
function TraceDetailsThread({
|
||||
dataLoadedFiredForRef,
|
||||
}: TraceDetailsThreadProps): JSX.Element {
|
||||
const { id: traceId } = useAppParams<'id'>();
|
||||
const [, setTab] = useTraceDetailsTab();
|
||||
const [view, setView] = useThreadView();
|
||||
@@ -62,6 +73,21 @@ function TraceDetailsThread(): JSX.Element {
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
|
||||
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
useThreadDataLoadedEvent({
|
||||
traceId: traceId || '',
|
||||
hasSpans: spans.length > 0,
|
||||
spanPanelVariant: spanDetailVariant,
|
||||
firedForRef: dataLoadedFiredForRef,
|
||||
logTraceEvent,
|
||||
});
|
||||
useSpanDetailsOpenedEvent({
|
||||
isOpen: panelState.isOpen,
|
||||
spanId: selectedSpan?.span_id,
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
logTraceEvent,
|
||||
});
|
||||
|
||||
const spanDetails = panelState.isOpen && selectedSpan && (
|
||||
<ThreadSpanDetails
|
||||
panelState={panelState}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { MutableRefObject, ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import {
|
||||
GetTraceSummary200,
|
||||
SpantypesGettableTraceSummaryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../../constants';
|
||||
import { TraceDetailEvents } from '../../../events';
|
||||
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
|
||||
import { useThreadDataLoadedEvent } from '../useThreadDataLoadedEvent';
|
||||
|
||||
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
|
||||
'trace-a': {
|
||||
startTimestampMillis: 1_000,
|
||||
endTimestampMillis: 4_000,
|
||||
totalSpansCount: 12,
|
||||
totalErrorSpansCount: 2,
|
||||
hasMissingSpans: false,
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
rootServiceName: 'agent',
|
||||
rootSpanStatusCode: 'STATUS_CODE_ERROR',
|
||||
},
|
||||
'trace-b': {
|
||||
startTimestampMillis: 5_000,
|
||||
endTimestampMillis: 5_500,
|
||||
totalSpansCount: 3,
|
||||
totalErrorSpansCount: 0,
|
||||
hasMissingSpans: false,
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
rootServiceName: 'agent',
|
||||
rootSpanStatusCode: 'STATUS_CODE_OK',
|
||||
},
|
||||
};
|
||||
|
||||
interface HookProps {
|
||||
traceId: string;
|
||||
hasSpans: boolean;
|
||||
spanPanelVariant?: SpanDetailVariant;
|
||||
}
|
||||
|
||||
function renderDataLoadedEvent(
|
||||
initialProps: HookProps,
|
||||
firedForRef: MutableRefObject<string> = { current: '' },
|
||||
): {
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
|
||||
rerender: (props: HookProps) => void;
|
||||
queryClient: QueryClient;
|
||||
} {
|
||||
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = renderHook(
|
||||
({ traceId, hasSpans, spanPanelVariant }: HookProps) =>
|
||||
useThreadDataLoadedEvent({
|
||||
traceId,
|
||||
hasSpans,
|
||||
spanPanelVariant: spanPanelVariant ?? SpanDetailVariant.DOCKED_RIGHT,
|
||||
firedForRef,
|
||||
logTraceEvent,
|
||||
}),
|
||||
{ initialProps, wrapper },
|
||||
);
|
||||
return { logTraceEvent, rerender, queryClient };
|
||||
}
|
||||
|
||||
describe('useThreadDataLoadedEvent', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
|
||||
const body: GetTraceSummary200 = {
|
||||
status: 'success',
|
||||
data: SUMMARIES[req.params.traceId as string],
|
||||
};
|
||||
return res(ctx.status(200), ctx.json(body));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the trace summary, then reports it as a thread load', async () => {
|
||||
const { logTraceEvent } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
});
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
totalSpansCount: 12,
|
||||
traceDurationMs: 3_000,
|
||||
hadErrors: true,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for the thread spans even when the summary is already loaded', async () => {
|
||||
const { logTraceEvent, rerender, queryClient } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: false,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryData(
|
||||
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
|
||||
),
|
||||
).toBeDefined(),
|
||||
);
|
||||
rerender({ traceId: 'trace-a', hasSpans: false });
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ traceId: 'trace-a', hasSpans: true });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a trace once, including when the tab is visited again', async () => {
|
||||
const firedForRef = { current: '' };
|
||||
const firstVisit = renderDataLoadedEvent(
|
||||
{ traceId: 'trace-a', hasSpans: true },
|
||||
firedForRef,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
firstVisit.rerender({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED,
|
||||
});
|
||||
|
||||
const secondVisit = renderDataLoadedEvent(
|
||||
{ traceId: 'trace-a', hasSpans: true },
|
||||
firedForRef,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
secondVisit.queryClient.getQueryData(
|
||||
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
|
||||
),
|
||||
).toBeDefined(),
|
||||
);
|
||||
secondVisit.rerender({ traceId: 'trace-a', hasSpans: true });
|
||||
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the next trace with its own summary', async () => {
|
||||
const { logTraceEvent, rerender } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
});
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ traceId: 'trace-b', hasSpans: true });
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
|
||||
expect(logTraceEvent).toHaveBeenLastCalledWith(
|
||||
TraceDetailEvents.DataLoaded,
|
||||
expect.objectContaining({ totalSpansCount: 3, traceDurationMs: 500 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../../events';
|
||||
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
|
||||
|
||||
interface ThreadDataLoadedEventArgs {
|
||||
traceId: string;
|
||||
hasSpans: boolean;
|
||||
spanPanelVariant: SpanDetailVariant;
|
||||
firedForRef: MutableRefObject<string>;
|
||||
logTraceEvent: TraceDetailLogEvent;
|
||||
}
|
||||
|
||||
export function useThreadDataLoadedEvent({
|
||||
traceId,
|
||||
hasSpans,
|
||||
spanPanelVariant,
|
||||
firedForRef,
|
||||
logTraceEvent,
|
||||
}: ThreadDataLoadedEventArgs): void {
|
||||
// No keepPreviousData: a previous trace's summary must not be reported for this one.
|
||||
const { data } = useGetTraceSummary(
|
||||
{ traceID: traceId },
|
||||
{ query: { enabled: !!traceId, refetchOnMount: false } },
|
||||
);
|
||||
const summary = data?.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!traceId || firedForRef.current === traceId || !hasSpans || !summary) {
|
||||
return;
|
||||
}
|
||||
firedForRef.current = traceId;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
summary.endTimestampMillis - summary.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
|
||||
});
|
||||
}, [traceId, hasSpans, summary, spanPanelVariant, firedForRef, logTraceEvent]);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export enum TraceDetailEvents {
|
||||
SpanPercentileTimeRangeChanged = 'Trace Detail: Span percentile time range changed',
|
||||
SpanPercentileAttributesSelectorToggled = 'Trace Detail: Span percentile attributes selector toggled',
|
||||
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
|
||||
SpanDetailsOpened = 'Trace Detail: Span details opened',
|
||||
DetailsTabChanged = 'Trace Detail: Details tab changed',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
@@ -28,7 +30,7 @@ export enum TraceDetailEventKeys {
|
||||
SpanPanelVariant = 'spanPanelVariant',
|
||||
ColorByField = 'colorByField',
|
||||
PreviewFieldsCount = 'previewFieldsCount',
|
||||
// View switched
|
||||
// View switched / details tab changed
|
||||
From = 'from',
|
||||
To = 'to',
|
||||
DwellMs = 'dwellMs',
|
||||
@@ -44,6 +46,8 @@ export enum TraceDetailEventKeys {
|
||||
PercentileValue = 'percentileValue',
|
||||
ResourceAttributeKey = 'resourceAttributeKey',
|
||||
Selected = 'selected',
|
||||
// Data loaded / span details opened: which tab the user is on
|
||||
DetailsTab = 'detailsTab',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import { useSpanDetailsOpenedEvent } from '../useSpanDetailsOpenedEvent';
|
||||
import { TraceDetailLogEvent } from '../useTraceDetailLogEvent';
|
||||
|
||||
interface PanelProps {
|
||||
isOpen: boolean;
|
||||
spanId?: string;
|
||||
}
|
||||
|
||||
function renderOpenedEvent(initialProps: PanelProps): {
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
|
||||
rerender: (props: PanelProps) => void;
|
||||
} {
|
||||
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
|
||||
const { rerender } = renderHook(
|
||||
({ isOpen, spanId }: PanelProps) =>
|
||||
useSpanDetailsOpenedEvent({
|
||||
isOpen,
|
||||
spanId,
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
logTraceEvent,
|
||||
}),
|
||||
{ initialProps },
|
||||
);
|
||||
return { logTraceEvent, rerender };
|
||||
}
|
||||
|
||||
function openedSpanIds(
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>,
|
||||
): unknown[] {
|
||||
return logTraceEvent.mock.calls.map(([, attributes]) => attributes?.spanId);
|
||||
}
|
||||
|
||||
describe('useSpanDetailsOpenedEvent', () => {
|
||||
it('waits for the panel to be open on a span, then reports it with the tab', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({ isOpen: false });
|
||||
rerender({ isOpen: true });
|
||||
rerender({ isOpen: false, spanId: 'span-1' });
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.SpanDetailsOpened,
|
||||
{ spanId: 'span-1', detailsTab: TraceDetailsTab.Thread },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not repeat while the same span stays open', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({
|
||||
isOpen: true,
|
||||
spanId: 'span-1',
|
||||
});
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the same span again after the panel is closed and reopened', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({
|
||||
isOpen: true,
|
||||
spanId: 'span-1',
|
||||
});
|
||||
rerender({ isOpen: false });
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
|
||||
expect(openedSpanIds(logTraceEvent)).toStrictEqual(['span-1', 'span-1']);
|
||||
});
|
||||
|
||||
it('reports each span selected while the panel stays open', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({
|
||||
isOpen: true,
|
||||
spanId: 'span-1',
|
||||
});
|
||||
rerender({ isOpen: true, spanId: 'span-2' });
|
||||
rerender({ isOpen: true, spanId: 'span-3' });
|
||||
|
||||
expect(openedSpanIds(logTraceEvent)).toStrictEqual([
|
||||
'span-1',
|
||||
'span-2',
|
||||
'span-3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { TraceDetailLogEvent } from './useTraceDetailLogEvent';
|
||||
|
||||
interface SpanDetailsOpenedEventArgs {
|
||||
isOpen: boolean;
|
||||
spanId: string | undefined;
|
||||
detailsTab: TraceDetailsTab;
|
||||
logTraceEvent: TraceDetailLogEvent;
|
||||
}
|
||||
|
||||
// Fires once per span shown in the details panel; resets when the panel closes.
|
||||
export function useSpanDetailsOpenedEvent({
|
||||
isOpen,
|
||||
spanId,
|
||||
detailsTab,
|
||||
logTraceEvent,
|
||||
}: SpanDetailsOpenedEventArgs): void {
|
||||
useEffect(() => {
|
||||
if (!isOpen || !spanId) {
|
||||
return;
|
||||
}
|
||||
logTraceEvent(TraceDetailEvents.SpanDetailsOpened, {
|
||||
[TraceDetailEventKeys.SpanId]: spanId,
|
||||
[TraceDetailEventKeys.DetailsTab]: detailsTab,
|
||||
});
|
||||
}, [isOpen, spanId, detailsTab, logTraceEvent]);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef<string>('');
|
||||
const threadDataLoadedFiredForRef = useRef<string>('');
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
@@ -39,7 +40,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
dataLoadedFiredForRef={dataLoadedFiredForRef}
|
||||
/>
|
||||
) : (
|
||||
<TraceDetailsThread />
|
||||
<TraceDetailsThread dataLoadedFiredForRef={threadDataLoadedFiredForRef} />
|
||||
)}
|
||||
</TraceDetailsWrapper>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -22,12 +26,14 @@ import (
|
||||
const unmappedModelsLookback = time.Hour
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
|
||||
return &module{store: store, querier: querier}
|
||||
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, telemetryMetadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings) llmpricingrule.Module {
|
||||
return &module{store: store, querier: querier, telemetryMetadataStore: telemetryMetadataStore, logger: providerSettings.Logger}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
@@ -51,13 +57,47 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
|
||||
unmapped = append(unmapped, m)
|
||||
return llmpricingruletypes.UnmappedModels(models, rules), nil
|
||||
}
|
||||
|
||||
// Collect keeps the rule counts even when ClickHouse is unreachable: the
|
||||
// counts that depend on it are skipped so the reporter retains their last value.
|
||||
// Models come from the traces tag table rather than a span scan, so the
|
||||
// unmapped count covers the metadata lookback instead of the last hour.
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
rules, err := module.listAllRules(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats := llmpricingruletypes.NewStatsFromRules(rules)
|
||||
|
||||
selectors := aiobservabilitytypes.StatsFieldValueSelectors(time.Now())
|
||||
results := make([]*telemetrytypes.TelemetryFieldValues, len(selectors))
|
||||
var lookups sync.WaitGroup
|
||||
for i, selector := range selectors {
|
||||
lookups.Go(func() {
|
||||
fieldValues, _, err := module.telemetryMetadataStore.GetAllValues(ctx, orgID, selector)
|
||||
if err != nil {
|
||||
module.logger.DebugContext(ctx, "failed to collect gen_ai attribute stats", slog.String("key", selector.Name), errors.Attr(err))
|
||||
return
|
||||
}
|
||||
results[i] = fieldValues
|
||||
})
|
||||
}
|
||||
lookups.Wait()
|
||||
|
||||
values := make(map[string]*telemetrytypes.TelemetryFieldValues, len(selectors))
|
||||
for i, selector := range selectors {
|
||||
if results[i] != nil {
|
||||
values[selector.Name] = results[i]
|
||||
}
|
||||
}
|
||||
return unmapped, nil
|
||||
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
|
||||
if models := values[aiobservabilitytypes.GenAIRequestModel]; models != nil {
|
||||
maps.Copy(stats, llmpricingruletypes.NewStatsFromModels(models.StringValues, rules))
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -13,6 +14,8 @@ type Module interface {
|
||||
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
|
||||
agentConf.AgentFeature
|
||||
|
||||
statsreporter.StatsCollector
|
||||
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
|
||||
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
|
||||
|
||||
@@ -164,7 +164,7 @@ func NewModules(
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, schemaMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +572,7 @@ func New(
|
||||
cloudIntegrationModule,
|
||||
modules.LogsPipeline,
|
||||
modules.InfraMonitoring,
|
||||
modules.LLMPricingRule,
|
||||
querier,
|
||||
authz,
|
||||
}
|
||||
|
||||
55
pkg/types/aiobservabilitytypes/stats.go
Normal file
55
pkg/types/aiobservabilitytypes/stats.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
// statsLookback reaches into the previous day; the metadata store rounds
|
||||
// the start down to a day boundary.
|
||||
statsLookback = 24 * time.Hour
|
||||
// statsModelValuesLimit caps the model list, which also feeds the
|
||||
// unmapped model count. One value is enough for the other keys.
|
||||
statsModelValuesLimit = 100
|
||||
)
|
||||
|
||||
// statsAttributeKeys are the gen_ai span attributes whose recent presence the
|
||||
// stats reporter tracks under telemetry.traces.<key>.exists.
|
||||
var statsAttributeKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
|
||||
|
||||
// StatsFieldValueSelectors look up each tracked attribute's string values
|
||||
// since the start of the previous day.
|
||||
func StatsFieldValueSelectors(now time.Time) []*telemetrytypes.FieldValueSelector {
|
||||
selectors := make([]*telemetrytypes.FieldValueSelector, 0, len(statsAttributeKeys))
|
||||
for _, key := range statsAttributeKeys {
|
||||
limit := 1
|
||||
if key == GenAIRequestModel {
|
||||
limit = statsModelValuesLimit
|
||||
}
|
||||
selectors = append(selectors, &telemetrytypes.FieldValueSelector{
|
||||
FieldKeySelector: &telemetrytypes.FieldKeySelector{
|
||||
StartUnixMilli: now.Add(-statsLookback).UnixMilli(),
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Name: key,
|
||||
},
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
return selectors
|
||||
}
|
||||
|
||||
// NewStatsFromFieldValues reports whether each looked-up key had a value. Keys
|
||||
// absent from values are left out, so a failed lookup never reports false.
|
||||
func NewStatsFromFieldValues(values map[string]*telemetrytypes.TelemetryFieldValues) map[string]any {
|
||||
stats := make(map[string]any, len(values))
|
||||
for _, key := range statsAttributeKeys {
|
||||
if fieldValues := values[key]; fieldValues != nil {
|
||||
stats["telemetry.traces."+key+".exists"] = len(fieldValues.StringValues) > 0
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
73
pkg/types/aiobservabilitytypes/stats_test.go
Normal file
73
pkg/types/aiobservabilitytypes/stats_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func TestNewStatsFromFieldValues(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
values map[string]*telemetrytypes.TelemetryFieldValues
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "NoLookups_NothingReported",
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "EveryKeySeen_AllTrue",
|
||||
values: map[string]*telemetrytypes.TelemetryFieldValues{
|
||||
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
|
||||
GenAIToolName: {StringValues: []string{"search"}},
|
||||
GenAIAgentName: {StringValues: []string{"planner"}},
|
||||
},
|
||||
want: map[string]any{
|
||||
"telemetry.traces.gen_ai.request.model.exists": true,
|
||||
"telemetry.traces.gen_ai.tool.name.exists": true,
|
||||
"telemetry.traces.gen_ai.agent.name.exists": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyAndMissingLookups_OnlyLookedUpKeysReported",
|
||||
values: map[string]*telemetrytypes.TelemetryFieldValues{
|
||||
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
|
||||
GenAIToolName: {},
|
||||
GenAIOperationName: {StringValues: []string{"chat"}},
|
||||
},
|
||||
want: map[string]any{
|
||||
"telemetry.traces.gen_ai.request.model.exists": true,
|
||||
"telemetry.traces.gen_ai.tool.name.exists": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, NewStatsFromFieldValues(testCase.values))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsFieldValueSelectors(t *testing.T) {
|
||||
now := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
selectors := StatsFieldValueSelectors(now)
|
||||
|
||||
require.Len(t, selectors, 3)
|
||||
for _, selector := range selectors {
|
||||
assert.Equal(t, now.Add(-24*time.Hour).UnixMilli(), selector.StartUnixMilli, selector.Name)
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, selector.Signal, selector.Name)
|
||||
assert.Equal(t, telemetrytypes.FieldContextAttribute, selector.FieldContext, selector.Name)
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeString, selector.FieldDataType, selector.Name)
|
||||
}
|
||||
assert.Equal(t, GenAIRequestModel, selectors[0].Name)
|
||||
assert.Equal(t, 100, selectors[0].Limit)
|
||||
assert.Equal(t, 1, selectors[1].Limit)
|
||||
assert.Equal(t, 1, selectors[2].Limit)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -10,6 +11,7 @@ const (
|
||||
statKeyPanelTracesCount = "dashboard.panels.traces.count"
|
||||
statKeyPanelMetricsCount = "dashboard.panels.metrics.count"
|
||||
statKeyPanelLogsCount = "dashboard.panels.logs.count"
|
||||
statKeyPanelAICount = "dashboard.panels.ai.count"
|
||||
)
|
||||
|
||||
// panelSignalStatKeys maps a builder query's signal to the stat it contributes
|
||||
@@ -22,14 +24,17 @@ var panelSignalStatKeys = map[telemetrytypes.Signal]string{
|
||||
}
|
||||
|
||||
// NewStatsFromStorableDashboards reports the stats of stored dashboards. Rows that
|
||||
// do not decode as v2 contribute to dashboard.count only.
|
||||
// do not decode as v2 contribute to dashboard.count only. System dashboards are
|
||||
// provisioned into every org, so they are left out of every count.
|
||||
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
|
||||
stats := newPanelStats()
|
||||
stats := newStats()
|
||||
|
||||
for _, dashboard := range dashboards {
|
||||
if dashboard == nil {
|
||||
if dashboard == nil || dashboard.Source == SourceSystem {
|
||||
continue
|
||||
}
|
||||
incrementStat(stats, statKeyDashboardCount)
|
||||
|
||||
dashboardV2, err := dashboard.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -37,7 +42,6 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
|
||||
addPanelStats(&dashboardV2.Spec, stats)
|
||||
}
|
||||
|
||||
stats[statKeyDashboardCount] = int64(len(dashboards))
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -45,24 +49,26 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
|
||||
// created, straight off the postable spec — the create path has no reason to make
|
||||
// a storable round-trip just to be counted.
|
||||
func NewStatsFromPostableDashboardV2(postable PostableDashboardV2) map[string]any {
|
||||
stats := newPanelStats()
|
||||
stats := newStats()
|
||||
stats[statKeyDashboardCount] = int64(1)
|
||||
addPanelStats(&postable.Spec, stats)
|
||||
|
||||
stats[statKeyDashboardCount] = int64(1)
|
||||
return stats
|
||||
}
|
||||
|
||||
func newPanelStats() map[string]any {
|
||||
func newStats() map[string]any {
|
||||
return map[string]any{
|
||||
statKeyDashboardCount: int64(0),
|
||||
statKeyPanelCount: int64(0),
|
||||
statKeyPanelTracesCount: int64(0),
|
||||
statKeyPanelMetricsCount: int64(0),
|
||||
statKeyPanelLogsCount: int64(0),
|
||||
statKeyPanelAICount: int64(0),
|
||||
}
|
||||
}
|
||||
|
||||
// addPanelStats counts the panels of a v2 spec, and each panel's queries against
|
||||
// the signal they read.
|
||||
// the signal they read. AI queries count only towards the AI stat.
|
||||
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
|
||||
for _, panel := range spec.Panels {
|
||||
if panel == nil {
|
||||
@@ -76,6 +82,10 @@ func addPanelStats(spec *DashboardSpec, stats map[string]any) {
|
||||
continue
|
||||
}
|
||||
for _, envelope := range composite.Queries {
|
||||
if envelope.Type == qb.QueryTypeBuilderAI {
|
||||
incrementStat(stats, statKeyPanelAICount)
|
||||
continue
|
||||
}
|
||||
if key, ok := panelSignalStatKeys[envelope.GetSignal()]; ok {
|
||||
incrementStat(stats, key)
|
||||
}
|
||||
|
||||
@@ -117,20 +117,60 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// An AI builder query is always a traces query, so it counts towards traces.
|
||||
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
|
||||
aiBuilder := `{
|
||||
func statsAIBuilderQuery() string {
|
||||
return `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
|
||||
}
|
||||
|
||||
// An AI builder query reads traces but counts only towards the AI stat.
|
||||
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsCountsAICompositeSubQueries(t *testing.T) {
|
||||
composite := `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_ai_query", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("B", "logs") + `}
|
||||
]}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `
|
||||
"p1": `+statsPanel(composite)+`,
|
||||
"p2": `+statsPanel(statsAIBuilderQuery())+`
|
||||
`)
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelAICount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsSkipsSystemDashboards(t *testing.T) {
|
||||
system := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
system.Source = SourceSystem
|
||||
system.Name = SystemDashboardNamePrefix + "overview"
|
||||
user := newStatsStorableV2(t, `"p1": `+statsPanel(statsBuilderQuery("logs")))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{system, user})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// promql and clickhouse queries carry no signal, so they land in the panel total
|
||||
@@ -215,6 +255,17 @@ func TestNewStatsFromPostableDashboardV2(t *testing.T) {
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromPostableDashboardV2CountsAIBuilderQueries(t *testing.T) {
|
||||
postable := newStatsPostableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
|
||||
stats := NewStatsFromPostableDashboardV2(postable)
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromPostableDashboardV2WithNoPanels(t *testing.T) {
|
||||
@@ -233,4 +284,5 @@ func TestNewStatsFromStorableDashboardsWithNoDashboards(t *testing.T) {
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
@@ -277,3 +277,13 @@ func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UnmappedModels(models []*UnmappedModel, rules []*LLMPricingRule) []*UnmappedModel {
|
||||
unmapped := make([]*UnmappedModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if !ModelMatchesAnyRule(model.ModelName, rules) {
|
||||
unmapped = append(unmapped, model)
|
||||
}
|
||||
}
|
||||
return unmapped
|
||||
}
|
||||
|
||||
31
pkg/types/llmpricingruletypes/stats.go
Normal file
31
pkg/types/llmpricingruletypes/stats.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
// NewStatsFromRules reports the rules users added themselves and the synced
|
||||
// defaults users pinned.
|
||||
func NewStatsFromRules(rules []*LLMPricingRule) map[string]any {
|
||||
custom, overrides := int64(0), int64(0)
|
||||
for _, rule := range rules {
|
||||
switch {
|
||||
case rule.SourceID == nil:
|
||||
custom++
|
||||
case rule.IsOverride:
|
||||
overrides++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"llmpricingrule.custom.count": custom,
|
||||
"llmpricingrule.override.count": overrides,
|
||||
}
|
||||
}
|
||||
|
||||
func NewStatsFromModels(models []string, rules []*LLMPricingRule) map[string]any {
|
||||
unmapped := int64(0)
|
||||
for _, model := range models {
|
||||
if !ModelMatchesAnyRule(model, rules) {
|
||||
unmapped++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{"llmpricingrule.unmapped_model.count": unmapped}
|
||||
}
|
||||
65
pkg/types/llmpricingruletypes/stats_test.go
Normal file
65
pkg/types/llmpricingruletypes/stats_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func TestNewStatsFromRules(t *testing.T) {
|
||||
sourceID := valuer.GenerateUUID()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
rules []*LLMPricingRule
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "NoRules",
|
||||
want: map[string]any{
|
||||
"llmpricingrule.custom.count": int64(0),
|
||||
"llmpricingrule.override.count": int64(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CustomPinnedAndSyncedRules_CountedSeparately",
|
||||
rules: []*LLMPricingRule{
|
||||
{IsOverride: true},
|
||||
{IsOverride: false},
|
||||
{SourceID: &sourceID, IsOverride: true},
|
||||
{SourceID: &sourceID, IsOverride: false},
|
||||
},
|
||||
want: map[string]any{
|
||||
"llmpricingrule.custom.count": int64(2),
|
||||
"llmpricingrule.override.count": int64(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, NewStatsFromRules(testCase.rules))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStatsFromModels(t *testing.T) {
|
||||
rules := []*LLMPricingRule{{ModelPattern: StringSlice{"gpt-*"}}}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
models []string
|
||||
want int64
|
||||
}{
|
||||
{name: "NoModels", want: 0},
|
||||
{name: "PricedAndUnpricedModels_UnpricedCounted", models: []string{"gpt-5-nano", "claude-haiku-5-5", "gemini-3"}, want: 2},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, map[string]any{"llmpricingrule.unmapped_model.count": testCase.want}, NewStatsFromModels(testCase.models, rules))
|
||||
})
|
||||
}
|
||||
}
|
||||
9
tests/e2e/drivers/channels.ts
generated
9
tests/e2e/drivers/channels.ts
generated
@@ -209,6 +209,10 @@ interface ChannelForm {
|
||||
/** Saves an existing channel and waits for the write to land. */
|
||||
saveEdit(): Promise<void>;
|
||||
save(): Promise<void>;
|
||||
/** The sticky bar holding discard, test and save. */
|
||||
actions(): Locator;
|
||||
/** Scrolls with the mouse wheel, which unlike scrollIntoView cannot move an overflow: hidden box. */
|
||||
wheelToEnd(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,6 +338,11 @@ function channelsDriver(page: Page): Channels {
|
||||
async save() {
|
||||
await page.getByTestId('save-channel-button').click();
|
||||
},
|
||||
actions: () => page.getByTestId('discard-channel-button').locator('..'),
|
||||
async wheelToEnd() {
|
||||
await form.name().hover();
|
||||
await page.mouse.wheel(0, 10_000);
|
||||
},
|
||||
};
|
||||
|
||||
const api: ChannelApi = {
|
||||
|
||||
57
tests/e2e/tests/alerts/channels/scroll.spec.ts
Normal file
57
tests/e2e/tests/alerts/channels/scroll.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
import { type Channels, expect, test } from '../../../drivers/channels';
|
||||
|
||||
// Shorter than the Slack form, so it only fits by scrolling inside the
|
||||
// configuration pane, whose own overflow is hidden.
|
||||
test.use({ viewport: { width: 1280, height: 600 } });
|
||||
|
||||
async function expectScrollsWithActionsPinned(
|
||||
page: Page,
|
||||
form: Channels['form'],
|
||||
): Promise<void> {
|
||||
const actions = form.actions();
|
||||
const lastControl = page.getByTestId('slack-action-add');
|
||||
|
||||
await expect(actions).toBeInViewport({ ratio: 1 });
|
||||
await expect(lastControl).not.toBeInViewport();
|
||||
|
||||
await form.wheelToEnd();
|
||||
|
||||
await expect(lastControl).toBeInViewport({ ratio: 1 });
|
||||
await expect(actions).toBeInViewport({ ratio: 1 });
|
||||
|
||||
const control = await lastControl.boundingBox();
|
||||
const bar = await actions.boundingBox();
|
||||
expect(control!.y + control!.height).toBeLessThanOrEqual(bar!.y);
|
||||
}
|
||||
|
||||
test.describe('Notification channels — form scroll', () => {
|
||||
test('TC-01 the new channel form scrolls to its end with actions pinned', async ({
|
||||
channels,
|
||||
authedPage,
|
||||
}) => {
|
||||
await channels.form.gotoNew();
|
||||
await channels.form.pickType('Slack');
|
||||
await expect(authedPage.getByTestId('slack-action-add')).toBeAttached();
|
||||
|
||||
await expectScrollsWithActionsPinned(authedPage, channels.form);
|
||||
});
|
||||
|
||||
test('TC-02 the edit channel form scrolls to its end with actions pinned', async ({
|
||||
channels,
|
||||
authedPage,
|
||||
}) => {
|
||||
let id: string | undefined;
|
||||
|
||||
try {
|
||||
({ id } = await channels.api.create(`e2e-ncs-${Date.now()}`));
|
||||
await authedPage.goto(`/alerts/channels/edit/${id}`);
|
||||
await expect(channels.form.name()).toBeVisible();
|
||||
|
||||
await expectScrollsWithActionsPinned(authedPage, channels.form);
|
||||
} finally {
|
||||
await channels.api.remove(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user