Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
f4d84b41fe Merge branch 'main' into nv/cache-grid 2026-10-30 19:58:53 +05:30
Naman Verma
5dcc195c72 perf: divide cache buckets for a query into cells in a grid 2026-10-30 19:55:47 +05:30
46 changed files with 854 additions and 1045 deletions

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
import { Dot } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
@@ -16,6 +16,7 @@ import {
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';

View File

@@ -18,9 +18,11 @@ import {
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import {
buildExplorerNavigationUrl,
findSavedViewInLists,
openSavedView,
openSavedViewByKey,
@@ -230,6 +232,23 @@ describe('findSavedViewInLists', () => {
});
});
describe('buildExplorerNavigationUrl', () => {
it('encodes composite query and view selectors', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewKey]: 'view-1',
},
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});
describe('openSavedView', () => {
beforeEach(() => {
mockedNavigate.mockClear();

View File

@@ -12,7 +12,7 @@ import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource'
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
import { navigate } from 'lib/router/navigation';
import { DataSource } from 'types/common/queryBuilder';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type SavedViewSourceHint = SavedViewSourcePage;
@@ -72,6 +72,26 @@ export function explorerRouteForSourcePage(
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
}
/**
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
* without inheriting stale query params from the current page's `urlQuery`.
*/
export function buildExplorerNavigationUrl(
route: string,
query: Query,
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});
return `${route}?${params.toString()}`;
}
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
if (!route) {

View File

@@ -38,7 +38,8 @@ import { useAppContext } from 'providers/App/App';
import { UserPreference } from 'types/api/preferences/preference';
import { DataSource } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { isIngestionActive, isModifierKeyPressed } from 'utils/app';
import { isIngestionActive } from 'utils/app';
import { isModifierKeyPressed } from 'utils/app';
import { popupContainer } from 'utils/selectPopupContainer';
import crackerUrl from '@/assets/Icons/cracker.svg';

View File

@@ -77,11 +77,6 @@
word-break: break-all;
}
.error {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}
.noFilters {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);

View File

@@ -1,8 +1,13 @@
import { CalendarClock } from '@signozhq/icons';
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { useTimezone } from 'providers/Timezone';
import SavedViewHoverCardDetails from './SavedViewHoverCardDetails';
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
import { SavedViewHoverCardProps } from './types';
import styles from './SavedViewHoverCard.module.scss';
@@ -13,11 +18,23 @@ function SavedViewHoverCard({
onCardLeave,
onClose,
}: SavedViewHoverCardProps): JSX.Element {
const { allowed: canReadAll } = useAuthZ([SavedViewReadPermission]);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const view = target?.view;
const querySummaries = useMemo(
() => (view ? getSavedViewQuerySummaries(view) : []),
[view],
);
const hasQueryNames = querySummaries.length > 1;
const copyValue = querySummaries
.filter(({ expression }) => expression.length > 0)
.map(({ queryName, expression }) =>
hasQueryNames ? `${queryName}: ${expression}` : expression,
)
.join('\n');
return (
<Popover
open={!!target}
open={!!view}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
@@ -38,17 +55,71 @@ function SavedViewHoverCard({
onOpenAutoFocus={(event): void => event.preventDefault()}
onCloseAutoFocus={(event): void => event.preventDefault()}
>
{target && (
{view && (
<div
className={styles.content}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
data-testid="saved-view-hover-card"
>
<SavedViewHoverCardDetails
viewId={target.viewId}
canReadAll={canReadAll}
/>
<div className={styles.header}>
<Typography.Text className={styles.name}>
{view.spec.displayName}
</Typography.Text>
{copyValue && (
<CopyButton
value={copyValue}
ariaLabel="Copy query"
testId="saved-view-hover-card-copy"
/>
)}
</div>
<div className={styles.query} data-testid="saved-view-hover-card-query">
{querySummaries.length === 0 ? (
<Typography.Text className={styles.noFilters}>
No filters
</Typography.Text>
) : (
querySummaries.map(({ queryName, expression, metric }) => (
<div key={queryName} className={styles.queryItem}>
{hasQueryNames && (
<Typography.Text className={styles.queryName}>
{queryName}
</Typography.Text>
)}
{metric && (
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
)}
{expression && (
<Typography.Text className={styles.expression}>
<SavedViewHighlightedExpression expression={expression} />
</Typography.Text>
)}
</div>
))
)}
</div>
<div className={styles.updated}>
<Typography.Text className={styles.sectionTitle}>
Last updated
</Typography.Text>
{view.updatedBy && (
<Typography.Text className={styles.metaText}>
{view.updatedBy}
</Typography.Text>
)}
{view.updatedAt && (
<div className={styles.meta}>
<CalendarClock size={14} />
<Typography.Text className={styles.metaText}>
{formatTimezoneAdjustedTimestamp(
view.updatedAt,
SAVED_VIEW_UPDATED_AT_FORMAT,
)}
</Typography.Text>
</div>
)}
</div>
</div>
)}
</PopoverContent>

View File

@@ -1,121 +0,0 @@
import { useMemo } from 'react';
import { CalendarClock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { useTimezone } from 'providers/Timezone';
import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_UPDATED_AT_FORMAT,
} from './constants';
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
import SavedViewHoverCardSkeleton from './SavedViewHoverCardSkeleton';
import { SavedViewHoverCardDetailsProps } from './types';
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
import styles from './SavedViewHoverCard.module.scss';
function SavedViewHoverCardDetails({
viewId,
}: SavedViewHoverCardDetailsProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { data, isLoading, isError } = useGetSavedView({ id: viewId });
const view = data?.data;
const querySummaries = useMemo(
() => (view ? getSavedViewQuerySummaries(view) : []),
[view],
);
if (isLoading) {
return <SavedViewHoverCardSkeleton />;
}
if (isError || !view) {
return (
<Typography.Text
className={styles.error}
data-testid="saved-view-hover-card-error"
>
{SAVED_VIEW_LOAD_FAILED_NAME}
</Typography.Text>
);
}
const hasQueryNames = querySummaries.length > 1;
const copyValue = querySummaries
.filter(({ expression }) => expression.length > 0)
.map(({ queryName, expression }) =>
hasQueryNames ? `${queryName}: ${expression}` : expression,
)
.join('\n');
return (
<>
<div className={styles.header}>
<Typography.Text className={styles.name}>
{view.spec.displayName}
</Typography.Text>
{copyValue && (
<CopyButton
value={copyValue}
ariaLabel="Copy query"
testId="saved-view-hover-card-copy"
/>
)}
</div>
<div className={styles.query} data-testid="saved-view-hover-card-query">
{querySummaries.length === 0 ? (
<Typography.Text className={styles.noFilters}>No filters</Typography.Text>
) : (
querySummaries.map(({ queryName, expression, metric }) => (
<div key={queryName} className={styles.queryItem}>
{hasQueryNames && (
<Typography.Text className={styles.queryName}>
{queryName}
</Typography.Text>
)}
{metric && (
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
)}
{expression && (
<Typography.Text className={styles.expression}>
<SavedViewHighlightedExpression expression={expression} />
</Typography.Text>
)}
</div>
))
)}
</div>
<div className={styles.updated}>
<Typography.Text className={styles.sectionTitle}>
Last updated
</Typography.Text>
{view.updatedBy && (
<Typography.Text className={styles.metaText}>
{view.updatedBy}
</Typography.Text>
)}
{view.updatedAt && (
<div className={styles.meta}>
<CalendarClock size={14} />
<Typography.Text className={styles.metaText}>
{formatTimezoneAdjustedTimestamp(
view.updatedAt,
SAVED_VIEW_UPDATED_AT_FORMAT,
)}
</Typography.Text>
</div>
)}
</div>
</>
);
}
export default withAuthZContent(SavedViewHoverCardDetails, {
checks: ({ viewId, canReadAll }) =>
canReadAll ? [] : [buildSavedViewReadPermission(viewId)],
fallbackOnLoading: <SavedViewHoverCardSkeleton />,
});

View File

@@ -1,13 +0,0 @@
import { Skeleton } from '@signozhq/ui/skeleton';
function SavedViewHoverCardSkeleton(): JSX.Element {
return (
<Skeleton
active
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
testId="saved-view-hover-card-loading"
/>
);
}
export default SavedViewHoverCardSkeleton;

View File

@@ -54,8 +54,9 @@
&.isActive {
background-color: var(--l3-background);
.rowLink {
color: var(--l1-foreground);
.rowName {
--button-variant-ghost-color: var(--l1-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
}
}
}
@@ -75,32 +76,18 @@
opacity: 1;
}
.rowLink {
display: flex;
.rowName {
--button-height: 100%;
--button-padding: 0 var(--spacing-6);
--button-justify-content: flex-start;
--button-font-size: var(--periscope-font-size-base);
--button-variant-ghost-color: var(--l2-foreground);
--button-variant-ghost-hover-color: var(--l2-foreground);
--button-variant-ghost-hover-background-color: transparent;
flex: 1;
align-items: center;
min-width: 0;
height: 100%;
padding: 0 var(--spacing-6);
border-radius: var(--radius-1);
color: var(--l2-foreground);
text-decoration: none;
&:focus-visible {
outline: 1px solid var(--button-focus-ring);
outline-offset: -1px;
}
&:hover {
color: var(--l2-foreground-hover);
}
}
// nowrap fills the row before the ellipsis; line clamping alone stops at the last whole word.
.rowLinkLabel {
min-width: 0;
letter-spacing: -0.005em;
white-space: nowrap;
}
.loading {

View File

@@ -65,11 +65,11 @@ function SavedViewsList({
const rowHover = useMemo<SavedViewsListRowHoverProps>(
() => ({
onRowEnter: (viewId, row): void => {
onRowEnter: (view, row): void => {
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
const rowRect = row.getBoundingClientRect();
onHoverCardRowEnter({
viewId,
view,
anchorTop: rowRect.top - listTop,
anchorHeight: rowRect.height,
});

View File

@@ -1,8 +1,10 @@
import cx from 'classnames';
import { Undo2 } from '@signozhq/icons';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import SavedViewsRowButton from './SavedViewsRowButton';
import SavedViewsRowLink from './SavedViewsRowLink';
import SavedViewsRowMenu from './SavedViewsRowMenu';
import { SavedViewsListRowProps } from './types';
@@ -16,17 +18,42 @@ function SavedViewsListRow({
onAction,
hover,
}: SavedViewsListRowProps): JSX.Element {
const name = view.spec.displayName;
const readPermission = buildSavedViewReadPermission(view.id);
// Same cached check as the button; gates the hover card.
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
const canRead = !isLoading && deniedPermissions.length === 0;
return (
<div
className={cx(styles.row, { [styles.isActive]: isActive })}
data-testid="saved-views-row"
data-view-id={view.id}
onMouseEnter={(event): void =>
hover.onRowEnter(view.id, event.currentTarget)
}
onMouseEnter={(event): void => {
if (canRead) {
hover.onRowEnter(view, event.currentTarget);
}
}}
onMouseLeave={hover.onRowLeave}
>
<SavedViewsRowLink view={view} isActive={isActive} onSelect={onSelect} />
<div className={styles.rowName}>
<AuthZButton
checks={[readPermission]}
size="md"
variant="ghost"
color="secondary"
width="100%"
textOverflow="ellipsis"
onClick={(): void => {
if (!isActive) {
onSelect(view);
}
}}
testId="saved-views-row-name"
>
{name}
</AuthZButton>
</div>
{isActive && (
<SavedViewsRowButton
icon={<Undo2 size={14} />}

View File

@@ -5,11 +5,7 @@ import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import {
SavedViewListPermission,
SavedViewReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsList from './SavedViewsList';
@@ -25,8 +21,6 @@ function SavedViewsPanel({
onClose?: () => void;
}): JSX.Element {
const [search, setSearch] = useState('');
// Batched with the list check, so the hover card reads it from cache.
useAuthZ([SavedViewReadPermission]);
return (
<div

View File

@@ -1,57 +0,0 @@
import { MouseEvent, useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { AppLink } from 'lib/router/AppLink';
import { useAppLocation } from 'lib/router/useAppLocation';
import { isModifierKeyPressed } from 'utils/app';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import { SavedViewsRowLinkProps } from './types';
import { getSavedViewPath } from './utils/getSavedViewPath';
import styles from './SavedViewsList.module.scss';
function SavedViewsRowLink({
view,
isActive,
onSelect,
}: SavedViewsRowLinkProps): JSX.Element {
const { pathname } = useAppLocation();
const timeSearch = useTimeSearchParams();
const path = useMemo(
() => getSavedViewPath(pathname, view, timeSearch),
[pathname, view, timeSearch],
);
// Opening in a new tab or window is left to the browser.
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
if (isModifierKeyPressed(event) || event.shiftKey) {
return;
}
event.preventDefault();
if (!isActive) {
onSelect(view);
}
};
return (
<AppLink
to={path}
className={styles.rowLink}
aria-current={isActive ? 'page' : undefined}
onClick={handleClick}
data-testid="saved-views-row-name"
>
<Typography.Text
as="span"
size="base"
weight="normal"
truncate={1}
className={styles.rowLinkLabel}
>
{view.spec.displayName}
</Typography.Text>
</AppLink>
);
}
export default SavedViewsRowLink;

View File

@@ -8,8 +8,6 @@ import {
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -18,7 +16,7 @@ import {
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
} from '../constants';
import SavedViewsPanel from '../SavedViewsPanel';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
@@ -141,11 +139,9 @@ function advance(ms: number): void {
});
}
// Resolves once the card has fetched its view.
async function openCard(name: string): Promise<HTMLElement> {
function openCard(name: string): void {
fireEvent.mouseEnter(row(name));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
return screen.findByTestId('saved-view-hover-card-query');
}
describe('SavedViewHoverCard', () => {
@@ -172,8 +168,7 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
advance(1);
expect(card()).toBeInTheDocument();
await waitFor(() => expect(card()).toHaveTextContent('Errors'));
expect(card()).toHaveTextContent('Errors');
});
it('does not open when the pointer leaves before the delay', async () => {
@@ -189,7 +184,7 @@ describe('SavedViewHoverCard', () => {
it('stays open while the pointer moves into the card', async () => {
await renderPanel();
await openCard('Errors');
openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(card() as HTMLElement);
@@ -203,7 +198,7 @@ describe('SavedViewHoverCard', () => {
it('closes on leaving the row and reopens for the next row after the delay', async () => {
await renderPanel();
await openCard('Errors');
openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(row('Slow spans'));
@@ -211,12 +206,12 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await waitFor(() => expect(card()).toHaveTextContent('Slow spans'));
expect(card()).toHaveTextContent('Slow spans');
});
it('closes when the row menu is pressed', async () => {
await renderPanel();
await openCard('Errors');
openCard('Errors');
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
@@ -227,17 +222,19 @@ describe('SavedViewHoverCard', () => {
describe('content', () => {
it('shows the filter, the updater and the update time', async () => {
await renderPanel();
const query = await openCard('Errors');
openCard('Errors');
const content = card() as HTMLElement;
expect(query).toHaveTextContent('has_error = true');
expect(
within(content).getByTestId('saved-view-hover-card-query'),
).toHaveTextContent('has_error = true');
expect(content).toHaveTextContent('editor@signoz.io');
expect(content).toHaveTextContent('Sep 28, 2026');
});
it('copies the filter expression', async () => {
await renderPanel();
await openCard('Errors');
openCard('Errors');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
@@ -246,8 +243,9 @@ describe('SavedViewHoverCard', () => {
it('names each query when there are several, and copies them per line', async () => {
await renderPanel();
const query = await openCard('Errors and slow');
openCard('Errors and slow');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('A');
expect(query).toHaveTextContent('B');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
@@ -259,74 +257,21 @@ describe('SavedViewHoverCard', () => {
it('says there are no filters, with nothing to copy', async () => {
await renderPanel();
const query = await openCard('Everything');
openCard('Everything');
expect(query).toHaveTextContent('No filters');
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
'No filters',
);
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
});
it('shows the metric of a metrics query', async () => {
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
const query = await openCard('Request rate');
openCard('Request rate');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('app.requests · rate · sum');
expect(query).not.toHaveTextContent('No filters');
});
});
describe('fetching', () => {
it('shows the query from the view fetch, not from the list', async () => {
server.use(
rest.get(API, (_req, res, ctx) =>
res(
ctx.json({
status: 'success',
data: [{ ...errors, spec: { ...errors.spec, queries: [] } }],
}),
),
),
);
await renderPanel();
const query = await openCard('Errors');
expect(query).toHaveTextContent('has_error = true');
});
it('fetches the view again on every open', async () => {
let fetches = 0;
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) => {
fetches += 1;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
await renderPanel();
await openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
expect(card()).toBeNull();
await openCard('Errors');
await waitFor(() => expect(fetches).toBe(2));
});
it('says the view failed to load when its fetch fails', async () => {
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await expect(
screen.findByTestId('saved-view-hover-card-error'),
).resolves.toHaveTextContent('View failed to load');
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
});
});
});

View File

@@ -1,10 +1,6 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
AuthtypesTransactionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
@@ -13,42 +9,34 @@ import {
buildSavedViewUpdatePermission,
SavedViewCreatePermission,
SavedViewListPermission,
SavedViewReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS } from '../constants';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const HOVER_CARD_TIMEOUT = { timeout: SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS * 3 };
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
function renderPanel(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
function renderPanel(url = explorerUrl(PATH, {})): void {
renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
@@ -80,37 +68,6 @@ async function openRowMenu(name: string): Promise<void> {
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
}
// Records every check, answered by `isGranted`.
function recordChecks(
isGranted: (transaction: AuthtypesTransactionDTO) => boolean = (): boolean =>
true,
): AuthtypesTransactionDTO[] {
const checks: AuthtypesTransactionDTO[] = [];
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
checks.push(...payload);
return res(ctx.json(authzMockResponse(payload, payload.map(isGranted))));
}),
);
return checks;
}
const readSelectors = (checks: AuthtypesTransactionDTO[]): string[] =>
checks
.filter((transaction) => transaction.relation === 'read')
.map((transaction) => transaction.object.selector);
async function hoverRow(name: string): Promise<HTMLElement> {
await screen.findAllByTestId('saved-views-row');
fireEvent.mouseEnter(rowNamed(name));
return screen.findByTestId(
'saved-view-hover-card',
undefined,
HOVER_CARD_TIMEOUT,
);
}
// Resolves once the row's check is back, so its state is final.
async function settledRow(testId: string): Promise<HTMLElement> {
await waitFor(
@@ -141,51 +98,21 @@ describe('SavedViews - AuthZ', () => {
expect(screen.queryByTestId('saved-views-search')).toBeNull();
});
it('runs no read check per row', async () => {
it('disables only the views it cannot read', async () => {
mockSavedViewsApi([errors, slow]);
const checks = recordChecks();
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderPanel();
// The menu checks go out after any check the rows made on mount.
await openRowMenu('Errors');
await settledRow('saved-views-row-menu-item-delete');
expect(checks.map((transaction) => transaction.relation)).toContain('delete');
expect(readSelectors(checks)).toStrictEqual(['*']);
});
it('opens a view it cannot read, then the header says no access', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
const { history } = renderWithExplorerProviders(
<>
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onToggleViews={jest.fn()}
/>
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>
</>,
explorerUrl(PATH, {}),
);
await screen.findAllByTestId('saved-views-row');
const name = within(rowNamed('Errors')).getByTestId('saved-views-row-name');
expectUsable(name);
await userEvent.click(name);
const nameOf = (view: string): HTMLElement =>
within(rowNamed(view)).getByTestId('saved-views-row-name');
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
),
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
);
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
});
it('keeps the row menu on a view it cannot read', async () => {
@@ -206,58 +133,6 @@ describe('SavedViews - AuthZ', () => {
).resolves.not.toHaveAttribute('data-disabled');
});
it('shows the denied callout in the hover card of a view it cannot read, without fetching it', async () => {
let fetches = 0;
mockSavedViewsApi([errors]);
server.use(
setupAuthzDeny(
SavedViewReadPermission,
buildSavedViewReadPermission(errors.id),
),
rest.get(`${API}/:id`, (_req, res, ctx) => {
fetches += 1;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(within(card).queryByTestId('saved-view-hover-card-query')).toBeNull();
expect(fetches).toBe(0);
});
it('skips the read check of the hovered view with read on every view', async () => {
mockSavedViewsApi([errors]);
const checks = recordChecks();
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByTestId('saved-view-hover-card-query'),
).resolves.toHaveTextContent('has_error = true');
expect(readSelectors(checks)).toStrictEqual(['*']);
});
it('checks read on the hovered view without read on every view', async () => {
mockSavedViewsApi([errors]);
const checks = recordChecks(
(transaction) =>
transaction.relation !== 'read' || transaction.object.selector !== '*',
);
renderPanel();
const card = await hoverRow('Errors');
await expect(
within(card).findByTestId('saved-view-hover-card-query'),
).resolves.toHaveTextContent('has_error = true');
expect(readSelectors(checks)).toStrictEqual(['*', errors.id]);
});
it('disables creating without create', async () => {
mockSavedViewsApi([]);
server.use(setupAuthzDeny(SavedViewCreatePermission));

View File

@@ -1,14 +1,9 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
fireEvent,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -19,7 +14,6 @@ import {
renderWithExplorerProviders,
urlParam,
viewUrl,
withGlobalTime,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
@@ -59,9 +53,6 @@ const row = (name: string): HTMLElement =>
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const rowName = (name: string): HTMLElement =>
within(row(name)).getByTestId('saved-views-row-name');
describe('SavedViewsPanel list', () => {
beforeEach(() => {
localStorage.clear();
@@ -94,8 +85,9 @@ describe('SavedViewsPanel list', () => {
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
await waitFor(() => expectUsable(rowName('Slow spans')));
await userEvent.click(rowName('Slow spans'));
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
await waitFor(() => expectUsable(name));
await userEvent.click(name);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
@@ -104,60 +96,6 @@ describe('SavedViewsPanel list', () => {
);
});
it('links each row to its view in the current time range', async () => {
mockSavedViewsApi([mine, alsoMine]);
renderWithExplorerProviders(
withGlobalTime(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
{ selectedTime: '1h' },
),
explorerUrl(PATH, {}),
);
await screen.findByTestId('saved-views-list');
const url = new URL(
rowName('Slow spans').getAttribute('href') as string,
window.location.origin,
);
expect(url.pathname).toBe(PATH);
expect(url.searchParams.get(QueryParams.viewKey)).toBe(
JSON.stringify(alsoMine.id),
);
expect(url.searchParams.get(QueryParams.panelTypes)).toBe(
JSON.stringify(alsoMine.spec.panelType),
);
expect(url.searchParams.has(QueryParams.compositeQuery)).toBe(true);
expect(url.searchParams.get(QueryParams.relativeTime)).toBe('1h');
});
it.each([
['Ctrl', { ctrlKey: true }],
['Cmd', { metaKey: true }],
['Shift', { shiftKey: true }],
])('leaves a %s click on a row to the browser', async (_key, modifiers) => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
const before = history.location.search;
let defaultPrevented: boolean | undefined;
const recordClick = (event: MouseEvent): void => {
defaultPrevented = event.defaultPrevented;
// jsdom cannot open a new tab.
event.preventDefault();
};
document.addEventListener('click', recordClick);
fireEvent.click(rowName('Slow spans'), modifiers);
document.removeEventListener('click', recordClick);
expect(defaultPrevented).toBe(false);
expect(history.location.search).toBe(before);
});
it('marks the open view and clears it from its row', async () => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel(viewUrl(PATH, mine));
@@ -182,8 +120,9 @@ describe('SavedViewsPanel list', () => {
await screen.findByTestId('saved-views-list');
const before = history.location.search;
expect(rowName('Errors')).toHaveAttribute('aria-current', 'page');
await userEvent.click(rowName('Errors'));
await userEvent.click(
within(row('Errors')).getByTestId('saved-views-row-name'),
);
expect(history.location.search).toBe(before);
});

View File

@@ -4,7 +4,6 @@ import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
@@ -17,7 +16,6 @@ import {
renderWithExplorerProviders,
urlParam,
viewUrl,
withGlobalTime,
} from './savedViewsTestUtils';
const mockCopy = jest.fn();
@@ -34,8 +32,6 @@ jest.mock('@signozhq/ui/sonner', () => ({
}));
const PATH = ROUTES.TRACES_EXPLORER;
const START_MS = 1700000000000;
const END_MS = 1700003600000;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
@@ -123,22 +119,9 @@ describe('SavedViewsPanel row menu', () => {
);
});
it('copies a link that opens the view with its query, tab and time range', async () => {
it('copies a link that opens the view with its query and tab', async () => {
mockSavedViewsApi([errors, slow]);
renderWithExplorerProviders(
withGlobalTime(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
{
selectedTime: 'custom',
minTime: START_MS * NANO_SECOND_MULTIPLIER,
maxTime: END_MS * NANO_SECOND_MULTIPLIER,
},
),
explorerUrl(PATH, {}),
);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Copy link');
@@ -154,9 +137,6 @@ describe('SavedViewsPanel row menu', () => {
expect(
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
).toContain('duration_nano > 1000');
expect(link.searchParams.get(QueryParams.startTime)).toBe(String(START_MS));
expect(link.searchParams.get(QueryParams.endTime)).toBe(String(END_MS));
expect(link.searchParams.has(QueryParams.relativeTime)).toBe(false);
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
});

View File

@@ -28,7 +28,6 @@ import { TestRouter } from 'tests/router';
import { getAppContextMock } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
@@ -161,23 +160,6 @@ export function viewUrl(
const mockStore = configureStore([thunk]);
export function withGlobalTime(
ui: ReactElement,
globalTime: Partial<GlobalReducer>,
): ReactElement {
const state = store.getState();
return (
<Provider
store={mockStore({
...state,
globalTime: { ...state.globalTime, ...globalTime },
})}
>
{ui}
</Provider>
);
}
// What the explorer page does around the header: writes its default query to a
// bare url, and sets the provider's tab from the url (the provider alone seeds
// it from the raw, still JSON-quoted param).

View File

@@ -12,10 +12,11 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
import { useAppLocation } from 'lib/router/useAppLocation';
import { getAbsoluteUrl } from 'utils/basePath';
import { toAPIError } from 'utils/errorUtils';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import {
SAVED_VIEW_SCHEMA_VERSION,
@@ -23,7 +24,7 @@ import {
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewPath } from '../utils/getSavedViewPath';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
@@ -42,7 +43,6 @@ export function useSavedViewRowActions(
): UseSavedViewRowActionsResult {
const queryClient = useQueryClient();
const { pathname } = useAppLocation();
const timeSearch = useTimeSearchParams();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
@@ -52,15 +52,17 @@ export function useSavedViewRowActions(
const copyViewLink = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
copyToClipboard(
getAbsoluteUrl(getSavedViewPath(pathname, view, timeSearch)),
);
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
[QueryParams.panelTypes]: view.spec.panelType,
[QueryParams.viewKey]: view.id,
});
copyToClipboard(getAbsoluteUrl(path));
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, timeSearch, copyToClipboard, logSavedViewEvent],
[pathname, copyToClipboard, logSavedViewEvent],
);
const copyViewId = useCallback(

View File

@@ -45,7 +45,6 @@ import {
savedViewsListResponse,
type SavedViewSource,
unreadableViewId,
withLongName,
} from './__story_mockdata__/savedViews';
import SavedViewsHeader from '../SavedViewsHeader';
@@ -74,13 +73,7 @@ const controls = {
unreadableView: toggleControl('Unreadable view', {
group: VIEW,
description:
'Denies `read` on the first view created by others, and so on `saved-view:*`, on top of the Access controls: its row still opens it, and the header then shows No view access, as with Open view on `created-by-others`. Its hover card shows the permission callout in place of the query.',
value: false,
}),
viewPending: toggleControl('View fetch pending', {
group: VIEW,
description:
'Holds every `GET /saved_views/:id` open, so a hover card stays on its loading skeleton.',
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
value: false,
}),
createdByMe: countControl('Created by me', {
@@ -94,12 +87,6 @@ const controls = {
value: 4,
max: SAVED_VIEWS_MAX,
}),
longNames: toggleControl('Long names', {
group: LIST,
description:
'Lengthens every view name past the sidebar width, so rows and the open view name truncate.',
value: false,
}),
};
type SavedViewsValues = MockControlValues<typeof controls>;
@@ -112,7 +99,7 @@ const viewDenied = (
): boolean =>
transaction.relation === 'read' &&
transaction.object.resource.kind === 'saved-view' &&
[deniedId, '*'].includes(transaction.object.selector);
transaction.object.selector === deniedId;
const definition = defineStoryMocks({
controls,
@@ -120,14 +107,7 @@ const definition = defineStoryMocks({
values: SavedViewsValues & AuthzArgs,
response,
): RequestHandler[] => {
const {
source,
openView,
viewPending,
createdByMe,
createdByOthers,
longNames,
} = values;
const { source, openView, createdByMe, createdByOthers } = values;
// What the panel writes stays in this handler set until a control changes.
const created: SavedviewtypesSavedViewDTO[] = [];
@@ -138,14 +118,8 @@ const definition = defineStoryMocks({
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
const named = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => (longNames ? withLongName(view) : view);
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
const stored = savedViewById(id);
const view =
created.find((item) => item.id === id) ?? (stored && named(stored));
const view = created.find((item) => item.id === id) ?? savedViewById(id);
return view && !deleted.has(id) ? current(view) : undefined;
};
@@ -169,7 +143,7 @@ const definition = defineStoryMocks({
return {
...list,
data: [...(list.data ?? []).map(named), ...created]
data: [...(list.data ?? []), ...created]
.filter((view) => !deleted.has(view.id))
.map(current),
};
@@ -177,10 +151,6 @@ const definition = defineStoryMocks({
),
rest.get(`${API}/:id`, (req, res, ctx) => {
if (viewPending) {
return res(ctx.delay('infinite'));
}
if (openError) {
return res(ctx.status(openError.status), ctx.json(openError.body));
}
@@ -304,9 +274,7 @@ const isPending = (element: HTMLElement): boolean =>
element.hasAttribute('data-loading');
const isDisabled = (element: HTMLElement): boolean =>
element.hasAttribute('disabled') ||
element.hasAttribute('data-disabled') ||
element.getAttribute('aria-disabled') === 'true';
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
export const expectDisabled = (
find: FindElement,
@@ -334,30 +302,6 @@ export const rowName =
(): HTMLElement =>
within(row).getByTestId('saved-views-row-name');
const hoverRow = async (row: HTMLElement): Promise<HTMLElement> => {
await userEvent.hover(row);
return screen.findByTestId('saved-view-hover-card', undefined, UNTIL_LOADED);
};
const searchViews = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
const search = await within(canvasElement).findByTestId(
'saved-views-search',
undefined,
UNTIL_LOADED,
);
await userEvent.type(search, text);
};
const clickWhenEnabled = async (find: FindElement): Promise<void> => {
await expectDisabled(find, false);
await userEvent.click(find());
};
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
await screen.findByRole('menu', undefined, UNTIL_LOADED);
@@ -383,115 +327,3 @@ export const openSaveChangesMenu = async (
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
byTestId(`saved-views-save-changes-item-${key}`);
type PlayContext = {
canvasElement: HTMLElement;
args: { source: SavedViewSource };
};
export const hoverFirstRow = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
const card = await hoverRow(row);
await within(card).findByTestId(
'saved-view-hover-card-query',
undefined,
UNTIL_LOADED,
);
};
export const hoverFirstRowLoading = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
const card = await hoverRow(row);
await within(card).findByTestId(
'saved-view-hover-card-loading',
undefined,
UNTIL_LOADED,
);
};
export const searchWithNoResults = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await searchViews(canvasElement, 'nothing like this');
await screen.findByTestId('saved-views-list-no-results');
};
export const openCreateModal = async (): Promise<void> => {
await clickWhenEnabled(byTestId('saved-views-create'));
await screen.findByTestId('save-view-modal');
};
export const openEditDetailsModal = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await clickWhenEnabled(rowMenuItem('editDetails'));
await screen.findByTestId('save-view-modal');
};
export const openSaveAsNewModal = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openSaveChangesMenu(canvasElement);
await clickWhenEnabled(saveChangesItem('save-as-new'));
await screen.findByTestId('save-view-modal');
};
export const openDeleteDialog = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await clickWhenEnabled(rowMenuItem('delete'));
await screen.findByTestId('delete-saved-view-confirm');
};
const findUnreadableRow = async ({
canvasElement,
args,
}: PlayContext): Promise<HTMLElement> => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
return rows.find(
(row) => row.getAttribute('data-view-id') === deniedId,
) as HTMLElement;
};
export const hoverUnreadableRow = async (
context: PlayContext,
): Promise<void> => {
const card = await hoverRow(await findUnreadableRow(context));
await expect(
await within(card).findByText(/not authorized/i, undefined, UNTIL_LOADED),
).toBeVisible();
await expect(
within(card).queryByTestId('saved-view-hover-card-query'),
).toBeNull();
};
export const openUnreadableView = async (
context: PlayContext,
): Promise<void> => {
const { canvasElement } = context;
const denied = await findUnreadableRow(context);
await userEvent.click(rowName(denied)());
await expect(
await within(canvasElement).findByText(
'No view access',
undefined,
UNTIL_LOADED,
),
).toBeVisible();
};

View File

@@ -6,17 +6,10 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import SavedViewsPanel from '../SavedViewsPanel';
import {
findRows,
hoverFirstRow,
hoverFirstRowLoading,
openCreateModal,
openDeleteDialog,
openEditDetailsModal,
openRowMenu,
openSaveAsNewModal,
openSaveChangesMenu,
SavedViewsSidebar,
savedViewsMocks,
searchWithNoResults,
sidebarCanvas,
} from './SavedViews.stories.mocks';
@@ -85,48 +78,3 @@ export const SaveChangesMenu: Story = {
await openSaveChangesMenu(canvasElement);
},
};
/**
* Views whose names run past the sidebar, with one of them open: each row and
* the open view name truncate.
*/
export const LongNames: Story = {
args: { longNames: true, openView: 'created-by-me' },
};
/** The hover card of the first row: its query, who updated it and when. */
export const HoverCard: Story = {
play: hoverFirstRow,
};
/** The hover card while its view is still loading: a skeleton in place of the query. */
export const HoverCardLoading: Story = {
args: { viewPending: true },
play: hoverFirstRowLoading,
};
/** A search that matches no view: the list says so in place of the rows. */
export const SearchNoResults: Story = {
play: searchWithNoResults,
};
/** The + in the header opens Save view for the current query. */
export const CreateModal: Story = {
play: openCreateModal,
};
/** Edit details from a row's menu: the modal starts on the view's name. */
export const EditDetailsModal: Story = {
play: openEditDetailsModal,
};
/** Save as new view from the ✓ menu on unsaved changes. */
export const SaveAsNewModal: Story = {
args: { openView: 'unsaved-changes' },
play: openSaveAsNewModal,
};
/** Delete from a row's menu asks to confirm before the view goes. */
export const DeleteDialog: Story = {
play: openDeleteDialog,
};

View File

@@ -209,18 +209,6 @@ export const createdSavedView = (
spec,
});
const LONG_NAME_SUFFIX = ', every environment and region, last 30 days';
export const withLongName = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({
...view,
spec: {
...view.spec,
displayName: `${view.spec.displayName}${LONG_NAME_SUFFIX}`,
},
});
export const savedViewResponse = (
view: SavedviewtypesSavedViewDTO,
): GetSavedView200 => ({ status: 'success', data: view });

View File

@@ -4,15 +4,14 @@ import { expect, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { unreadableViewId } from '../__story_mockdata__/savedViews';
import {
byTestId,
expectDisabled,
expectPending,
findRows,
hoverUnreadableRow,
openRowMenu,
openSaveChangesMenu,
openUnreadableView,
rowMenuItem,
rowName,
saveChangesItem,
@@ -100,18 +99,21 @@ export const NoCreateSaveMenu: Story = {
};
/**
* `read` denied on one view made by someone else: the list checks no `read`, so
* its row opens it like any other, and the header then says No view access.
* `read` denied on one view made by someone else: its name is disabled and its
* hover card never opens, while every other view still opens.
*/
export const NoRead: Story = {
args: { unreadableView: true },
play: openUnreadableView,
};
play: async ({ canvasElement, args }) => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
/** The hover card of the unreadable view: the permission callout in place of its query. */
export const NoReadHoverCard: Story = {
args: { unreadableView: true },
play: hoverUnreadableRow,
await Promise.all(
rows.map((row) =>
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
),
);
},
};
/** The unreadable view open from its link: the header names it No view access. */

View File

@@ -128,11 +128,6 @@ export interface SavedViewsListRowProps {
hover: SavedViewsListRowHoverProps;
}
export type SavedViewsRowLinkProps = Pick<
SavedViewsListRowProps,
'view' | 'isActive' | 'onSelect'
>;
export type SavedViewRowActionKey =
| 'copyLink'
| 'copyId'
@@ -186,7 +181,7 @@ export interface UseSavedViewRowActionsResult {
}
export interface SavedViewHoverCardTarget {
viewId: string;
view: SavedviewtypesSavedViewDTO;
anchorTop: number;
anchorHeight: number;
}
@@ -207,14 +202,8 @@ export interface SavedViewHoverCardProps {
onClose: () => void;
}
export interface SavedViewHoverCardDetailsProps {
viewId: string;
/** `read` is granted on every view, so the view needs no check of its own. */
canReadAll: boolean;
}
export interface SavedViewsListRowHoverProps {
onRowEnter: (viewId: string, row: HTMLElement) => void;
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
onRowLeave: () => void;
onMenuOpen: () => void;
}

View File

@@ -1,34 +0,0 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { makeView } from '../../__tests__/savedViewsTestUtils';
import { getSavedViewPath } from '../getSavedViewPath';
const view = makeView({ id: 'view-1', displayName: 'Errors' });
function paramsOf(path: string): URLSearchParams {
return new URL(path, window.location.origin).searchParams;
}
describe('getSavedViewPath', () => {
it('opens the view on the given explorer in the given time range', () => {
const path = getSavedViewPath(
ROUTES.TRACES_EXPLORER,
view,
'startTime=1700000000000&endTime=1700003600000',
);
const params = paramsOf(path);
expect(path.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
expect(params.get(QueryParams.viewKey)).toBe(JSON.stringify(view.id));
expect(params.get(QueryParams.startTime)).toBe('1700000000000');
expect(params.get(QueryParams.endTime)).toBe('1700003600000');
});
it('adds no time range when there is none', () => {
const path = getSavedViewPath(ROUTES.TRACES_EXPLORER, view, '');
expect(path.endsWith('&')).toBe(false);
expect(paramsOf(path).has(QueryParams.relativeTime)).toBe(false);
});
});

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { getSavedViewQuery } from './getSavedViewQuery';
export function getSavedViewPath(
pathname: string,
view: SavedviewtypesSavedViewDTO,
timeSearch: string,
): string {
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
[QueryParams.panelTypes]: view.spec.panelType,
[QueryParams.viewKey]: view.id,
});
return timeSearch ? `${path}&${timeSearch}` : path;
}

View File

@@ -12,13 +12,12 @@ import {
IOption,
IResourceAttribute,
IResourceAttributeProps,
OperatorValues,
Tags,
} from 'hooks/useResourceAttribute/types';
import { decode } from 'js-base64';
import { getCurrentLocation } from 'lib/router/navigation';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { OperatorValues, Tags } from 'hooks/useResourceAttribute/types';
import { v4 as uuid } from 'uuid';
import { whilelistedKeys } from './config';

View File

@@ -6,7 +6,6 @@ export const SavedViewCreatePermission = buildPermission(
'create',
'saved-view:*',
);
export const SavedViewReadPermission = buildPermission('read', 'saved-view:*');
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `saved-view:${id}`);

View File

@@ -1,7 +1,7 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import { useTimeSearchParams } from '../useTimeSearchParams';
let mockGlobalTime = {
selectedTime: '30m',

View File

@@ -9,7 +9,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import { useTimeSearchParams } from './useTimeSearchParams';
import logEvent from '@/api/common/logEvent';
import { DashboardDetailEvents } from '../../constants/events';

View File

@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
/** Active time window as a query string (no leading `?`), or `''` when unset. */
export function useTimeSearchParams(): string {

View File

@@ -1,6 +1,6 @@
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
import { timeParamsFromGlobalTime } from '../timeUrlParams';
describe('timeParamsFromGlobalTime', () => {
it('emits relativeTime for a relative selection', () => {

View File

@@ -24,7 +24,7 @@ import {
parseNewPanelTarget,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';

View File

@@ -1,22 +0,0 @@
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
describe('buildExplorerNavigationUrl', () => {
it('encodes composite query and view selectors', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewKey]: 'view-1',
},
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});

View File

@@ -1,22 +0,0 @@
import { QueryParams } from 'constants/query';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
* without inheriting stale query params from the current page's `urlQuery`.
*/
export function buildExplorerNavigationUrl(
route: string,
query: Query,
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});
return `${route}?${params.toString()}`;
}

5
pkg/cache/cache.go vendored
View File

@@ -16,6 +16,11 @@ type Cache interface {
// Get gets the cacheble entity in the dest entity passed.
Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable) error
// GetMany gets the cacheable entities of cacheKeys into dests, one per key in
// the same order. A key that is not found or could not be read sets its dest
// to nil. The returned error joins the read failures; a miss alone is not one.
GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error
// Delete deletes the cacheable entity from cache
Delete(ctx context.Context, orgID valuer.UUID, cacheKey string)

View File

@@ -196,6 +196,28 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey s
return errors.NewInternalf(errors.CodeInternal, "unrecognized: (value: \"%s\")", reflect.TypeOf(cachedData).String())
}
func (provider *provider) GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error {
if len(dests) != len(cacheKeys) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "got %d destinations for %d keys", len(dests), len(cacheKeys))
}
var errs []error
for i, cacheKey := range cacheKeys {
err := provider.Get(ctx, orgID, cacheKey, dests[i])
if err == nil {
continue
}
dests[i] = nil
if !errors.Ast(err, errors.TypeNotFound) {
errs = append(errs, err)
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, cacheKey string) {
_, span := provider.settings.Tracer().Start(ctx, "memory.delete", trace.WithAttributes(
attribute.String(semconv.AttributeDBSystem, "memory"),

View File

@@ -260,3 +260,27 @@ func TestCloneableConcurrentSetGet(t *testing.T) {
assert.NotSame(t, cachedCloneable, cloneables[i])
}
}
func TestGetMany(t *testing.T) {
cache, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
}})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
require.NoError(t, cache.Set(context.Background(), orgID, "first", &CloneableA{Key: "first", Value: 1}, 10*time.Second))
require.NoError(t, cache.Set(context.Background(), orgID, "third", &CloneableA{Key: "third", Value: 3}, 10*time.Second))
// bytes that are not JSON cannot be read into a CloneableA
memory := cache.(*provider)
memory.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), "fourth"}, "::"), []byte("not json"), 1, 10*time.Second)
memory.cc.Wait()
dests := []cachetypes.Cacheable{new(CloneableA), new(CloneableA), new(CloneableA), new(CloneableA)}
err = cache.GetMany(context.Background(), orgID, []string{"first", "second", "third", "fourth"}, dests)
// the unreadable key is reported and nilled, the readable ones are still served
assert.Error(t, err)
assert.Equal(t, []cachetypes.Cacheable{&CloneableA{Key: "first", Value: 1}, nil, &CloneableA{Key: "third", Value: 3}, nil}, dests)
}

View File

@@ -66,6 +66,49 @@ func (c *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey string,
return nil
}
func (c *provider) GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error {
if len(dests) != len(cacheKeys) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "got %d destinations for %d keys", len(dests), len(cacheKeys))
}
if len(cacheKeys) == 0 {
return nil
}
updatedCacheKeys := make([]string, len(cacheKeys))
for i, cacheKey := range cacheKeys {
updatedCacheKeys[i] = strings.Join([]string{orgID.StringValue(), cacheKey}, "::")
}
values, err := c.client.MGet(ctx, updatedCacheKeys...).Result()
if err != nil {
for i := range dests {
dests[i] = nil
}
return err
}
var errs []error
for i, value := range values {
// MGET answers a missing key with nil and a present one with its string
switch stored := value.(type) {
case nil:
dests[i] = nil
case string:
if err := dests[i].UnmarshalBinary([]byte(stored)); err != nil {
dests[i] = nil
errs = append(errs, err)
}
default:
dests[i] = nil
errs = append(errs, errors.NewInternalf(errors.CodeInternal, "unexpected value of type %T for key %s", value, cacheKeys[i]))
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
func (c *provider) Delete(ctx context.Context, orgID valuer.UUID, cacheKey string) {
c.DeleteMany(ctx, orgID, []string{cacheKey})
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/go-redis/redismock/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type CacheableA struct {
@@ -58,3 +59,28 @@ func TestSet(t *testing.T) {
assert.NoError(t, cache.Set(context.Background(), orgID, "key", cacheable, 10*time.Second))
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMany(t *testing.T) {
db, mock := redismock.NewClientMock()
providerSettings := instrumentationtest.New().ToProviderSettings()
cache := &provider{client: db, settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/cache/rediscache")}
orgID := valuer.GenerateUUID()
stored, err := json.Marshal(&CacheableA{Key: "first", Value: 1, Expiry: time.Microsecond})
require.NoError(t, err)
// one round trip for all keys, with the missing one answered as nil and
// the third holding something that is not a CacheableA
mock.ExpectMGet(
strings.Join([]string{orgID.StringValue(), "first"}, "::"),
strings.Join([]string{orgID.StringValue(), "second"}, "::"),
strings.Join([]string{orgID.StringValue(), "third"}, "::"),
).SetVal([]any{string(stored), nil, "not json"})
dests := []cachetypes.Cacheable{new(CacheableA), new(CacheableA), new(CacheableA)}
err = cache.GetMany(context.Background(), orgID, []string{"first", "second", "third"}, dests)
// the unreadable key is reported and nilled, the readable one is still served
assert.Error(t, err)
assert.Equal(t, []cachetypes.Cacheable{&CacheableA{Key: "first", Value: 1, Expiry: time.Microsecond}, nil, nil}, dests)
assert.NoError(t, mock.ExpectationsWereMet())
}

View File

@@ -1,6 +1,7 @@
package querier
import (
"cmp"
"context"
"encoding/json"
"fmt"
@@ -11,11 +12,16 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
// bucketCacheCellSteps is the grid cell width in steps; no cached bucket
// crosses a cell boundary.
const bucketCacheCellSteps = 60
// BucketCache is the interface for bucket-based caching.
type BucketCache interface {
// cached portion + list of gaps to fetch
@@ -58,44 +64,59 @@ func (bc *bucketCache) GetMissRanges(
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
// Generate cache key
cacheKey := bc.generateCacheKey(q)
bc.logger.DebugContext(ctx, "cache key", slog.String("cache_key", cacheKey))
// Try to get cached data
var data qbtypes.CachedData
err := bc.cache.Get(ctx, orgID, cacheKey, &data)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
bc.logger.ErrorContext(ctx, "error getting cached data", errors.Attr(err))
}
// No cached data, need to fetch entire range
missing = []*qbtypes.TimeRange{{From: startMs, To: endMs}}
return nil, missing
}
// Extract step interval if this is a builder query
stepMs := uint64(step.Milliseconds())
// Find missing ranges with step alignment
missing = bc.findMissingRangesWithStep(data.Buckets, startMs, endMs, stepMs)
bc.logger.DebugContext(ctx, "missing ranges", slog.Any("missing", missing), slog.Uint64("step", stepMs))
// No cached data, need to fetch entire range
missing = []*qbtypes.TimeRange{{From: startMs, To: endMs}}
// If no cached data overlaps with requested range, return empty result
if len(data.Buckets) == 0 {
if endMs <= startMs {
return nil, missing
}
cellWidthMs := bucketCacheCellSteps * stepMs
firstCellIdx, lastCellIdx := deriveCellIdx(startMs, cellWidthMs), deriveCellIdx(endMs-1, cellWidthMs)
// Gather the buckets of every cell the window touches
cellKeys := make([]string, 0, lastCellIdx-firstCellIdx+1)
cellEntries := make([]cachetypes.Cacheable, 0, lastCellIdx-firstCellIdx+1)
for cellIdx := firstCellIdx; cellIdx <= lastCellIdx; cellIdx++ {
cellKeys = append(cellKeys, bc.generateCacheKey(q, cellIdx))
cellEntries = append(cellEntries, &qbtypes.CachedData{})
}
// A cell that could not be read comes back nil like a miss, so the rest
// are still worth serving
if err := bc.cache.GetMany(ctx, orgID, cellKeys, cellEntries); err != nil {
bc.logger.ErrorContext(ctx, "error getting cached data", errors.Attr(err))
}
cachedBuckets := []*qbtypes.CachedBucket{}
cachedWarnings := []string{}
for _, cellEntry := range cellEntries {
if cellEntry == nil {
continue
}
cellData := cellEntry.(*qbtypes.CachedData)
cachedBuckets = append(cachedBuckets, cellData.Buckets...)
cachedWarnings = append(cachedWarnings, cellData.Warnings...)
}
if len(cachedBuckets) == 0 {
return nil, missing
}
// Find missing ranges with step alignment
missing = bc.findMissingRangesWithStep(cachedBuckets, startMs, endMs, stepMs)
bc.logger.DebugContext(ctx, "missing ranges", slog.Any("missing", missing), slog.Uint64("step", stepMs))
// Extract relevant buckets and merge them
relevantBuckets := bc.filterRelevantBuckets(data.Buckets, startMs, endMs)
relevantBuckets := bc.filterRelevantBuckets(cachedBuckets, startMs, endMs)
if len(relevantBuckets) == 0 {
// nothing cached overlaps the window, so all of it has to be fetched.
return nil, []*qbtypes.TimeRange{{From: startMs, To: endMs}}
}
// Merge buckets into a single result
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, data.Warnings)
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings)
// Filter the merged result to only include values within the requested time range
mergedResult = bc.filterResultToTimeRange(mergedResult, q, startMs, endMs, stepMs)
@@ -130,15 +151,6 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
slog.Uint64("cachable_end", cachableEndMs))
}
// Generate cache key
cacheKey := bc.generateCacheKey(q)
// Get existing cached data
var existingData qbtypes.CachedData
if err := bc.cache.Get(ctx, orgID, cacheKey, &existingData); err != nil {
existingData = qbtypes.CachedData{}
}
// Trim the result to exclude data within flux interval
trimmedResult := bc.trimResultToFluxBoundary(fresh, cachableEndMs)
if trimmedResult == nil {
@@ -174,38 +186,44 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
}
}
// Convert trimmed result to buckets with adjusted boundaries
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs)
// Convert trimmed result to buckets, one per grid cell it spans
cellWidthMs := bucketCacheCellSteps * stepMs
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs)
// If no fresh buckets and no existing data, don't cache
if len(freshBuckets) == 0 && len(existingData.Buckets) == 0 {
return
}
// Each cell is its own cache entry, rewritten only when the fresh bucket
// changes what it holds, so an entry's TTL runs from its last new data
for _, freshBucket := range freshBuckets {
cellKey := bc.generateCacheKey(q, deriveCellIdx(freshBucket.StartMs, cellWidthMs))
// Merge with existing buckets
mergedBuckets := bc.mergeAndDeduplicateBuckets(existingData.Buckets, freshBuckets)
var cellEntry qbtypes.CachedData
if err := bc.cache.Get(ctx, orgID, cellKey, &cellEntry); err != nil {
cellEntry = qbtypes.CachedData{}
}
// Update warnings
allWarnings := append(existingData.Warnings, trimmedResult.Warnings...)
uniqueWarnings := bc.deduplicateWarnings(allWarnings)
// Create updated cached data
updatedData := qbtypes.CachedData{
Buckets: mergedBuckets,
Warnings: uniqueWarnings,
}
// Marshal and store in cache
if err := bc.cache.Set(ctx, orgID, cacheKey, &updatedData, bc.cacheTTL); err != nil {
bc.logger.ErrorContext(ctx, "error setting cached data", errors.Attr(err))
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket)
if !changed {
continue
}
cellEntry.Buckets = cellBuckets
cellEntry.Warnings = bc.deduplicateWarnings(append(cellEntry.Warnings, trimmedResult.Warnings...))
if err := bc.cache.Set(ctx, orgID, cellKey, &cellEntry, bc.cacheTTL); err != nil {
bc.logger.ErrorContext(ctx, "error setting cached data", errors.Attr(err))
}
}
}
// generateCacheKey creates a unique cache key based on query fingerprint.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query) string {
fingerprint := q.Fingerprint()
// generateCacheKey names the cache entry holding one grid cell of a query.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query, cellIdx uint64) string {
return fmt.Sprintf("v6:query:%s:cellidx:%d", q.Fingerprint(), cellIdx)
}
return fmt.Sprintf("v5:query:%s", fingerprint)
// deriveCellIdx returns the index of the grid cell holding startMs; with no grid
// every bucket shares cell 0.
func deriveCellIdx(startMs, cellWidthMs uint64) uint64 {
if cellWidthMs == 0 {
return 0
}
return startMs / cellWidthMs
}
// findMissingRangesWithStep identifies time ranges not covered by cached buckets with step alignment.
@@ -646,7 +664,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
}
// resultToBuckets converts a query result into time-based buckets.
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs uint64) []*qbtypes.CachedBucket {
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64) []*qbtypes.CachedBucket {
// Check if result is empty
isEmpty, isFiltered := bc.isEmptyResult(result)
@@ -657,62 +675,74 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
return nil
}
// For now, create a single bucket for the entire range
// In the future, we could split large ranges into smaller buckets
valueBytes, err := json.Marshal(result.Value)
// Empty filtered results still become buckets, so the range is not re-queried
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs)
if err != nil {
bc.logger.ErrorContext(ctx, "failed to marshal result value", errors.Attr(err))
bc.logger.ErrorContext(ctx, "failed to convert result to buckets", errors.Attr(err))
return nil
}
// Always create a bucket, even for empty filtered results
// This ensures we don't re-query for data that doesn't exist
return []*qbtypes.CachedBucket{
{
StartMs: startMs,
EndMs: endMs,
Type: result.Type,
Value: valueBytes,
Stats: result.Stats,
},
}
return perCellBuckets
}
// mergeAndDeduplicateBuckets combines and deduplicates bucket lists.
func (bc *bucketCache) mergeAndDeduplicateBuckets(existing, fresh []*qbtypes.CachedBucket) []*qbtypes.CachedBucket {
// Create a map to deduplicate by time range
bucketMap := make(map[string]*qbtypes.CachedBucket)
// Add existing buckets
for _, bucket := range existing {
key := fmt.Sprintf("%d-%d", bucket.StartMs, bucket.EndMs)
bucketMap[key] = bucket
}
// Add/update with fresh buckets
for _, bucket := range fresh {
key := fmt.Sprintf("%d-%d", bucket.StartMs, bucket.EndMs)
bucketMap[key] = bucket
}
// Convert back to slice with pre-allocated capacity
result := make([]*qbtypes.CachedBucket, 0, len(bucketMap))
for _, bucket := range bucketMap {
result = append(result, bucket)
}
// Sort by start time
slices.SortFunc(result, func(a, b *qbtypes.CachedBucket) int {
if a.StartMs < b.StartMs {
return -1
// mergeFreshBucketIntoCell folds a fresh bucket into the buckets of its cell
// and reports whether the cell changed: a bucket already covering the fresh
// one leaves it as is, buckets inside the fresh one are replaced, buckets
// overlapping or edge to edge with it are unioned, and the rest are kept apart.
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket) ([]*qbtypes.CachedBucket, bool) {
keptBuckets := make([]*qbtypes.CachedBucket, 0, len(cellBuckets)+1)
touchingBuckets := []*qbtypes.CachedBucket{}
for _, cellBucket := range cellBuckets {
switch {
case cellBucket.Contains(freshBucket):
return cellBuckets, false
case cellBucket.Touches(freshBucket):
touchingBuckets = append(touchingBuckets, cellBucket)
default:
keptBuckets = append(keptBuckets, cellBucket)
}
if a.StartMs > b.StartMs {
return 1
}
return 0
}
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets))
slices.SortFunc(keptBuckets, func(a, b *qbtypes.CachedBucket) int {
return cmp.Compare(a.StartMs, b.StartMs)
})
return keptBuckets, true
}
return result
// unionBuckets widens the fresh bucket by the buckets it touches. Buckets
// inside it are replaced outright, so only a genuine partial overlap decodes
// anything.
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket) *qbtypes.CachedBucket {
bucketsToMerge := []*qbtypes.CachedBucket{freshBucket}
for _, touchingBucket := range touchingBuckets {
if !freshBucket.Contains(touchingBucket) {
bucketsToMerge = append(bucketsToMerge, touchingBucket)
}
}
if len(bucketsToMerge) == 1 {
return freshBucket
}
value, err := json.Marshal(bc.mergeTimeSeriesValues(ctx, bucketsToMerge))
if err != nil {
bc.logger.ErrorContext(ctx, "failed to marshal unioned bucket", errors.Attr(err))
return freshBucket
}
unionBucket := &qbtypes.CachedBucket{
StartMs: freshBucket.StartMs,
EndMs: freshBucket.EndMs,
Type: freshBucket.Type,
Value: value,
Stats: qbtypes.ExecStats{StepIntervals: freshBucket.Stats.StepIntervals},
}
for _, mergedBucket := range bucketsToMerge {
unionBucket.StartMs = min(unionBucket.StartMs, mergedBucket.StartMs)
unionBucket.EndMs = max(unionBucket.EndMs, mergedBucket.EndMs)
unionBucket.Stats.RowsScanned += mergedBucket.Stats.RowsScanned
unionBucket.Stats.BytesScanned += mergedBucket.Stats.BytesScanned
unionBucket.Stats.DurationMS += mergedBucket.Stats.DurationMS
}
return unionBucket
}
// deduplicateWarnings removes duplicate warnings.

View File

@@ -2,6 +2,7 @@ package querier
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"testing"
@@ -1465,3 +1466,161 @@ func TestBucketCache_NoCache(t *testing.T) {
// The actual NoCache logic is implemented in querier.run(), not in bucket cache
// This test verifies that the cache works normally and NoCache bypasses it at a higher level
}
func TestBucketCachePutSlicesAtCellBoundaries(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
// a 1s step gives 60s cells, so 30s to 150s spans three of them
query := &mockQuery{fingerprint: "sliced-query", startMs: 30000, endMs: 150000}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 30000, 150000, 1000),
Stats: qbtypes.ExecStats{RowsScanned: 120, BytesScanned: 1200, DurationMS: 12},
})
type bucketShape struct {
cell, startMs, endMs, rowsScanned uint64
valueCount int
}
shapes := []bucketShape{}
for cell := uint64(0); cell < 3; cell++ {
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(query, cell), &cellEntry))
for _, bucket := range cellEntry.Buckets {
var tsData qbtypes.TimeSeriesData
require.NoError(t, json.Unmarshal(bucket.Value, &tsData))
require.Len(t, tsData.Aggregations, 1)
require.Len(t, tsData.Aggregations[0].Series, 1)
shapes = append(shapes, bucketShape{cell, bucket.StartMs, bucket.EndMs, bucket.Stats.RowsScanned, len(tsData.Aggregations[0].Series[0].Values)})
}
}
assert.Equal(t, []bucketShape{
{0, 30000, 60000, 30, 30},
{1, 60000, 120000, 60, 60},
{2, 120000, 150000, 30, 30},
}, shapes)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Equal(t, []*qbtypes.TimeRange{}, missing)
assert.Len(t, cached.Value.(*qbtypes.TimeSeriesData).Aggregations[0].Series[0].Values, 120)
assert.Equal(t, uint64(120), cached.Stats.RowsScanned)
}
func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
type bucketShape struct{ startMs, endMs, rowsScanned uint64 }
testCases := []struct {
name string
firstWindow qbtypes.TimeRange
secondWindow qbtypes.TimeRange
expectedBuckets []bucketShape
expectedMissingRanges []*qbtypes.TimeRange
expectedValueCount int
}{
{
name: "DisjointWrites_StayApart",
firstWindow: qbtypes.TimeRange{From: 1000, To: 3000},
secondWindow: qbtypes.TimeRange{From: 5000, To: 8000},
expectedBuckets: []bucketShape{{1000, 3000, 2}, {5000, 8000, 3}},
expectedMissingRanges: []*qbtypes.TimeRange{{From: 3000, To: 5000}},
expectedValueCount: 5,
},
{
name: "EdgeToEdgeWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 3000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 5000},
expectedBuckets: []bucketShape{{1000, 5000, 4}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 4,
},
{
name: "OverlappingWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 4000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 6000},
expectedBuckets: []bucketShape{{1000, 6000, 6}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 5,
},
{
name: "ContainedWrite_KeepsExistingBucket",
firstWindow: qbtypes.TimeRange{From: 1000, To: 10000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 5000},
expectedBuckets: []bucketShape{{1000, 10000, 9}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 9,
},
{
name: "ContainingWrite_ReplacesExistingBucket",
firstWindow: qbtypes.TimeRange{From: 3000, To: 5000},
secondWindow: qbtypes.TimeRange{From: 1000, To: 10000},
expectedBuckets: []bucketShape{{1000, 10000, 9}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 9,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
for _, window := range []qbtypes.TimeRange{testCase.firstWindow, testCase.secondWindow} {
query := &mockQuery{fingerprint: "absorb-query", startMs: window.From, endMs: window.To}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", window.From, window.To, 1000),
// one row per second, so a bucket's stats read as the seconds it was scanned for
Stats: qbtypes.ExecStats{RowsScanned: (window.To - window.From) / 1000},
})
}
query := &mockQuery{
fingerprint: "absorb-query",
startMs: min(testCase.firstWindow.From, testCase.secondWindow.From),
endMs: max(testCase.firstWindow.To, testCase.secondWindow.To),
}
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(query, 0), &cellEntry))
shapes := make([]bucketShape, 0, len(cellEntry.Buckets))
for _, bucket := range cellEntry.Buckets {
shapes = append(shapes, bucketShape{bucket.StartMs, bucket.EndMs, bucket.Stats.RowsScanned})
}
assert.Equal(t, testCase.expectedBuckets, shapes)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Equal(t, testCase.expectedMissingRanges, missing)
assert.Len(t, cached.Value.(*qbtypes.TimeSeriesData).Aggregations[0].Series[0].Values, testCase.expectedValueCount)
})
}
}
func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
wide := &mockQuery{fingerprint: "unchanged-query", startMs: 1000, endMs: 10000}
bc.Put(ctx, orgID, wide, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 10000, 1000),
Warnings: []string{"from the wide write"},
})
// a write already covered by the cell's bucket must not rewrite the entry,
// which shows as its warnings never arriving
inside := &mockQuery{fingerprint: "unchanged-query", startMs: 3000, endMs: 5000}
bc.Put(ctx, orgID, inside, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 3000, 5000, 1000),
Warnings: []string{"from the inside write"},
})
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(wide, 0), &cellEntry))
assert.Equal(t, []string{"from the wide write"}, cellEntry.Warnings)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"maps"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
)
@@ -33,6 +34,15 @@ func (c *CachedBucket) Clone() *CachedBucket {
}
}
func (c *CachedBucket) Contains(other *CachedBucket) bool {
return c.StartMs <= other.StartMs && other.EndMs <= c.EndMs
}
// true if c and other overlap or sit edge to edge, false otherwise.
func (c *CachedBucket) Touches(other *CachedBucket) bool {
return c.StartMs <= other.EndMs && other.StartMs <= c.EndMs
}
// CachedData represents the full cached data for a query.
type CachedData struct {
Buckets []*CachedBucket `json:"buckets"`
@@ -77,3 +87,53 @@ func (c *CachedData) Cost() int64 {
}
return size
}
func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64) ([]*CachedBucket, error) {
tsData, ok := r.Value.(*TimeSeriesData)
if !ok || tsData == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "result of type %s cannot be bucketed", r.Type.StringValue())
}
cellBuckets := make([]*CachedBucket, 0, 1)
for from := startMs; from < endMs; {
to := endMs
if cellWidthMs > 0 {
to = min(endMs, (from/cellWidthMs+1)*cellWidthMs)
}
// A promql value stamped exactly at the window end is an instant inside
// the window, so the last slice keeps it. A builder value never sits
// there, since the end is step-aligned and partial steps are dropped.
sliceEndMs := to
if to == endMs {
sliceEndMs = endMs + 1
}
value, err := json.Marshal(tsData.FilterToTimeRange(from, sliceEndMs))
if err != nil {
return nil, err
}
cellBuckets = append(cellBuckets, &CachedBucket{
StartMs: from,
EndMs: to,
Type: r.Type,
Value: value,
Stats: r.Stats.prorate(to-from, endMs-startMs),
})
from = to
}
return cellBuckets, nil
}
// prorate returns the share of the stats that partMs out of wholeMs of the
// scanned window accounts for.
func (e ExecStats) prorate(partMs, wholeMs uint64) ExecStats {
ratio := 1.0
if partMs < wholeMs {
ratio = float64(partMs) / float64(wholeMs)
}
return ExecStats{
RowsScanned: uint64(float64(e.RowsScanned) * ratio),
BytesScanned: uint64(float64(e.BytesScanned) * ratio),
DurationMS: uint64(float64(e.DurationMS) * ratio),
StepIntervals: maps.Clone(e.StepIntervals),
}
}

View File

@@ -137,6 +137,28 @@ type TimeSeriesData struct {
Aggregations []*AggregationBucket `json:"aggregations" description:"One bucket per aggregation of the query. The order is not guaranteed; match buckets to aggregations by index."`
}
// FilterToTimeRange copies the aggregation and series structure keeping only
// the values with startMs <= timestamp < endMs.
func (t *TimeSeriesData) FilterToTimeRange(startMs, endMs uint64) *TimeSeriesData {
filtered := &TimeSeriesData{
QueryName: t.QueryName,
Aggregations: make([]*AggregationBucket, 0, len(t.Aggregations)),
}
for _, aggBucket := range t.Aggregations {
filteredBucket := &AggregationBucket{
Index: aggBucket.Index,
Alias: aggBucket.Alias,
Meta: aggBucket.Meta,
Series: make([]*TimeSeries, 0, len(aggBucket.Series)),
}
for _, series := range aggBucket.Series {
filteredBucket.Series = append(filteredBucket.Series, series.filterToTimeRange(startMs, endMs))
}
filtered.Aggregations = append(filtered.Aggregations, filteredBucket)
}
return filtered
}
type AggregationBucket struct {
Index int `json:"index" description:"The zero-based position of the aggregation in the query."`
Alias string `json:"alias" description:"The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given."`
@@ -259,6 +281,21 @@ func (ts *TimeSeries) EvaluableValues() []*TimeSeriesValue {
return result
}
// filterToTimeRange copies the series keeping only the values with
// startMs <= timestamp < endMs.
func (ts *TimeSeries) filterToTimeRange(startMs, endMs uint64) *TimeSeries {
filtered := &TimeSeries{
Labels: ts.Labels,
Values: make([]*TimeSeriesValue, 0, len(ts.Values)),
}
for _, value := range ts.Values {
if timestamp := uint64(value.Timestamp); timestamp >= startMs && timestamp < endMs {
filtered.Values = append(filtered.Values, value)
}
}
return filtered
}
type Label struct {
Key telemetrytypes.TelemetryFieldKey `json:"key" description:"The group-by field this label value belongs to."`
Value any `json:"value" description:"The label value as a string, number or boolean."`

View File

@@ -436,3 +436,97 @@ func TestRoundToNonZeroDecimals(t *testing.T) {
assert.Equal(t, math.Inf(1), roundToNonZeroDecimals(math.Inf(1), 3))
assert.Equal(t, math.Inf(-1), roundToNonZeroDecimals(math.Inf(-1), 3))
}
func TestTimeSeriesDataFilterToTimeRange(t *testing.T) {
testCases := []struct {
name string
timeSeriesData *TimeSeriesData
startMs uint64
endMs uint64
expectedFiltered *TimeSeriesData
}{
{
name: "HalfOpenRange_KeepsStartDropsEnd",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 0,
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{
{Timestamp: 1000, Value: 1},
{Timestamp: 2000, Value: 2},
{Timestamp: 3000, Value: 3},
{Timestamp: 4000, Value: 4},
},
}},
}},
},
startMs: 2000,
endMs: 4000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 0,
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{
{Timestamp: 2000, Value: 2},
{Timestamp: 3000, Value: 3},
},
}},
}},
},
},
{
// heatmap counts are positional against Meta.Buckets, so the axis
// has to survive even when no value falls in the range
name: "NothingInRange_KeepsSeriesAndHeatmapAxis",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 1,
Alias: "__result_1",
Meta: AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{{Timestamp: 1000, Values: []float64{1, 2, 3, 4}}},
}},
}},
},
startMs: 5000,
endMs: 6000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 1,
Alias: "__result_1",
Meta: AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{},
}},
}},
},
},
{
name: "NoSeries_KeepsAggregation",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{Index: 0}},
},
startMs: 1000,
endMs: 2000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{Index: 0, Series: []*TimeSeries{}}},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expectedFiltered, testCase.timeSeriesData.FilterToTimeRange(testCase.startMs, testCase.endMs))
})
}
}