mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-01 22:11:12 +00:00
Compare commits
3 Commits
fix/clickh
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a621fb2b49 | ||
|
|
72443c8267 | ||
|
|
b37231f31a |
@@ -17,7 +17,7 @@ Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` fr
|
||||
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
|
||||
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
|
||||
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
|
||||
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
|
||||
| `langfuse.observation.model.name`, `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.observation.type`, `langfuse.session.id` | Langfuse SDK v3 (Python) and v4 (JS), LiteLLM `langfuse_otel` callback |
|
||||
| `session.id` | OpenInference, Langfuse, Claude Code |
|
||||
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
"antlr4": "4.13.2",
|
||||
"axios": "1.18.0",
|
||||
"axios": "1.21.0",
|
||||
"babel-jest": "^29.6.4",
|
||||
"chart.js": "3.9.1",
|
||||
"chartjs-adapter-date-fns": "^2.0.0",
|
||||
|
||||
12
frontend/pnpm-lock.yaml
generated
12
frontend/pnpm-lock.yaml
generated
@@ -127,8 +127,8 @@ importers:
|
||||
specifier: 4.13.2
|
||||
version: 4.13.2
|
||||
axios:
|
||||
specifier: 1.18.0
|
||||
version: 1.18.0
|
||||
specifier: 1.21.0
|
||||
version: 1.21.0
|
||||
babel-jest:
|
||||
specifier: ^29.6.4
|
||||
version: 29.6.4(@babel/core@7.29.7)
|
||||
@@ -4593,8 +4593,8 @@ packages:
|
||||
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
axios@1.18.0:
|
||||
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
|
||||
axios@1.21.0:
|
||||
resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==}
|
||||
|
||||
babel-jest@29.6.4:
|
||||
resolution: {integrity: sha512-meLj23UlSLddj6PC+YTOFRgDAtjnZom8w/ACsrx0gtPtv5cJZk0A5Unk5bV4wixD7XaPCN1fQvpww8czkZURmw==}
|
||||
@@ -14283,7 +14283,7 @@ snapshots:
|
||||
|
||||
axe-core@4.13.0: {}
|
||||
|
||||
axios@1.18.0:
|
||||
axios@1.21.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.6
|
||||
@@ -20310,7 +20310,7 @@ snapshots:
|
||||
|
||||
wait-on@7.2.0:
|
||||
dependencies:
|
||||
axios: 1.18.0
|
||||
axios: 1.21.0
|
||||
joi: 17.13.7
|
||||
lodash: 4.18.1
|
||||
minimist: 1.2.8
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
@@ -16,7 +16,6 @@ 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';
|
||||
|
||||
@@ -18,11 +18,9 @@ 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,
|
||||
@@ -232,23 +230,6 @@ 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();
|
||||
|
||||
@@ -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 { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
|
||||
@@ -72,26 +72,6 @@ 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) {
|
||||
|
||||
@@ -38,8 +38,7 @@ 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 } from 'utils/app';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { isIngestionActive, isModifierKeyPressed } from 'utils/app';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import crackerUrl from '@/assets/Icons/cracker.svg';
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
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);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
import SavedViewHoverCardDetails from './SavedViewHoverCardDetails';
|
||||
import { SavedViewHoverCardProps } from './types';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
@@ -18,23 +13,11 @@ function SavedViewHoverCard({
|
||||
onCardLeave,
|
||||
onClose,
|
||||
}: SavedViewHoverCardProps): JSX.Element {
|
||||
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');
|
||||
const { allowed: canReadAll } = useAuthZ([SavedViewReadPermission]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!view}
|
||||
open={!!target}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
@@ -55,71 +38,17 @@ function SavedViewHoverCard({
|
||||
onOpenAutoFocus={(event): void => event.preventDefault()}
|
||||
onCloseAutoFocus={(event): void => event.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
{target && (
|
||||
<div
|
||||
className={styles.content}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
data-testid="saved-view-hover-card"
|
||||
>
|
||||
<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>
|
||||
<SavedViewHoverCardDetails
|
||||
viewId={target.viewId}
|
||||
canReadAll={canReadAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
|
||||
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
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 />,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
@@ -54,9 +54,8 @@
|
||||
&.isActive {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.rowName {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
.rowLink {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,18 +75,32 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
.rowLink {
|
||||
display: flex;
|
||||
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 {
|
||||
|
||||
@@ -65,11 +65,11 @@ function SavedViewsList({
|
||||
|
||||
const rowHover = useMemo<SavedViewsListRowHoverProps>(
|
||||
() => ({
|
||||
onRowEnter: (view, row): void => {
|
||||
onRowEnter: (viewId, row): void => {
|
||||
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
onHoverCardRowEnter({
|
||||
view,
|
||||
viewId,
|
||||
anchorTop: rowRect.top - listTop,
|
||||
anchorHeight: rowRect.height,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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';
|
||||
|
||||
@@ -18,42 +16,17 @@ 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 => {
|
||||
if (canRead) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void =>
|
||||
hover.onRowEnter(view.id, event.currentTarget)
|
||||
}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<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>
|
||||
<SavedViewsRowLink view={view} isActive={isActive} onSelect={onSelect} />
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
icon={<Undo2 size={14} />}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
SavedViewListPermission,
|
||||
SavedViewReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
@@ -21,6 +25,8 @@ 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
|
||||
|
||||
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
@@ -8,6 +8,8 @@ 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';
|
||||
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
@@ -139,9 +141,11 @@ function advance(ms: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
function openCard(name: string): void {
|
||||
// Resolves once the card has fetched its view.
|
||||
async function openCard(name: string): Promise<HTMLElement> {
|
||||
fireEvent.mouseEnter(row(name));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
return screen.findByTestId('saved-view-hover-card-query');
|
||||
}
|
||||
|
||||
describe('SavedViewHoverCard', () => {
|
||||
@@ -168,7 +172,8 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(1);
|
||||
expect(card()).toHaveTextContent('Errors');
|
||||
expect(card()).toBeInTheDocument();
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Errors'));
|
||||
});
|
||||
|
||||
it('does not open when the pointer leaves before the delay', async () => {
|
||||
@@ -184,7 +189,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('stays open while the pointer moves into the card', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(card() as HTMLElement);
|
||||
@@ -198,7 +203,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('closes on leaving the row and reopens for the next row after the delay', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(row('Slow spans'));
|
||||
@@ -206,12 +211,12 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
expect(card()).toHaveTextContent('Slow spans');
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Slow spans'));
|
||||
});
|
||||
|
||||
it('closes when the row menu is pressed', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
|
||||
@@ -222,19 +227,17 @@ describe('SavedViewHoverCard', () => {
|
||||
describe('content', () => {
|
||||
it('shows the filter, the updater and the update time', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
const query = await openCard('Errors');
|
||||
|
||||
const content = card() as HTMLElement;
|
||||
expect(
|
||||
within(content).getByTestId('saved-view-hover-card-query'),
|
||||
).toHaveTextContent('has_error = true');
|
||||
expect(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();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
@@ -243,9 +246,8 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('names each query when there are several, and copies them per line', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors and slow');
|
||||
const query = await 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'));
|
||||
@@ -257,21 +259,74 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('says there are no filters, with nothing to copy', async () => {
|
||||
await renderPanel();
|
||||
openCard('Everything');
|
||||
const query = await openCard('Everything');
|
||||
|
||||
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
|
||||
'No filters',
|
||||
);
|
||||
expect(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);
|
||||
openCard('Request rate');
|
||||
const query = await 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -9,34 +13,42 @@ 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, {})): void {
|
||||
renderWithExplorerProviders(
|
||||
function renderPanel(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
@@ -68,6 +80,37 @@ 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(
|
||||
@@ -98,21 +141,51 @@ describe('SavedViews - AuthZ', () => {
|
||||
expect(screen.queryByTestId('saved-views-search')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the views it cannot read', async () => {
|
||||
it('runs no read check per row', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
const checks = recordChecks();
|
||||
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 nameOf = (view: string): HTMLElement =>
|
||||
within(rowNamed(view)).getByTestId('saved-views-row-name');
|
||||
const name = within(rowNamed('Errors')).getByTestId('saved-views-row-name');
|
||||
expectUsable(name);
|
||||
await userEvent.click(name);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('keeps the row menu on a view it cannot read', async () => {
|
||||
@@ -133,6 +206,58 @@ 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));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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';
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
@@ -53,6 +59,9 @@ 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();
|
||||
@@ -85,9 +94,8 @@ describe('SavedViewsPanel list', () => {
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
await waitFor(() => expectUsable(name));
|
||||
await userEvent.click(name);
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
@@ -96,6 +104,60 @@ 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));
|
||||
@@ -120,9 +182,8 @@ describe('SavedViewsPanel list', () => {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
expect(rowName('Errors')).toHaveAttribute('aria-current', 'page');
|
||||
await userEvent.click(rowName('Errors'));
|
||||
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
@@ -32,6 +34,8 @@ 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({
|
||||
@@ -119,9 +123,22 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
it('copies a link that opens the view with its query, tab and time range', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
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, {}),
|
||||
);
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy link');
|
||||
|
||||
@@ -137,6 +154,9 @@ 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());
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ 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';
|
||||
@@ -160,6 +161,23 @@ 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).
|
||||
|
||||
@@ -12,11 +12,10 @@ 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,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from '../constants';
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getSavedViewPath } from '../utils/getSavedViewPath';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
@@ -43,6 +42,7 @@ 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,17 +52,15 @@ export function useSavedViewRowActions(
|
||||
|
||||
const copyViewLink = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
copyToClipboard(
|
||||
getAbsoluteUrl(getSavedViewPath(pathname, view, timeSearch)),
|
||||
);
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
[pathname, timeSearch, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
savedViewsListResponse,
|
||||
type SavedViewSource,
|
||||
unreadableViewId,
|
||||
withLongName,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -73,7 +74,13 @@ const controls = {
|
||||
unreadableView: toggleControl('Unreadable view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'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.',
|
||||
'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.',
|
||||
value: false,
|
||||
}),
|
||||
createdByMe: countControl('Created by me', {
|
||||
@@ -87,6 +94,12 @@ 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>;
|
||||
@@ -99,7 +112,7 @@ const viewDenied = (
|
||||
): boolean =>
|
||||
transaction.relation === 'read' &&
|
||||
transaction.object.resource.kind === 'saved-view' &&
|
||||
transaction.object.selector === deniedId;
|
||||
[deniedId, '*'].includes(transaction.object.selector);
|
||||
|
||||
const definition = defineStoryMocks({
|
||||
controls,
|
||||
@@ -107,7 +120,14 @@ const definition = defineStoryMocks({
|
||||
values: SavedViewsValues & AuthzArgs,
|
||||
response,
|
||||
): RequestHandler[] => {
|
||||
const { source, openView, createdByMe, createdByOthers } = values;
|
||||
const {
|
||||
source,
|
||||
openView,
|
||||
viewPending,
|
||||
createdByMe,
|
||||
createdByOthers,
|
||||
longNames,
|
||||
} = values;
|
||||
|
||||
// What the panel writes stays in this handler set until a control changes.
|
||||
const created: SavedviewtypesSavedViewDTO[] = [];
|
||||
@@ -118,8 +138,14 @@ 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 view = created.find((item) => item.id === id) ?? savedViewById(id);
|
||||
const stored = savedViewById(id);
|
||||
const view =
|
||||
created.find((item) => item.id === id) ?? (stored && named(stored));
|
||||
|
||||
return view && !deleted.has(id) ? current(view) : undefined;
|
||||
};
|
||||
@@ -143,7 +169,7 @@ const definition = defineStoryMocks({
|
||||
|
||||
return {
|
||||
...list,
|
||||
data: [...(list.data ?? []), ...created]
|
||||
data: [...(list.data ?? []).map(named), ...created]
|
||||
.filter((view) => !deleted.has(view.id))
|
||||
.map(current),
|
||||
};
|
||||
@@ -151,6 +177,10 @@ 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));
|
||||
}
|
||||
@@ -274,7 +304,9 @@ const isPending = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('data-loading');
|
||||
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
|
||||
element.hasAttribute('disabled') ||
|
||||
element.hasAttribute('data-disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
export const expectDisabled = (
|
||||
find: FindElement,
|
||||
@@ -302,6 +334,30 @@ 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);
|
||||
@@ -327,3 +383,115 @@ 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();
|
||||
};
|
||||
|
||||
@@ -6,10 +6,17 @@ 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';
|
||||
|
||||
@@ -78,3 +85,48 @@ 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,
|
||||
};
|
||||
|
||||
@@ -209,6 +209,18 @@ 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 });
|
||||
|
||||
@@ -4,14 +4,15 @@ 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,
|
||||
@@ -99,21 +100,18 @@ export const NoCreateSaveMenu: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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.
|
||||
*/
|
||||
export const NoRead: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
play: openUnreadableView,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
|
||||
),
|
||||
);
|
||||
},
|
||||
/** The hover card of the unreadable view: the permission callout in place of its query. */
|
||||
export const NoReadHoverCard: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: hoverUnreadableRow,
|
||||
};
|
||||
|
||||
/** The unreadable view open from its link: the header names it No view access. */
|
||||
|
||||
@@ -128,6 +128,11 @@ export interface SavedViewsListRowProps {
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewsRowLinkProps = Pick<
|
||||
SavedViewsListRowProps,
|
||||
'view' | 'isActive' | 'onSelect'
|
||||
>;
|
||||
|
||||
export type SavedViewRowActionKey =
|
||||
| 'copyLink'
|
||||
| 'copyId'
|
||||
@@ -181,7 +186,7 @@ export interface UseSavedViewRowActionsResult {
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardTarget {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
viewId: string;
|
||||
anchorTop: number;
|
||||
anchorHeight: number;
|
||||
}
|
||||
@@ -202,8 +207,14 @@ 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: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
|
||||
onRowEnter: (viewId: string, row: HTMLElement) => void;
|
||||
onRowLeave: () => void;
|
||||
onMenuOpen: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useTimeSearchParams } from '../useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
@@ -12,12 +12,13 @@ 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';
|
||||
|
||||
@@ -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 {
|
||||
@@ -6,6 +6,7 @@ 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}`);
|
||||
|
||||
@@ -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 './useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardDetailEvents } from '../../constants/events';
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
parseNewPanelTarget,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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}=`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../timeUrlParams';
|
||||
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
|
||||
|
||||
describe('timeParamsFromGlobalTime', () => {
|
||||
it('emits relativeTime for a relative selection', () => {
|
||||
22
frontend/src/utils/buildExplorerNavigationUrl.ts
Normal file
22
frontend/src/utils/buildExplorerNavigationUrl.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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()}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"definition": {
|
||||
"name": "gen_ai.llm",
|
||||
"condition": {
|
||||
@@ -134,6 +134,12 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "langfuse.observation.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "openinference.span.kind",
|
||||
"context": "attribute",
|
||||
@@ -356,6 +362,12 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "langfuse.observation.input",
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.prompt",
|
||||
"context": "attribute",
|
||||
@@ -382,6 +394,12 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "langfuse.observation.output",
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.completion",
|
||||
"context": "attribute",
|
||||
|
||||
@@ -20,9 +20,6 @@ var (
|
||||
ErrCodeUnknownIdentifier = errors.MustNewCode("unknown_identifier")
|
||||
ErrCodeIllegalArgument = errors.MustNewCode("illegal_argument")
|
||||
|
||||
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
|
||||
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
|
||||
|
||||
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
|
||||
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
|
||||
ErrCodeExecutionFailed = errors.MustNewCode("execution_failed")
|
||||
@@ -75,18 +72,6 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
|
||||
chproto.ErrTooLessArgumentsForFunction: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "too few arguments to function: %s", ex.Message)
|
||||
},
|
||||
chproto.ErrBadGet: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "parameter of the wrong type: %s", ex.Message).
|
||||
WithSuggestions("write numeric function parameters in the type the function expects, for example 1.0 rather than 1 where it takes a Float64")
|
||||
},
|
||||
chproto.ErrDistributedInJoinSubqueryDenied: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeDistributedSubqueryDenied, "IN or JOIN subquery over a distributed table is not allowed: %s", ex.Message).
|
||||
WithSuggestions("use GLOBAL IN or GLOBAL JOIN for the subquery")
|
||||
},
|
||||
chproto.ErrDecimalOverflow: func(cause error, ex *clickhouse.Exception) error {
|
||||
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
|
||||
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
|
||||
},
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
|
||||
@@ -121,10 +121,13 @@ func (f *formatting) messages() {
|
||||
|
||||
// OpenRouter's answer object or an Anthropic response beside the OpenAI chat request it answers is one
|
||||
// format, not a mix: an Anthropic request without system and without Anthropic-only blocks reads as an
|
||||
// OpenAI chat request.
|
||||
// OpenAI chat request. A side kept as generic already warned, and a side read from OpenInference's
|
||||
// buckets beside the raw payload of the other is the documented fallback.
|
||||
func (f *formatting) warnMixedFormats(inputLabel, outputLabel string) {
|
||||
switch {
|
||||
case inputLabel == "" || outputLabel == "" || outputLabel == inputLabel:
|
||||
case inputLabel == FormatterGeneric || outputLabel == FormatterGeneric:
|
||||
case inputLabel == FormatterOpenInference || outputLabel == FormatterOpenInference:
|
||||
case inputLabel == FormatterOpenAIChat && (outputLabel == FormatterOpenRouter || outputLabel == FormatterAnthropic):
|
||||
default:
|
||||
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
|
||||
@@ -183,11 +186,16 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
|
||||
return f.semconv(v), FormatterSemconv
|
||||
case isAnthropicMessages(v):
|
||||
return f.anthropicMessages(v), FormatterAnthropic
|
||||
case isResponsesItems(v):
|
||||
return foldAssistant(f.responsesItems(v)), FormatterOpenAIResponses
|
||||
case first["role"] != nil:
|
||||
return f.openAIChatMessages(v), FormatterOpenAIChat
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if inner, ok := superJSON(v); ok {
|
||||
return f.side(decode(inner), role)
|
||||
}
|
||||
switch {
|
||||
case isLangChainRun(v):
|
||||
return f.langChainRun(v), FormatterLangChain
|
||||
@@ -214,6 +222,9 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
|
||||
if messages, ok := v["messages"].([]any); ok {
|
||||
return f.openAIChatMessages(messages), FormatterOpenAIChat
|
||||
}
|
||||
if (v["role"] != nil && hasKeys(v, "content")) || v["tool_calls"] != nil {
|
||||
return OutputMessages{f.openAIChatSingle(v)}, FormatterOpenAIChat
|
||||
}
|
||||
case string:
|
||||
if v == "" {
|
||||
return OutputMessages{}, FormatterText
|
||||
@@ -309,6 +320,23 @@ func foldAssistant(msgs OutputMessages) OutputMessages {
|
||||
return out
|
||||
}
|
||||
|
||||
// superJSON unwraps {json, meta?}, the envelope superjson, the transformer tRPC apps serialise with,
|
||||
// puts around a value; a Langfuse generation records it around the request when the app passes the
|
||||
// serialised form:
|
||||
// https://github.com/flightcontrolhq/superjson/blob/aaa65e36e8e31da740265a56b27c965ca1c7b754/README.md
|
||||
func superJSON(m map[string]any) (any, bool) {
|
||||
inner, ok := m["json"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for key := range m {
|
||||
if key != "json" && key != "meta" {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return inner, true
|
||||
}
|
||||
|
||||
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
|
||||
// Anything that is not JSON is returned as is.
|
||||
func decode(value any) any {
|
||||
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, or a bare message list.
|
||||
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records.
|
||||
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, a bare message list, or a
|
||||
// single message.
|
||||
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records, the Langfuse SDK.
|
||||
// Handled: string and block content, tool_calls nested or flattened, tool messages, refusal,
|
||||
// reasoning_content, finish reasons.
|
||||
// Not yet: streaming delta chunks, legacy completions choices[].text, audio output.
|
||||
@@ -88,6 +89,19 @@ func (f *formatting) openAIChatMessage(m map[string]any) OutputMessage {
|
||||
return msg
|
||||
}
|
||||
|
||||
// openAIChatSingle converts a message recorded outside a list: the first choice's message the Langfuse
|
||||
// OpenAI wrapper writes as the output, or the additional_kwargs of a LangChain AIMessage, {tool_calls}
|
||||
// without a role, which the Langfuse LangChain handler writes for a tool-calling turn:
|
||||
// https://github.com/langfuse/langfuse-python/blob/18baf80450dd5f9c1f85916eb2d1b3c72d2052e1/langfuse/openai.py#L446
|
||||
// https://github.com/langfuse/langfuse-python/blob/18baf80450dd5f9c1f85916eb2d1b3c72d2052e1/langfuse/langchain/CallbackHandler.py#L1558
|
||||
func (f *formatting) openAIChatSingle(m map[string]any) OutputMessage {
|
||||
msg := f.openAIChatMessage(m)
|
||||
if msg.Role.IsZero() {
|
||||
msg.Role = RoleAssistant
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// toolCallPart reads {id, function: {name, arguments}}; a gateway may flatten it to {id, name, arguments|args}.
|
||||
func toolCallPart(c map[string]any) Part {
|
||||
call := ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(c["id"]), Name: stringOf(c["name"])}
|
||||
|
||||
@@ -205,6 +205,30 @@ func TestFormat_OpenAIChat(t *testing.T) {
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// Langfuse SDK generation: the history as a list, the first choice's message as the output
|
||||
name: "SingleMessageOutput_AssistantMessage",
|
||||
input: `[{"role": "system", "content": "You are a support agent on a phone call."}, {"role": "user", "content": "Hello?"}]`,
|
||||
output: `{"role": "assistant", "content": "Hi, this is a courtesy call about your order. Do you have a minute?"}`,
|
||||
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "You are a support agent on a phone call."}]}, {"role": "user", "parts": [{"type": "text", "content": "Hello?"}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hi, this is a courtesy call about your order. Do you have a minute?"}]}]`,
|
||||
},
|
||||
{
|
||||
// a structured output that happens to carry a role key is not a message
|
||||
name: "ObjectWithRoleKeyButNoContent_KeptAsGeneric",
|
||||
output: `{"role": "admin", "permissions": ["read", "write"]}`,
|
||||
warnings: []string{"message format not recognised, kept as generic"},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"permissions\":[\"read\",\"write\"],\"role\":\"admin\"}"}]}]`,
|
||||
},
|
||||
{
|
||||
// Langfuse LangChain handler: the additional_kwargs of the AIMessage, without a role, as the output
|
||||
// of a tool-calling turn
|
||||
name: "RolelessToolCallsOutput_AssistantToolCallPart",
|
||||
output: `{"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Oslo\"}"}}], "refusal": null}`,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Oslo"}}]}]`,
|
||||
},
|
||||
{
|
||||
name: "ResponseWithScalarChoice_KeptAsGeneric",
|
||||
output: `{"choices":["oops",{"finish_reason":"stop","index":1,"message":{"role":"assistant","content":"hi"}}]}`,
|
||||
|
||||
@@ -2,13 +2,15 @@ package aiobservabilitytypes
|
||||
|
||||
import "strings"
|
||||
|
||||
// Format: OpenAI Responses, a request {input, instructions} or a response {output, status} of items:
|
||||
// Format: OpenAI Responses, a request {input, instructions}, a response {output, status} or a bare list
|
||||
// of items:
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L47597
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53834
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57596
|
||||
// Written by: gateways recording raw Responses bodies, and the raw calls OpenInference records for the
|
||||
// Responses API and the OpenAI Agents SDK when its flattened messages are absent.
|
||||
// Written by: gateways recording raw Responses bodies, the raw calls OpenInference records for the
|
||||
// Responses API and the OpenAI Agents SDK when its flattened messages are absent, and LiteLLM's
|
||||
// langfuse_otel callback, the function_call items of a tool-calling turn as a bare list.
|
||||
// Handled: message items with input_text, output_text, refusal, input_image and input_file content;
|
||||
// function_call and custom_tool_call with their outputs; reasoning summaries; compaction; every other
|
||||
// *_call item as a server tool call and *_output item as its response; the status and incomplete_details
|
||||
@@ -32,6 +34,19 @@ func isResponsesRequest(m map[string]any) bool {
|
||||
return isItem
|
||||
}
|
||||
|
||||
// isResponsesItems looks for an item only the Responses API has, a tool call or its output, a reasoning
|
||||
// or compaction item, all without a role; a list of messages has none.
|
||||
func isResponsesItems(list []any) bool {
|
||||
for _, item := range list {
|
||||
m, _ := item.(map[string]any)
|
||||
typ := stringOf(m["type"])
|
||||
if m["role"] == nil && (typ == "reasoning" || typ == "compaction" || typ == "mcp_approval_response" || strings.HasSuffix(typ, "_call") || strings.HasSuffix(typ, "_output")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isResponsesResponse(m map[string]any) bool {
|
||||
if _, ok := m["output"].([]any); !ok {
|
||||
return false
|
||||
|
||||
@@ -73,6 +73,23 @@ func TestFormat_OpenAIResponses(t *testing.T) {
|
||||
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Be brief."}]}, {"role": "user", "parts": [{"type": "text", "content": "Say hi"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// LiteLLM langfuse_otel: the function_call items of a tool-calling turn as a bare list
|
||||
name: "BareFunctionCallItems_OneAssistantMessage",
|
||||
output: `[{"id": "chatcmpl-1", "name": "set_stage", "call_id": "call_7", "type": "function_call", "arguments": {"fields": {"stage": "consent"}}}]`,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "set_stage", "id": "call_7", "arguments": {"fields": {"stage": "consent"}}}]}]`,
|
||||
},
|
||||
{
|
||||
name: "BareItemsWithMessage_ToolRound",
|
||||
input: `[{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Weather in Oslo?"}]}, {"type": "function_call", "call_id": "call_9", "name": "get_weather", "arguments": "{\"city\":\"Oslo\"}"}, {"type": "function_call_output", "call_id": "call_9", "output": "4C, cloudy"}]`,
|
||||
wantInput: `[
|
||||
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
|
||||
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_9", "arguments": {"city": "Oslo"}}]},
|
||||
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_9", "response": "4C, cloudy"}]}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// openinference capture, CreateEmbeddings span: an embeddings request also has input and model
|
||||
name: "EmbeddingsRequest_NotResponses_KeptAsGeneric",
|
||||
|
||||
@@ -4,8 +4,9 @@ import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
// Format: the flattened llm.input_messages.N.message.* and llm.output_messages.N.message.* keys, the
|
||||
// provider-neutral copy of the conversation the OpenInference instrumentations write on LLM spans. A side
|
||||
// without them falls back to the raw provider payload the mapper moved onto gen_ai.*.messages; pipecat
|
||||
// streams the reply into output.value alone.
|
||||
// without them, or whose buckets hold no message, falls back to the raw provider payload the mapper moved
|
||||
// onto gen_ai.*.messages; pipecat streams the reply into output.value alone, LiteLLM's OpenInference mode
|
||||
// leaves the output bucket empty on a tool-calling turn.
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L77
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L35
|
||||
@@ -28,15 +29,14 @@ func (f *formatting) openInference() bool {
|
||||
return false
|
||||
}
|
||||
inputLabel, outputLabel := FormatterOpenInference, FormatterOpenInference
|
||||
if len(input) > 0 {
|
||||
f.out.Input = inputMessages(openInferenceMessages(input))
|
||||
if msgs := openInferenceMessages(input); len(msgs) > 0 {
|
||||
f.out.Input = inputMessages(msgs)
|
||||
} else if raw := attribute(f.attributes, GenAIInputMessages); raw != nil {
|
||||
var msgs OutputMessages
|
||||
msgs, inputLabel = f.side(decode(raw), RoleUser)
|
||||
f.out.Input = inputMessages(msgs)
|
||||
}
|
||||
if len(output) > 0 {
|
||||
f.out.Output = openInferenceMessages(output)
|
||||
if msgs := openInferenceMessages(output); len(msgs) > 0 {
|
||||
f.out.Output = msgs
|
||||
if fr := finishReason(stringOf(attribute(f.attributes, openInferenceFinishReason))); fr != nil {
|
||||
for i := range f.out.Output {
|
||||
f.out.Output[i].FinishReason = fr
|
||||
|
||||
@@ -160,16 +160,30 @@ func TestFormat_OpenInference(t *testing.T) {
|
||||
// which the mapper moves onto gen_ai.output.messages
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_attributes.py#L430
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_observer.py#L628
|
||||
name: "OutputKeysAbsent_RawOutputFormatted_Warns",
|
||||
name: "OutputKeysAbsent_RawOutputFormatted",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "user",
|
||||
"llm.input_messages.0.message.content": "hi",
|
||||
GenAIOutputMessages: "Hello there.",
|
||||
},
|
||||
warnings: []string{"bare assistant text, role assumed", "output formatted as text, input as openinference"},
|
||||
warnings: []string{"bare assistant text, role assumed"},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello there."}]}]`,
|
||||
},
|
||||
{
|
||||
// LiteLLM's OpenInference mode leaves the output bucket empty on a tool-calling turn; the calls are
|
||||
// the Langfuse output the mapper moved onto gen_ai.output.messages
|
||||
name: "EmptyOutputBucket_RawOutputFormatted",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "user",
|
||||
"llm.input_messages.0.message.content": "Yes, go ahead.",
|
||||
"llm.output_messages.0.message.role": "assistant",
|
||||
"llm.output_messages.0.message.content": "",
|
||||
GenAIOutputMessages: `[{"id": "chatcmpl-2", "name": "set_stage", "call_id": "call_8", "type": "function_call", "arguments": {"fields": {"stage": "email"}}}]`,
|
||||
},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Yes, go ahead."}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "set_stage", "id": "call_8", "arguments": {"fields": {"stage": "email"}}}]}]`,
|
||||
},
|
||||
{
|
||||
// message.role left unset by some instrumentors: tool_call_id, tool_calls or neither decide it,
|
||||
// as the collector-contrib normalizer does
|
||||
|
||||
@@ -122,6 +122,31 @@ So, the ball costs 5 cents.`,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// Langfuse SDK generation: the app passes the request through superjson, the output is the parsed
|
||||
// structured output
|
||||
name: "SuperJSONEnvelope_Unwrapped_StructuredOutputGeneric",
|
||||
input: `{"json": {"model": "gpt-4o-mini", "messages": [{"role": "system", "content": "Extract the main article of the page as JSON."}, {"role": "user", "content": "# Council approves new bike lanes"}], "temperature": 0}}`,
|
||||
output: `{"hasArticle": true, "article": {"title": "Council approves new bike lanes"}}`,
|
||||
warnings: []string{"message format not recognised, kept as generic"},
|
||||
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Extract the main article of the page as JSON."}]}, {"role": "user", "parts": [{"type": "text", "content": "# Council approves new bike lanes"}]}]`,
|
||||
wantOutput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"article\":{\"title\":\"Council approves new bike lanes\"},\"hasArticle\":true}"}]}]`,
|
||||
},
|
||||
{
|
||||
name: "SuperJSONEnvelopeWithMeta_Unwrapped",
|
||||
input: `{"json": {"messages": [{"role": "user", "content": "What time is it in Oslo?"}]}, "meta": {"values": {"messages.0.at": ["Date"]}}}`,
|
||||
output: `{"json": {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "It is 14:05 in Oslo."}}]}, "meta": {"values": {}}}`,
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What time is it in Oslo?"}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "It is 14:05 in Oslo."}], "finish_reason": "stop"}]`,
|
||||
},
|
||||
{
|
||||
// a json key beside others is a payload of its own, not the envelope
|
||||
name: "JSONKeyBesideOthers_NotUnwrapped_Generic",
|
||||
input: `{"json": {"messages": []}, "schema": "v1"}`,
|
||||
warnings: []string{"message format not recognised, kept as generic"},
|
||||
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"json\":{\"messages\":[]},\"schema\":\"v1\"}"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
name: "NoMessageAttributes_EmptyLists",
|
||||
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},
|
||||
|
||||
Reference in New Issue
Block a user