mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-18 08:00:41 +01:00
Compare commits
5 Commits
feat/trace
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5afc3c1439 | ||
|
|
dccd75e1de | ||
|
|
4f3e7fa50e | ||
|
|
531504c523 | ||
|
|
b770c249a0 |
@@ -104,6 +104,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -25008,6 +25008,75 @@ paths:
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/clone:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new alert rule from the stored definition
|
||||
of an existing one. The clone keeps every modeled field of the source, including
|
||||
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
|
||||
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
|
||||
clone is owned by the caller.
|
||||
operationId: CloneRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Clone alert rule
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/history/filter_keys:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -423,7 +423,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
func (r *AnomalyRule) String() string {
|
||||
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: r.Name(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
target := 500.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data",
|
||||
AlertName: valuer.NewDisplayName("Test anomaly no data"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -149,7 +149,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
target := 500.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data with AbsentFor",
|
||||
AlertName: valuer.NewDisplayName("Test anomaly no data with AbsentFor"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
|
||||
@@ -118,7 +119,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
parsedRule := opts.Rule
|
||||
var alertname = parsedRule.AlertName
|
||||
var alertname = parsedRule.AlertName.StringValue()
|
||||
if alertname == "" {
|
||||
// alertname is not mandatory for testing, so picking
|
||||
// a random string here
|
||||
@@ -126,7 +127,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
// append name to indicate this is test alert
|
||||
parsedRule.AlertName = fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix)
|
||||
parsedRule.AlertName = valuer.NewDisplayName(fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix))
|
||||
|
||||
var rule baserules.Rule
|
||||
var err error
|
||||
|
||||
@@ -142,7 +142,7 @@ const preview: Preview = {
|
||||
'Logs',
|
||||
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', ['Overview', 'Thread'], 'Funnel Details'],
|
||||
['Explorer', 'Trace Details', 'Funnel Details'],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
|
||||
@@ -9,12 +9,14 @@ describe('ErrorResponseHandler', () => {
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
});
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toStrictEqual(
|
||||
{
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
},
|
||||
);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CloneRuleByID201,
|
||||
CloneRuleByIDPathParameters,
|
||||
CreateRule201,
|
||||
CreateRuleView201,
|
||||
DeleteRuleByIDPathParameters,
|
||||
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
|
||||
> => {
|
||||
return useMutation(getUpdateRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller.
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const cloneRuleByID = (
|
||||
{ id }: CloneRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CloneRuleByID201>({
|
||||
url: `/api/v2/rules/${id}/clone`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCloneRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['cloneRuleByID'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
{ pathParams: CloneRuleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return cloneRuleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CloneRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>
|
||||
>;
|
||||
|
||||
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const useCloneRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCloneRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns distinct label keys from rule history entries for the selected range.
|
||||
* @summary Get rule history filter keys
|
||||
|
||||
@@ -14612,6 +14612,17 @@ export type PatchRuleByID200 = {
|
||||
export type UpdateRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByID201 = {
|
||||
data: RuletypesRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetRuleHistoryFilterKeysPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -65,21 +65,6 @@ function Code({
|
||||
);
|
||||
}
|
||||
|
||||
// Untrusted images become links so viewing a message never fetches a third-party URL.
|
||||
function ImageAsLink({
|
||||
src,
|
||||
alt,
|
||||
}: {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
{alt || src}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Link({ href, children }: LinkProps): JSX.Element {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
@@ -116,22 +101,19 @@ function MarkdownRenderer({
|
||||
trackCopyAction,
|
||||
elementDetails,
|
||||
className,
|
||||
isTrusted = true,
|
||||
}: {
|
||||
markdownContent: any;
|
||||
variables: any;
|
||||
trackCopyAction?: boolean;
|
||||
elementDetails?: Record<string, unknown>;
|
||||
className?: string;
|
||||
// False for untrusted content (e.g. telemetry): HTML renders as text, images as links.
|
||||
isTrusted?: boolean;
|
||||
}): JSX.Element {
|
||||
const interpolatedMarkdown = interpolateMarkdown(markdownContent, variables);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={className}
|
||||
rehypePlugins={isTrusted ? [rehypeRaw as any] : []}
|
||||
rehypePlugins={[rehypeRaw as any]}
|
||||
components={{
|
||||
// @ts-expect-error
|
||||
a: Link,
|
||||
@@ -143,7 +125,6 @@ function MarkdownRenderer({
|
||||
}),
|
||||
code: Code,
|
||||
customtag: CustomTag,
|
||||
...(isTrusted ? {} : { img: ImageAsLink }),
|
||||
}}
|
||||
>
|
||||
{interpolatedMarkdown}
|
||||
@@ -154,7 +135,6 @@ function MarkdownRenderer({
|
||||
MarkdownRenderer.defaultProps = {
|
||||
elementDetails: {},
|
||||
trackCopyAction: false,
|
||||
isTrusted: true,
|
||||
};
|
||||
|
||||
export { Code, Link, MarkdownRenderer, Pre };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
@@ -197,6 +197,11 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
|
||||
'aria-busy',
|
||||
),
|
||||
);
|
||||
await user.click(screen.getByTestId('saved-views-open'));
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
@@ -227,6 +232,11 @@ describe('QuickFiltersLayout', () => {
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
|
||||
'aria-busy',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
|
||||
'data-source',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { formUrlParams } from 'utils/traceUtils';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
@@ -21,7 +20,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
}
|
||||
|
||||
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
|
||||
tab: TraceDetailsTab.Thread,
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -45,10 +44,7 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={{
|
||||
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
|
||||
search: `?tab=${TraceDetailsTab.Thread}`,
|
||||
}}
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -339,6 +339,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
@@ -422,7 +423,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -432,6 +433,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Logs,
|
||||
Receipt,
|
||||
Shield,
|
||||
TowerControl,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
@@ -95,6 +96,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type quick filter ID, separate multiple with comma or space',
|
||||
docsAnchor: 'quick-filter',
|
||||
},
|
||||
'saved-view': {
|
||||
label: 'Saved Views',
|
||||
description: 'Saved views in the logs, traces, metrics and meter explorers.',
|
||||
icon: TowerControl,
|
||||
selectorPlaceholder:
|
||||
'Type saved view ID, separate multiple with comma or space',
|
||||
docsAnchor: 'saved-view',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
import {
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
function SaveChangesMenu({
|
||||
viewId,
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const items = useMemo<DropdownItemType[]>(
|
||||
const items = useMemo<AuthZDropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'save-as-new',
|
||||
label: 'Save as new view',
|
||||
checks: [SavedViewCreatePermission],
|
||||
onClick: onSaveAsNew,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'update',
|
||||
label: 'Update selected view',
|
||||
checks: [buildSavedViewUpdatePermission(viewId)],
|
||||
onClick: onUpdate,
|
||||
},
|
||||
],
|
||||
[onSaveAsNew, onUpdate],
|
||||
[viewId, onSaveAsNew, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
nativeButton
|
||||
align="end"
|
||||
side="bottom"
|
||||
disabled={disabled}
|
||||
disabledTooltip="Saving the view"
|
||||
testId="saved-views-save-changes"
|
||||
>
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
@@ -43,7 +52,7 @@ function SaveChangesMenu({
|
||||
color="warning"
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</Dropdown>
|
||||
</AuthZDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { SavedViewCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SavedViewsEmptyStateProps } from './types';
|
||||
@@ -31,7 +32,8 @@ function SavedViewsEmptyState({
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Save the current view to open it later.
|
||||
</Typography.Text>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[SavedViewCreatePermission]}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
@@ -40,7 +42,7 @@ function SavedViewsEmptyState({
|
||||
testId="saved-views-list-create"
|
||||
>
|
||||
Create view
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
{isModalOpen && (
|
||||
<SaveViewModal
|
||||
mode="create"
|
||||
|
||||
@@ -5,9 +5,17 @@ import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildSavedViewReadPermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
SAVED_VIEW_FORBIDDEN_NAME,
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
@@ -31,8 +39,16 @@ function SavedViewsHeader({
|
||||
onOpenViews?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, isError, hasUnsavedChanges } =
|
||||
useActiveSavedView(source);
|
||||
const {
|
||||
view,
|
||||
isLoading,
|
||||
isError,
|
||||
isReadDenied,
|
||||
errorMessage,
|
||||
hasUnsavedChanges,
|
||||
} = useActiveSavedView(source);
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const readPermission = buildSavedViewReadPermission(viewKey);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
@@ -49,11 +65,27 @@ function SavedViewsHeader({
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = isError
|
||||
? SAVED_VIEW_LOAD_FAILED_NAME
|
||||
: (view?.spec.displayName ?? MY_VIEW_NAME);
|
||||
let name = view?.spec.displayName ?? MY_VIEW_NAME;
|
||||
if (isReadDenied) {
|
||||
name = SAVED_VIEW_FORBIDDEN_NAME;
|
||||
} else if (isError) {
|
||||
name = SAVED_VIEW_LOAD_FAILED_NAME;
|
||||
}
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
|
||||
const nameChip = (
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.header, {
|
||||
@@ -68,6 +100,7 @@ function SavedViewsHeader({
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<List size={14} />}
|
||||
checks={[SavedViewListPermission]}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
@@ -79,19 +112,10 @@ function SavedViewsHeader({
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={name}>
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,6 +126,7 @@ function SavedViewsHeader({
|
||||
icon={<Plus size={14} />}
|
||||
disabled={isLoading}
|
||||
disabledTooltip="Loading views"
|
||||
checks={[SavedViewCreatePermission]}
|
||||
onClick={(): void => setModalMode('create')}
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
@@ -117,6 +142,7 @@ function SavedViewsHeader({
|
||||
{view && isDirty && (
|
||||
<>
|
||||
<SaveChangesMenu
|
||||
viewId={view.id}
|
||||
disabled={isSaving}
|
||||
onSaveAsNew={openSaveAsNew}
|
||||
onUpdate={(): void => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button, type VariantColorType } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
|
||||
import { SavedViewsIconButtonProps } from './types';
|
||||
|
||||
@@ -25,11 +26,33 @@ const SavedViewsIconButton = forwardRef<
|
||||
color = 'secondary',
|
||||
disabled,
|
||||
disabledTooltip,
|
||||
checks,
|
||||
onClick,
|
||||
...triggerProps
|
||||
},
|
||||
ref,
|
||||
): JSX.Element {
|
||||
if (checks?.length) {
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
{...VARIANT_COLORS[color]}
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={title}
|
||||
disabled={disabled}
|
||||
// AuthZButton skips its checks while a block reason is set.
|
||||
disabledTooltip={disabled ? disabledTooltip : undefined}
|
||||
onClick={onClick}
|
||||
testId={testId}
|
||||
>
|
||||
{icon}
|
||||
</AuthZButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<Button
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
gap: var(--spacing-4);
|
||||
height: var(--spacing-16);
|
||||
padding-right: var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
.row:hover .rowMenu,
|
||||
.row:focus-within .rowMenu,
|
||||
.rowMenu:has([data-state='open']) {
|
||||
.rowMenu:has([data-popup-open]) {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -40,8 +40,14 @@ function SavedViewsList({
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
useSavedViewsList(source, search);
|
||||
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
|
||||
useSavedViewRowActions(source);
|
||||
const {
|
||||
copyViewLink,
|
||||
copyViewId,
|
||||
renameView,
|
||||
deleteView,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
} = useSavedViewRowActions(source);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<PendingSavedViewAction | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -79,10 +85,14 @@ function SavedViewsList({
|
||||
copyViewLink(view);
|
||||
return;
|
||||
}
|
||||
if (key === 'copyId') {
|
||||
copyViewId(view);
|
||||
return;
|
||||
}
|
||||
closeHoverCard();
|
||||
setPendingAction({ key, view });
|
||||
},
|
||||
[copyViewLink, closeHoverCard],
|
||||
[copyViewLink, copyViewId, closeHoverCard],
|
||||
);
|
||||
|
||||
const closePendingAction = useCallback((): void => setPendingAction(null), []);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
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 SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
@@ -17,17 +19,26 @@ function SavedViewsListRow({
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
const readPermission = buildSavedViewReadPermission(view.id);
|
||||
// Same cached check as the button; gates the hover card.
|
||||
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
|
||||
const canRead = !isLoading && deniedPermissions.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
|
||||
onMouseEnter={(event): void => {
|
||||
if (canRead) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<div className={styles.rowName}>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[readPermission]}
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -41,7 +52,7 @@ function SavedViewsListRow({
|
||||
testId="saved-views-row-name"
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</div>
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
@@ -54,6 +65,7 @@ function SavedViewsListRow({
|
||||
)}
|
||||
<span className={styles.rowMenu}>
|
||||
<SavedViewsRowMenu
|
||||
viewId={view.id}
|
||||
onAction={(key): void => onAction(view, key)}
|
||||
onOpen={hover.onMenuOpen}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Input } from '@signozhq/ui/input';
|
||||
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 SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
|
||||
@@ -39,16 +42,20 @@ function SavedViewsPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
<AuthZGuardContent checks={[SavedViewListPermission]}>
|
||||
<>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
|
||||
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import { SavedViewsRowMenuProps } from './types';
|
||||
|
||||
function SavedViewsRowMenu({
|
||||
viewId,
|
||||
onAction,
|
||||
onOpen,
|
||||
}: SavedViewsRowMenuProps): JSX.Element {
|
||||
const items = useMemo<DropdownItemType[]>(
|
||||
const items = useMemo<AuthZDropdownItemType[]>(
|
||||
() =>
|
||||
SAVED_VIEW_ROW_ACTIONS.flatMap(
|
||||
({
|
||||
@@ -19,7 +21,8 @@ function SavedViewsRowMenu({
|
||||
icon: Icon,
|
||||
danger,
|
||||
hasDividerBefore,
|
||||
}): DropdownItemType[] => [
|
||||
buildPermission,
|
||||
}): AuthZDropdownItemType[] => [
|
||||
...(hasDividerBefore
|
||||
? [{ type: 'separator' as const, value: `separator-${key}` }]
|
||||
: []),
|
||||
@@ -29,15 +32,16 @@ function SavedViewsRowMenu({
|
||||
label,
|
||||
prefix: <Icon size={14} />,
|
||||
danger,
|
||||
checks: buildPermission ? [buildPermission(viewId)] : undefined,
|
||||
onClick: (): void => onAction(key),
|
||||
},
|
||||
],
|
||||
),
|
||||
[onAction],
|
||||
[viewId, onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
nativeButton
|
||||
side="right"
|
||||
@@ -47,6 +51,7 @@ function SavedViewsRowMenu({
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
testId="saved-views-row-menu"
|
||||
>
|
||||
<SavedViewsRowButton
|
||||
icon={<Ellipsis size={14} />}
|
||||
@@ -54,7 +59,7 @@ function SavedViewsRowMenu({
|
||||
tooltip="More options"
|
||||
testId="saved-views-row-menu"
|
||||
/>
|
||||
</Dropdown>
|
||||
</AuthZDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
@@ -110,6 +117,11 @@ async function renderPanel(
|
||||
dataSource,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row-name')
|
||||
.forEach((name) => expectUsable(name)),
|
||||
);
|
||||
jest.useFakeTimers();
|
||||
}
|
||||
|
||||
@@ -140,6 +152,10 @@ describe('SavedViewHoverCard', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// useAuthZ batches checks on a timer; left pending, it blocks later checks.
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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 { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
buildSavedViewDeletePermission,
|
||||
buildSavedViewReadPermission,
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
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 SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
function renderPanel(url = explorerUrl(PATH, {})): void {
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const rowNamed = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((row) => row.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
async function openRowMenu(name: string): Promise<void> {
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
|
||||
}
|
||||
|
||||
// Resolves once the row's check is back, so its state is final.
|
||||
async function settledRow(testId: string): Promise<HTMLElement> {
|
||||
await waitFor(
|
||||
() => expect(screen.getByTestId(testId)).not.toHaveAttribute('data-loading'),
|
||||
MENU_CHECK_TIMEOUT,
|
||||
);
|
||||
return screen.getByTestId(testId);
|
||||
}
|
||||
|
||||
describe('SavedViews - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows the denied callout instead of the list without list', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(SavedViewListPermission));
|
||||
renderPanel();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-list')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-search')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the views it cannot read', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderPanel();
|
||||
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
const nameOf = (view: string): HTMLElement =>
|
||||
within(rowNamed(view)).getByTestId('saved-views-row-name');
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
|
||||
);
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('keeps the row menu on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderPanel();
|
||||
|
||||
await openRowMenu('Errors');
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-delete'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-editDetails'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-copyLink'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables creating without create', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
renderPanel();
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-list-create')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-create')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps create disabled while the check is pending', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
const create = screen.getByTestId('saved-views-create');
|
||||
expect(create).toHaveAttribute('aria-busy', 'true');
|
||||
await userEvent.click(create);
|
||||
expect(screen.queryByTestId('save-view-name')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables edit details without update and delete without delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
setupAuthzDeny(
|
||||
buildSavedViewUpdatePermission(errors.id),
|
||||
buildSavedViewDeletePermission(errors.id),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await openRowMenu('Errors');
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-editDetails'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-delete'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-copyLink'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables updating the open view without update, keeping save as new', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewUpdatePermission(errors.id)));
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-update'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-save-as-new'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('says no access when the check denies read, even if the server returns the view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveAttribute(
|
||||
'data-denied-permissions',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryAllByText(/is not authorized to perform read:saved-view:view-1/)
|
||||
.length,
|
||||
).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a 403 the check allowed as a failed load, showing the server message', async () => {
|
||||
const message = 'saved-view:read is missing, ask an admin for it';
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(403),
|
||||
ctx.json({ status: 'error', error: { code: 'forbidden', message } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'View failed to load',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@ import { usePreferenceContext } from 'providers/preferences/context/PreferenceCo
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
@@ -115,9 +117,11 @@ describe('SavedViewsHeader columns', () => {
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Update selected view' }),
|
||||
const update = await screen.findByTestId(
|
||||
'saved-views-save-changes-item-update',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(update);
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
|
||||
@@ -9,8 +9,11 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
expectRowUsable,
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
@@ -67,9 +70,21 @@ async function openDirtyView(
|
||||
return rendered;
|
||||
}
|
||||
|
||||
const SAVE_MENU_ITEM_TEST_IDS: Record<string, string> = {
|
||||
'Save as new view': 'saved-views-save-changes-item-save-as-new',
|
||||
'Update selected view': 'saved-views-save-changes-item-update',
|
||||
};
|
||||
|
||||
async function chooseFromSaveMenu(item: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
const menuItem = await screen.findByTestId(SAVE_MENU_ITEM_TEST_IDS[item]);
|
||||
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(menuItem);
|
||||
}
|
||||
|
||||
async function clickCreate(): Promise<void> {
|
||||
await waitFor(() => expectUsable(screen.getByTestId('saved-views-create')));
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
@@ -87,7 +102,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
|
||||
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-view-submit')).toHaveAttribute(
|
||||
@@ -130,7 +145,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([], { createStatus: 500 });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
@@ -145,7 +160,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
|
||||
await userEvent.click(screen.getByTestId('save-view-cancel'));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ROUTES from 'constants/routes';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
@@ -84,9 +85,9 @@ describe('SavedViewsPanel list', () => {
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
await waitFor(() => expectUsable(name));
|
||||
await userEvent.click(name);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
@@ -161,7 +162,9 @@ describe('SavedViewsPanel list', () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
|
||||
const create = await screen.findByTestId('saved-views-list-create');
|
||||
await waitFor(() => expectUsable(create));
|
||||
await userEvent.click(create);
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
@@ -58,13 +60,22 @@ const row = (name: string): HTMLElement =>
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const ROW_MENU_ITEM_TEST_IDS: Record<string, string> = {
|
||||
'Copy link': 'saved-views-row-menu-item-copyLink',
|
||||
'Copy ID': 'saved-views-row-menu-item-copyId',
|
||||
'Edit details': 'saved-views-row-menu-item-editDetails',
|
||||
Delete: 'saved-views-row-menu-item-delete',
|
||||
};
|
||||
|
||||
async function chooseFromRowMenu(
|
||||
viewName: string,
|
||||
item: string,
|
||||
): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
const menuItem = await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS[item]);
|
||||
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
|
||||
fireEvent.click(menuItem);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
@@ -76,21 +87,38 @@ describe('SavedViewsPanel row menu', () => {
|
||||
jest.mocked(toast.success).mockClear();
|
||||
});
|
||||
|
||||
it('offers copy link, edit details and delete', async () => {
|
||||
it('offers copy link, copy id, edit details and delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
|
||||
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
|
||||
const items = await screen.findAllByRole('menuitem');
|
||||
expect(items.map((item) => item.textContent)).toStrictEqual([
|
||||
await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS['Copy link']);
|
||||
const items = Object.values(ROW_MENU_ITEM_TEST_IDS).map(
|
||||
(testId) => screen.getByTestId(testId).textContent,
|
||||
);
|
||||
expect(items).toStrictEqual([
|
||||
'Copy link',
|
||||
'Copy ID',
|
||||
'Edit details',
|
||||
'Delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies the view id', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy ID');
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(slow.id);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View ID copied',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
|
||||
@@ -13,6 +14,7 @@ export interface Requests {
|
||||
}
|
||||
|
||||
// Mutates `views` on create, update and delete, so a refetch sees the change.
|
||||
// Grants every permission; a later server.use overrides it.
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
@@ -30,6 +32,7 @@ export function mockSavedViewsApi(
|
||||
const requests: Requests = { created: [], updated: [], deleted: [] };
|
||||
let failedLists = 0;
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
if (failedLists < listFailures) {
|
||||
failedLists += 1;
|
||||
|
||||
@@ -251,3 +251,20 @@ export function renderWithExplorerProviders(
|
||||
export function urlParam(history: MemoryHistory, key: string): string | null {
|
||||
return new URLSearchParams(history.location.search).get(key);
|
||||
}
|
||||
|
||||
// The ui Button marks a pending check with aria-busy and a block with
|
||||
// aria-disabled, never the disabled attribute.
|
||||
export function expectUsable(element: HTMLElement): void {
|
||||
expect(element).not.toHaveAttribute('aria-busy');
|
||||
expect(element).not.toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
// A menu checks its rows on first open; on a loaded CI runner that outlasts
|
||||
// waitFor's 1s default.
|
||||
export const MENU_CHECK_TIMEOUT = { timeout: 5000 };
|
||||
|
||||
// A menu row waiting on its check is data-loading, not yet data-disabled.
|
||||
export function expectRowUsable(row: HTMLElement): void {
|
||||
expect(row).not.toHaveAttribute('data-loading');
|
||||
expect(row).not.toHaveAttribute('data-disabled');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
buildSavedViewDeletePermission,
|
||||
buildSavedViewUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
|
||||
@@ -30,6 +34,8 @@ export const MY_VIEW_NAME = 'My view';
|
||||
|
||||
export const SAVED_VIEW_LOAD_FAILED_NAME = 'View failed to load';
|
||||
|
||||
export const SAVED_VIEW_FORBIDDEN_NAME = 'No view access';
|
||||
|
||||
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
|
||||
|
||||
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
|
||||
@@ -47,13 +53,20 @@ export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
|
||||
|
||||
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
|
||||
{ key: 'copyLink', label: 'Copy link', icon: Link },
|
||||
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
|
||||
{ key: 'copyId', label: 'Copy ID', icon: Copy },
|
||||
{
|
||||
key: 'editDetails',
|
||||
label: 'Edit details',
|
||||
icon: PenLine,
|
||||
buildPermission: buildSavedViewUpdatePermission,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
hasDividerBefore: true,
|
||||
buildPermission: buildSavedViewDeletePermission,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
@@ -23,11 +26,19 @@ export function useActiveSavedView(
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: !!viewKey } },
|
||||
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
|
||||
[buildSavedViewReadPermission(viewKey)],
|
||||
{ enabled: !!viewKey },
|
||||
);
|
||||
const view = viewKey ? data?.data : undefined;
|
||||
const isReadDenied = !!viewKey && deniedPermissions.length > 0;
|
||||
|
||||
const canFetch = !!viewKey && !isCheckingRead && !isReadDenied;
|
||||
|
||||
const { data, isLoading, isError, error } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: canFetch } },
|
||||
);
|
||||
const view = canFetch ? data?.data : undefined;
|
||||
|
||||
// The query builder stages the url query a render later. Until the ids
|
||||
// match, keep the last answer for this view; another view starts clean.
|
||||
@@ -68,10 +79,17 @@ export function useActiveSavedView(
|
||||
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
|
||||
}, [view?.id, hasUnsavedChanges]);
|
||||
|
||||
const errorMessage =
|
||||
viewKey && isError && error
|
||||
? toAPIError(error, '').getErrorMessage() || undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
view,
|
||||
isLoading: !!viewKey && isLoading,
|
||||
isError: !!viewKey && isError,
|
||||
isLoading: !!viewKey && (isCheckingRead || isLoading),
|
||||
isError: !!viewKey && (isError || isReadDenied),
|
||||
isReadDenied,
|
||||
errorMessage,
|
||||
hasUnsavedChanges,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ export function useSavedViewRowActions(
|
||||
[pathname, copyToClipboard],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
copyToClipboard(view.id);
|
||||
toast.success('View ID copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renameView = useCallback(
|
||||
async (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
@@ -101,5 +109,12 @@ export function useSavedViewRowActions(
|
||||
[deleteSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
|
||||
return {
|
||||
copyViewLink,
|
||||
copyViewId,
|
||||
renameView,
|
||||
deleteView,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
};
|
||||
}
|
||||
|
||||
218
frontend/src/container/SavedViews/stories/SavedViews.stories.mocks.tsx
generated
Normal file
218
frontend/src/container/SavedViews/stories/SavedViews.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import {
|
||||
type AuthtypesTransactionDTO,
|
||||
type SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
type SavedviewtypesUpdatableSavedViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockControlValues, StoryMocks } from '@/storybook/controls/types';
|
||||
import { authzMocks, type AuthzArgs } from '@/storybook/globals/authzMocks';
|
||||
import { respondWith } from '@/storybook/runtime/responseState';
|
||||
|
||||
import {
|
||||
createdSavedView,
|
||||
OPEN_VIEW_ERRORS,
|
||||
OPEN_VIEWS,
|
||||
type OpenView,
|
||||
SAVED_VIEW_SOURCES,
|
||||
SAVED_VIEWS_MAX,
|
||||
savedViewById,
|
||||
savedViewResponse,
|
||||
savedViewRoute,
|
||||
savedViewsListResponse,
|
||||
type SavedViewSource,
|
||||
unreadableViewId,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
const LIST = 'Saved Views · list';
|
||||
const VIEW = 'Saved Views · open view';
|
||||
|
||||
const controls = {
|
||||
source: choiceControl<SavedViewSource>('Source', {
|
||||
group: VIEW,
|
||||
description: 'Which explorer the views belong to.',
|
||||
options: SAVED_VIEW_SOURCES,
|
||||
value: SavedviewtypesSourceDTO.traces,
|
||||
}),
|
||||
openView: choiceControl<OpenView>('Open view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'The `viewKey` the explorer url carries. `unsaved-changes` stages a query that differs from the view, which swaps in the ✓ save menu; `forbidden`, `failed` and `not-found` are the 403, 500 and 404 the view fetch answers with; each shows View failed to load.',
|
||||
options: OPEN_VIEWS,
|
||||
value: 'none',
|
||||
}),
|
||||
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.',
|
||||
value: false,
|
||||
}),
|
||||
createdByMe: countControl('Created by me', {
|
||||
group: LIST,
|
||||
value: 3,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
createdByOthers: countControl('Created by others', {
|
||||
group: LIST,
|
||||
description: 'Both at 0 is the empty state, with its Create view button.',
|
||||
value: 4,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
};
|
||||
|
||||
type SavedViewsValues = MockControlValues<typeof controls>;
|
||||
|
||||
type WrittenView = Pick<SavedviewtypesSavedViewDTO, 'spec' | 'source'>;
|
||||
|
||||
const viewDenied = (
|
||||
transaction: AuthtypesTransactionDTO,
|
||||
deniedId: string,
|
||||
): boolean =>
|
||||
transaction.relation === 'read' &&
|
||||
transaction.object.resource.kind === 'saved-view' &&
|
||||
transaction.object.selector === deniedId;
|
||||
|
||||
const definition = defineStoryMocks({
|
||||
controls,
|
||||
handlers: (
|
||||
values: SavedViewsValues & AuthzArgs,
|
||||
response,
|
||||
): RequestHandler[] => {
|
||||
const { source, openView, createdByMe, createdByOthers } = values;
|
||||
|
||||
// What the panel writes stays in this handler set until a control changes.
|
||||
const created: SavedviewtypesSavedViewDTO[] = [];
|
||||
const updated = new Map<string, WrittenView>();
|
||||
const deleted = new Set<string>();
|
||||
|
||||
const current = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
|
||||
|
||||
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
|
||||
const view = created.find((item) => item.id === id) ?? savedViewById(id);
|
||||
|
||||
return view && !deleted.has(id) ? current(view) : undefined;
|
||||
};
|
||||
|
||||
const openError = OPEN_VIEW_ERRORS[openView];
|
||||
|
||||
return [
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
|
||||
const id = `storybook-${source}-created-${created.length + 1}`;
|
||||
|
||||
created.push(createdSavedView(source, id, body.spec));
|
||||
|
||||
return res(ctx.status(201), ctx.json({ status: 'success', data: { id } }));
|
||||
}),
|
||||
|
||||
rest.get(
|
||||
API,
|
||||
response.json(() => {
|
||||
const list = savedViewsListResponse(source, createdByMe, createdByOthers);
|
||||
|
||||
return {
|
||||
...list,
|
||||
data: [...(list.data ?? []), ...created]
|
||||
.filter((view) => !deleted.has(view.id))
|
||||
.map(current),
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
if (openError) {
|
||||
return res(ctx.status(openError.status), ctx.json(openError.body));
|
||||
}
|
||||
|
||||
const view = findView(String(req.params.id));
|
||||
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json(OPEN_VIEW_ERRORS['not-found']?.body));
|
||||
}
|
||||
|
||||
return response.json(() => savedViewResponse(view))(req, res, ctx);
|
||||
}),
|
||||
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
|
||||
|
||||
updated.set(String(req.params.id), {
|
||||
spec: body.spec,
|
||||
source: body.source,
|
||||
});
|
||||
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
|
||||
rest.delete(`${API}/:id`, (req, res, ctx) => {
|
||||
deleted.add(String(req.params.id));
|
||||
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
|
||||
...(values.unreadableView
|
||||
? [
|
||||
rest.post(
|
||||
AUTHZ_CHECK_URL,
|
||||
respondWith(values.authzState, async (req) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const granted = accessFor(
|
||||
values.access,
|
||||
values.permissions,
|
||||
values.revoked,
|
||||
);
|
||||
const deniedId = unreadableViewId(source);
|
||||
|
||||
return authzMockResponse(
|
||||
payload,
|
||||
payload.map(
|
||||
(transaction) =>
|
||||
!viewDenied(transaction, deniedId) && granted.allows(transaction),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
config: ({ source, openView }) => ({
|
||||
route: savedViewRoute(source, openView),
|
||||
}),
|
||||
effect: () => {
|
||||
remove(LOCALSTORAGE.LAST_USED_SAVED_VIEWS);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The per-view read denial answers `authz/check` in place of the global access
|
||||
* mocks, so it reads their controls as well as its own to answer every other
|
||||
* check the way they would.
|
||||
*/
|
||||
export const savedViewsMocks: StoryMocks<typeof controls> = {
|
||||
...definition,
|
||||
read: (args) => ({ ...authzMocks.read(args), ...definition.read(args) }),
|
||||
};
|
||||
114
frontend/src/container/SavedViews/stories/SavedViews.stories.tsx
Normal file
114
frontend/src/container/SavedViews/stories/SavedViews.stories.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { savedViewsMocks } from './SavedViews.stories.mocks';
|
||||
|
||||
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
|
||||
|
||||
const componentStory = storyMocks(savedViewsMocks);
|
||||
|
||||
/** The panel fetches the list and checks every row's permissions before it settles. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The saved views sidebar of an explorer without quick filters: the header with
|
||||
* the open view and its actions, above the list of views created by the user
|
||||
* and by others. Every action is gated on the `saved-view` authz permissions
|
||||
* (list, create, and read, update and delete per view).
|
||||
*
|
||||
* Route: the explorer of the Source control, with the Open view control's
|
||||
* `viewKey`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Saved Views',
|
||||
tags: ['authz', 'play'],
|
||||
component: SavedViewsPanel,
|
||||
decorators: [
|
||||
withCanvas({
|
||||
width: 280,
|
||||
height: 640,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid var(--l1-border)',
|
||||
}),
|
||||
],
|
||||
render: ({ source }): JSX.Element => (
|
||||
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<SavedViewsHeader source={source} />
|
||||
<SavedViewsPanel source={source} />
|
||||
</TooltipProvider>
|
||||
),
|
||||
...componentStory,
|
||||
parameters: { ...componentStory.parameters },
|
||||
} satisfies Meta<SavedViewsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SavedViewsArgs>;
|
||||
|
||||
/** Traces views created by the user and by others, with no view open. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace with no views yet: the empty state offers Create view. */
|
||||
export const Empty: Story = {
|
||||
args: { createdByMe: 0, createdByOthers: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A view open with a query that differs from what it saved: the name carries
|
||||
* the unsaved dot, and ✓ and discard replace Clear view.
|
||||
*/
|
||||
export const UnsavedChanges: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
};
|
||||
|
||||
/** Without `list` on saved views, the panel shows the permission callout. */
|
||||
export const NoAccess: Story = {
|
||||
args: { revoked: ['list:saved-view'] },
|
||||
};
|
||||
|
||||
/**
|
||||
* The first row's menu. Edit details and Delete follow `update` and `delete` on
|
||||
* that view, so revoking either disables its item.
|
||||
*/
|
||||
export const RowMenu: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
'saved-views-row',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ✓ menu on a view with unsaved changes. Save as new view follows `create`
|
||||
* and Update selected view follows `update` on the open view.
|
||||
*/
|
||||
export const SaveChangesMenu: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await waitFor(async () => {
|
||||
const button = within(canvasElement).getByTestId('saved-views-save-changes');
|
||||
|
||||
await expect(button).not.toHaveAttribute('aria-busy');
|
||||
|
||||
return button;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('menu', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
309
frontend/src/container/SavedViews/stories/__story_mockdata__/savedViews.ts
generated
Normal file
309
frontend/src/container/SavedViews/stories/__story_mockdata__/savedViews.ts
generated
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GetSavedView200,
|
||||
ListSavedViews200,
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from 'container/SavedViews/utils/toSavedViewSpec';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
export const SAVED_VIEW_SOURCES = [
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
SavedviewtypesSourceDTO.meter,
|
||||
] as const;
|
||||
|
||||
export type SavedViewSource = (typeof SAVED_VIEW_SOURCES)[number];
|
||||
|
||||
export const OPEN_VIEWS = [
|
||||
'none',
|
||||
'created-by-me',
|
||||
'created-by-others',
|
||||
'unsaved-changes',
|
||||
'forbidden',
|
||||
'failed',
|
||||
'not-found',
|
||||
] as const;
|
||||
|
||||
export type OpenView = (typeof OPEN_VIEWS)[number];
|
||||
|
||||
export const SAVED_VIEWS_MAX = 8;
|
||||
|
||||
/** The user the story app context signs in as, so "Created by me" follows it. */
|
||||
const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user.email;
|
||||
|
||||
const OTHER_AUTHORS = ['priya@signoz.io', 'marco@signoz.io', 'lena@signoz.io'];
|
||||
|
||||
const MY_VIEW_NAMES = [
|
||||
'Checkout errors',
|
||||
'Slow payment calls',
|
||||
'Cart service, production',
|
||||
'Auth retries',
|
||||
'Inventory sync',
|
||||
'Search p99 regressions',
|
||||
'Webhook failures',
|
||||
'Cold starts after deploy',
|
||||
];
|
||||
|
||||
const OTHER_VIEW_NAMES = [
|
||||
'On-call triage',
|
||||
'Frontend 5xx',
|
||||
'Shipping latency by region',
|
||||
'Kafka consumer lag',
|
||||
'Staging smoke tests',
|
||||
'Recommendation timeouts',
|
||||
'Ad service sampling',
|
||||
'Email queue backlog',
|
||||
];
|
||||
|
||||
const EXPRESSIONS = [
|
||||
"service.name = 'checkout' AND has_error = true",
|
||||
"service.name = 'payments'",
|
||||
"service.name = 'cart' AND deployment.environment = 'production'",
|
||||
"service.name = 'auth'",
|
||||
"service.name IN ('inventory', 'catalog')",
|
||||
"service.name = 'search'",
|
||||
"service.name = 'webhooks' AND has_error = true",
|
||||
"deployment.environment = 'production'",
|
||||
];
|
||||
|
||||
const BASE_QUERY: Record<SavedViewSource, Query> = {
|
||||
traces: initialQueriesMap.traces,
|
||||
logs: initialQueriesMap.logs,
|
||||
metrics: initialQueriesMap.metrics,
|
||||
meter: initialQueryMeterWithType,
|
||||
};
|
||||
|
||||
const EXPLORER_ROUTE: Record<SavedViewSource, string> = {
|
||||
traces: ROUTES.TRACES_EXPLORER,
|
||||
logs: ROUTES.LOGS_EXPLORER,
|
||||
metrics: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
meter: ROUTES.METER_EXPLORER,
|
||||
};
|
||||
|
||||
const queryWith = (source: SavedViewSource, expression: string): Query => {
|
||||
const base = BASE_QUERY[source];
|
||||
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
|
||||
type Author = 'mine' | 'others';
|
||||
|
||||
const viewId = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
index: number,
|
||||
): string => `storybook-${source}-${author}-${index + 1}`;
|
||||
|
||||
/** The view other people made that the per-view read denial applies to. */
|
||||
export const unreadableViewId = (source: SavedViewSource): string =>
|
||||
viewId(source, 'others', 0);
|
||||
|
||||
const savedView = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
index: number,
|
||||
): SavedviewtypesSavedViewDTO => {
|
||||
const displayName = (author === 'mine' ? MY_VIEW_NAMES : OTHER_VIEW_NAMES)[
|
||||
index
|
||||
];
|
||||
const createdBy =
|
||||
author === 'mine'
|
||||
? STORY_USER_EMAIL
|
||||
: OTHER_AUTHORS[index % OTHER_AUTHORS.length];
|
||||
const id = viewId(source, author, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdAt: '2026-09-02T08:30:00Z',
|
||||
createdBy,
|
||||
updatedAt: `2026-10-${String(14 - (index % 10)).padStart(2, '0')}T11:42:00Z`,
|
||||
updatedBy: createdBy,
|
||||
spec: toSavedViewSpec({
|
||||
query: queryWith(
|
||||
source,
|
||||
EXPRESSIONS[(index + (author === 'mine' ? 0 : 3)) % EXPRESSIONS.length],
|
||||
),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const savedViews = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
count: number,
|
||||
): SavedviewtypesSavedViewDTO[] =>
|
||||
Array.from({ length: Math.min(count, SAVED_VIEWS_MAX) }, (_, index) =>
|
||||
savedView(source, author, index),
|
||||
);
|
||||
|
||||
export const savedViewsListResponse = (
|
||||
source: SavedViewSource,
|
||||
createdByMe: number,
|
||||
createdByOthers: number,
|
||||
): ListSavedViews200 => ({
|
||||
status: 'success',
|
||||
data: [
|
||||
...savedViews(source, 'mine', createdByMe),
|
||||
...savedViews(source, 'others', createdByOthers),
|
||||
],
|
||||
});
|
||||
|
||||
/** Every view the workspace has, whatever the list control trims it to. */
|
||||
export const savedViewById = (
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined =>
|
||||
SAVED_VIEW_SOURCES.flatMap((source) => [
|
||||
...savedViews(source, 'mine', SAVED_VIEWS_MAX),
|
||||
...savedViews(source, 'others', SAVED_VIEWS_MAX),
|
||||
]).find((view) => view.id === id);
|
||||
|
||||
/** A view the panel itself created, owned by the story user. */
|
||||
export const createdSavedView = (
|
||||
source: SavedViewSource,
|
||||
id: string,
|
||||
spec: SavedviewtypesSavedViewDTO['spec'],
|
||||
): SavedviewtypesSavedViewDTO => ({
|
||||
...savedView(source, 'mine', 0),
|
||||
id,
|
||||
name: id,
|
||||
createdAt: '2026-10-16T09:00:00Z',
|
||||
updatedAt: '2026-10-16T09:00:00Z',
|
||||
spec,
|
||||
});
|
||||
|
||||
export const savedViewResponse = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 => ({ status: 'success', data: view });
|
||||
|
||||
export const OPEN_VIEW_ERRORS: Partial<
|
||||
Record<OpenView, { status: number; body: RenderErrorResponseDTO }>
|
||||
> = {
|
||||
forbidden: {
|
||||
status: 403,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'You do not have access to this saved view',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'forbidden',
|
||||
},
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
status: 500,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'Failed to load the saved view',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'internal',
|
||||
},
|
||||
},
|
||||
},
|
||||
'not-found': {
|
||||
status: 404,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'not_found',
|
||||
message: 'Saved view not found',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'not_found',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const openViewId = (source: SavedViewSource, open: OpenView): string | null => {
|
||||
if (open === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (open === 'created-by-others' || open === 'forbidden') {
|
||||
return viewId(source, 'others', 0);
|
||||
}
|
||||
|
||||
if (open === 'not-found') {
|
||||
return `storybook-${source}-deleted`;
|
||||
}
|
||||
|
||||
return viewId(source, 'mine', 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorer url with a view open, its params written the way
|
||||
* `redirectWithQueryBuilderData` writes them. `panelTypes` is left out: the
|
||||
* query builder seeds its panel type from the raw, JSON-quoted param, which no
|
||||
* saved panel type equals, and without it the header compares against `list`.
|
||||
*/
|
||||
export const savedViewRoute = (
|
||||
source: SavedViewSource,
|
||||
open: OpenView,
|
||||
): string => {
|
||||
const params = new URLSearchParams({ relativeTime: '15m' });
|
||||
const id = openViewId(source, open);
|
||||
|
||||
if (id) {
|
||||
const view = savedViewById(id);
|
||||
const query = view
|
||||
? getSavedViewQuery(view)
|
||||
: queryWith(source, EXPRESSIONS[0]);
|
||||
const staged =
|
||||
open === 'unsaved-changes'
|
||||
? queryWith(source, `${EXPRESSIONS[0]} AND duration_nano > 500000000`)
|
||||
: query;
|
||||
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(
|
||||
JSON.stringify({ ...staged, id: `storybook-staged-${open}` }),
|
||||
),
|
||||
);
|
||||
params.set(QueryParams.viewKey, JSON.stringify(id));
|
||||
}
|
||||
|
||||
return `${EXPLORER_ROUTE[source]}?${params.toString()}`;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
import type { Link } from '@signozhq/icons';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -41,6 +42,8 @@ export interface UseActiveSavedViewResult {
|
||||
view: SavedviewtypesSavedViewDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isReadDenied: boolean;
|
||||
errorMessage: string | undefined;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ export interface SaveViewModalProps {
|
||||
}
|
||||
|
||||
export interface SaveChangesMenuProps {
|
||||
viewId: string;
|
||||
disabled: boolean;
|
||||
onSaveAsNew: () => void;
|
||||
onUpdate: () => void;
|
||||
@@ -82,6 +86,7 @@ export interface SavedViewsIconButtonProps {
|
||||
color?: 'secondary' | 'warning';
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: ReactNode;
|
||||
checks?: BrandedPermission[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -116,7 +121,11 @@ export interface SavedViewsListRowProps {
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
|
||||
export type SavedViewRowActionKey =
|
||||
| 'copyLink'
|
||||
| 'copyId'
|
||||
| 'editDetails'
|
||||
| 'delete';
|
||||
|
||||
export interface SavedViewRowActionConfig {
|
||||
key: SavedViewRowActionKey;
|
||||
@@ -124,10 +133,11 @@ export interface SavedViewRowActionConfig {
|
||||
icon: typeof Link;
|
||||
danger?: boolean;
|
||||
hasDividerBefore?: boolean;
|
||||
buildPermission?: (id: string) => BrandedPermission;
|
||||
}
|
||||
|
||||
export interface PendingSavedViewAction {
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink' | 'copyId'>;
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
@@ -140,6 +150,7 @@ export interface SavedViewsRowButtonProps {
|
||||
}
|
||||
|
||||
export interface SavedViewsRowMenuProps {
|
||||
viewId: string;
|
||||
onAction: (key: SavedViewRowActionKey) => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
@@ -152,6 +163,7 @@ export interface DeleteSavedViewDialogProps {
|
||||
|
||||
export interface UseSavedViewRowActionsResult {
|
||||
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
copyViewId: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
renameView: (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
displayName: string,
|
||||
|
||||
@@ -23,6 +23,11 @@ export default {
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'saved-view',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'subscription',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BrandedPermission } from '../types';
|
||||
import { buildPermission } from '../utils';
|
||||
|
||||
export const SavedViewListPermission = buildPermission('list', 'saved-view:*');
|
||||
export const SavedViewCreatePermission = buildPermission(
|
||||
'create',
|
||||
'saved-view:*',
|
||||
);
|
||||
|
||||
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `saved-view:${id}`);
|
||||
export const buildSavedViewUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `saved-view:${id}`);
|
||||
export const buildSavedViewDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `saved-view:${id}`);
|
||||
@@ -5,7 +5,7 @@ import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
|
||||
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/components/PanelTypeBrowser/PanelTypeBrowser';
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
|
||||
|
||||
@@ -7,11 +7,11 @@ import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetSto
|
||||
import type { NewPanelTarget } from '../../../patchOps';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
|
||||
import AddPanelSplitButton from './AddPanelSplitButton';
|
||||
import NewSectionNameInput from './NewSectionNameInput';
|
||||
import PanelTypeBrowser from './PanelTypeBrowser';
|
||||
import { usePanelPickerDraftSection } from './usePanelPickerDraftSection';
|
||||
import { usePanelPickerTarget } from './usePanelPickerTarget';
|
||||
import AddPanelSplitButton from './components/AddPanelSplitButton/AddPanelSplitButton';
|
||||
import NewSectionNameInput from './components/NewSectionNameInput/NewSectionNameInput';
|
||||
import PanelTypeBrowser from './components/PanelTypeBrowser/PanelTypeBrowser';
|
||||
import { usePanelPickerDraftSection } from './hooks/usePanelPickerDraftSection';
|
||||
import { usePanelPickerTarget } from './hooks/usePanelPickerTarget';
|
||||
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
|
||||
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChevronUp, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
|
||||
import type { SectionOption } from './types';
|
||||
import type { SectionOption } from '../../types';
|
||||
|
||||
import styles from './AddPanelSplitButton.module.scss';
|
||||
|
||||
@@ -88,76 +88,6 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
|
||||
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.tileDisabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.tileText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tileTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tileName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.newBadge {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--bg-sienna-400);
|
||||
border-radius: 3px;
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tileDescription {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4,12 +4,12 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { PanelKind } from '../../../../../Panels/types/panelKind';
|
||||
import {
|
||||
filterPanelTypeGroups,
|
||||
type PanelTypeGroupId,
|
||||
} from './panelTypeCatalog';
|
||||
import PanelTypeTile from './PanelTypeTile';
|
||||
import PanelTypeTile from '../PanelTypeTile/PanelTypeTile';
|
||||
|
||||
import styles from './PanelTypeBrowser.module.scss';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PANEL_OPTIONS, type PanelOption } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import {
|
||||
PANEL_OPTIONS,
|
||||
type PanelOption,
|
||||
} from '../../../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../../../Panels/types/panelKind';
|
||||
|
||||
export type PanelTypeGroupId =
|
||||
| 'trends'
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { PanelKind } from '../../../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPE_PREVIEWS } from './panelTypePreviews';
|
||||
|
||||
import styles from './PanelTypePreview.module.scss';
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { PanelKind } from '../../../../../Panels/types/panelKind';
|
||||
import PreviewBars from './PreviewBars';
|
||||
import PreviewRows, { type PreviewCell } from './PreviewRows';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
|
||||
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.tileDisabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.tileText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tileTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tileName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.newBadge {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--bg-sienna-400);
|
||||
border-radius: 3px;
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tileDescription {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { PanelTypeItem } from './panelTypeCatalog';
|
||||
import PanelTypePreview from './PanelTypePreview';
|
||||
import type { PanelTypeItem } from '../PanelTypeBrowser/panelTypeCatalog';
|
||||
import PanelTypePreview from '../PanelTypePreview/PanelTypePreview';
|
||||
|
||||
import styles from './PanelTypeBrowser.module.scss';
|
||||
import styles from './PanelTypeTile.module.scss';
|
||||
|
||||
interface PanelTypeTileProps {
|
||||
item: PanelTypeItem;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
|
||||
import type { PanelKind } from '../../../../Panels/types/panelKind';
|
||||
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
|
||||
|
||||
export function usePanelPickerDraftSection(
|
||||
title: string | null,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
|
||||
import type { PanelKind } from '../../../../Panels/types/panelKind';
|
||||
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
|
||||
|
||||
interface UsePanelPickerTargetArgs {
|
||||
open: boolean;
|
||||
@@ -1,381 +0,0 @@
|
||||
// Design token Paragraph/Base/400.
|
||||
@mixin paragraph-base {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Typography sets size, weight and line height; the mixin adds the rest.
|
||||
.roleLabel {
|
||||
@include paragraph-base;
|
||||
}
|
||||
|
||||
.parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.textBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
@include paragraph-base;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
word-break: break-word;
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
:global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(:is(ol, ul)) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-10);
|
||||
}
|
||||
|
||||
:global(li::marker) {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2, h3, h4, h5, h6)) {
|
||||
margin: var(--spacing-6) 0 var(--spacing-4);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2)) {
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(strong) {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:global(code:not(pre code)) {
|
||||
padding: 0 var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l3-background);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(pre) {
|
||||
position: relative;
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
// The highlighter sets its box with inline styles; syntax colours stay its own.
|
||||
:global(pre > div:not(.code-copy-btn)) {
|
||||
margin: 0 !important;
|
||||
padding: var(--spacing-4) var(--spacing-5) !important;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2) !important;
|
||||
background: var(--l1-background) !important;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:global(pre code) {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
:global(blockquote) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-5);
|
||||
border-left: 2px solid var(--l3-border);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
margin: var(--spacing-6) 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.plainText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
min-width: 0;
|
||||
|
||||
@include paragraph-base;
|
||||
|
||||
&.isPlain {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
border-radius: var(--radius-round);
|
||||
|
||||
&[data-tone='success'] {
|
||||
background: var(--accent-forest);
|
||||
}
|
||||
|
||||
&[data-tone='warning'] {
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
|
||||
&[data-tone='error'] {
|
||||
background: var(--accent-cherry);
|
||||
}
|
||||
|
||||
&[data-tone='info'] {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipSeparator {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chipValue {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toolCall,
|
||||
.toolResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
border-radius: var(--radius-2);
|
||||
transition: outline-color 0.3s ease;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: var(--spacing-1);
|
||||
|
||||
&.isHighlighted {
|
||||
outline-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolLink {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.chip {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.toolLinkIcon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolLinkIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
min-width: 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center !important;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 !important;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// Unboxed: the chevron sits right after the label.
|
||||
:global(.ant-collapse-header-text) {
|
||||
flex: none !important;
|
||||
margin-inline-end: 0 !important;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
@include paragraph-base;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
height: auto !important;
|
||||
padding-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-4) 0 0 !important;
|
||||
}
|
||||
|
||||
&.boxed {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
padding: var(--spacing-3) var(--spacing-5) !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
flex: auto !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 var(--spacing-5) var(--spacing-4) !important;
|
||||
}
|
||||
|
||||
// The block is the box; nested text drops its own.
|
||||
.textBox {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapsibleChevron {
|
||||
display: flex;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.jsonBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.json {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: auto;
|
||||
|
||||
:global(.pretty-view) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jsonCollapsed {
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jsonCopy {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-2);
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import { ThreadMessage } from './types';
|
||||
import CollapsibleBlock from './CollapsibleBlock';
|
||||
import MessagePart from './MessagePart';
|
||||
import PartChip from './PartChip';
|
||||
import RoleLabel from './RoleLabel';
|
||||
import { getFinishReasonTone, getRoleLabel } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
export interface AIThreadMessageProps {
|
||||
message: ThreadMessage;
|
||||
isOutput?: boolean;
|
||||
canLinkToToolCall?: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function AIThreadMessage({
|
||||
message,
|
||||
isOutput,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: AIThreadMessageProps): JSX.Element {
|
||||
const { role, parts: messageParts, finish_reason: finishReason } = message;
|
||||
const roleLabel = getRoleLabel(role);
|
||||
const tone = getFinishReasonTone(finishReason);
|
||||
|
||||
const parts = (
|
||||
<div className={styles.parts}>
|
||||
{messageParts.length === 0 ? (
|
||||
<span className={styles.empty}>No content</span>
|
||||
) : (
|
||||
messageParts.map((part, index) => (
|
||||
<MessagePart
|
||||
// Parts have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
part={part}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.message}
|
||||
data-testid={`ai-thread-message-${role || 'unknown'}`}
|
||||
>
|
||||
{role === 'system' || role === 'developer' ? (
|
||||
<CollapsibleBlock
|
||||
label={roleLabel && <RoleLabel label={roleLabel} />}
|
||||
testId="ai-message-system-toggle"
|
||||
>
|
||||
{parts}
|
||||
</CollapsibleBlock>
|
||||
) : (
|
||||
<>
|
||||
{(roleLabel || isOutput) && (
|
||||
<div className={styles.roleHeader}>
|
||||
{roleLabel && <RoleLabel label={roleLabel} />}
|
||||
{isOutput && (
|
||||
<Badge
|
||||
variant="solid"
|
||||
color={tone === 'error' ? 'danger' : tone}
|
||||
testId="ai-message-output-badge"
|
||||
>
|
||||
Output
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parts}
|
||||
</>
|
||||
)}
|
||||
{finishReason && (
|
||||
<PartChip
|
||||
label="Finish Reason"
|
||||
value={finishReason}
|
||||
tone={tone}
|
||||
testId="ai-message-finish-reason"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AIThreadMessage.defaultProps = {
|
||||
isOutput: false,
|
||||
canLinkToToolCall: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default AIThreadMessage;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import { Collapse } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const PANEL_KEY = 'content';
|
||||
|
||||
interface CollapsibleBlockProps {
|
||||
label: ReactNode;
|
||||
// Boxed: full-width bordered row with the chevron on the right.
|
||||
isBoxed?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleBlock({
|
||||
label,
|
||||
isBoxed = false,
|
||||
testId,
|
||||
children,
|
||||
}: CollapsibleBlockProps): JSX.Element {
|
||||
// Toggling must not count as a click on the message card; the body still does.
|
||||
const stopHeaderClick = (event: MouseEvent): void => {
|
||||
if ((event.target as HTMLElement).closest('.ant-collapse-header')) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div onClick={stopHeaderClick}>
|
||||
<Collapse
|
||||
ghost
|
||||
className={cx(styles.collapsible, isBoxed && styles.boxed)}
|
||||
expandIconPosition="end"
|
||||
expandIcon={({ isActive }): JSX.Element => (
|
||||
<span className={styles.collapsibleChevron} data-testid={testId}>
|
||||
{isActive ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</span>
|
||||
)}
|
||||
items={[{ key: PANEL_KEY, label, children }]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapsibleBlock.defaultProps = {
|
||||
isBoxed: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CollapsibleBlock;
|
||||
@@ -1,57 +0,0 @@
|
||||
import { MouseEvent, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface ExpandableTextProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
function ExpandableText({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: ExpandableTextProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
|
||||
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
className={styles.text}
|
||||
markdownContent={visibleText}
|
||||
variables={{}}
|
||||
isTrusted={false}
|
||||
/>
|
||||
) : (
|
||||
<div className={cx(styles.text, styles.plainText)}>{visibleText}</div>
|
||||
)}
|
||||
{text.length > MAX_TEXT_LENGTH && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-message-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ExpandableText.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default ExpandableText;
|
||||
@@ -1,86 +0,0 @@
|
||||
import { MouseEvent, useMemo, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
// Branch toggles (arrow, key, summary); PrettyView already stops its actions menu.
|
||||
const TREE_TOGGLES = '.pretty-view__nested-row > :not(ul)';
|
||||
const NO_ACTIONS = { leaf: [], nested: [] };
|
||||
|
||||
interface JsonBlockProps {
|
||||
data: object;
|
||||
/** Clamp JSON longer than `MAX_TEXT_LENGTH` behind Show more. */
|
||||
isCollapsible?: boolean;
|
||||
/** Tree levels open on first render; 1 shows top-level keys closed. */
|
||||
expandDepth?: number;
|
||||
}
|
||||
|
||||
// Expanding tree nodes must not count as a click on the message.
|
||||
function JsonBlock({
|
||||
data,
|
||||
isCollapsible = true,
|
||||
expandDepth = 1,
|
||||
}: JsonBlockProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
const isLong = isCollapsible && text.length > MAX_TEXT_LENGTH;
|
||||
|
||||
const stopToggleClick = (event: MouseEvent): void => {
|
||||
if ((event.target as HTMLElement).closest(TREE_TOGGLES)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.jsonBlock}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={cx(styles.json, isLong && !isExpanded && styles.jsonCollapsed)}
|
||||
onClick={stopToggleClick}
|
||||
data-testid="ai-json-block"
|
||||
>
|
||||
<span className={styles.jsonCopy}>
|
||||
<CopyButton
|
||||
value={text}
|
||||
size={12}
|
||||
ariaLabel="Copy JSON"
|
||||
testId="ai-json-copy"
|
||||
/>
|
||||
</span>
|
||||
<PrettyView
|
||||
data={data as Record<string, unknown>}
|
||||
searchable={false}
|
||||
drawerKey="ai-thread-message"
|
||||
visibleActions={NO_ACTIONS}
|
||||
expandDepth={expandDepth}
|
||||
/>
|
||||
</div>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-json-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
JsonBlock.defaultProps = {
|
||||
isCollapsible: true,
|
||||
expandDepth: 1,
|
||||
};
|
||||
|
||||
export default JsonBlock;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { MessagePart as MessagePartType } from './types';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import ThinkingPart from './parts/ThinkingPart';
|
||||
import ToolCallPart from './parts/ToolCallPart';
|
||||
import ToolResultPart from './parts/ToolResultPart';
|
||||
import TextContent from './TextContent';
|
||||
import {
|
||||
isEmptyPart,
|
||||
toDisplayText,
|
||||
toToolCallKey,
|
||||
truncateBlobContent,
|
||||
} from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const EMPTY_PART = (
|
||||
<span className={styles.empty} data-testid="ai-message-empty-part">
|
||||
No content
|
||||
</span>
|
||||
);
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType;
|
||||
canLinkToToolCall?: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagePartProps): JSX.Element {
|
||||
if (isEmptyPart(part)) {
|
||||
return EMPTY_PART;
|
||||
}
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <TextContent text={part.content} isMarkdown />;
|
||||
case 'reasoning':
|
||||
return <ThinkingPart content={part.content} />;
|
||||
case 'compaction':
|
||||
return (
|
||||
<ThinkingPart
|
||||
label="Compaction"
|
||||
content={part.content ?? undefined}
|
||||
testId="ai-message-compaction-toggle"
|
||||
/>
|
||||
);
|
||||
case 'tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id ?? ''}
|
||||
name={part.name ?? ''}
|
||||
args={part.arguments}
|
||||
type={part.type}
|
||||
/>
|
||||
);
|
||||
case 'server_tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id ?? ''}
|
||||
name={part.name ?? ''}
|
||||
args={part.server_tool_call}
|
||||
type={part.type}
|
||||
/>
|
||||
);
|
||||
case 'tool_call_response':
|
||||
case 'server_tool_call_response': {
|
||||
const toolCallId = part.id ?? '';
|
||||
const toolCallKey = toToolCallKey(
|
||||
toolCallId,
|
||||
part.type === 'server_tool_call_response',
|
||||
);
|
||||
return (
|
||||
<ToolResultPart
|
||||
toolCallId={toolCallId}
|
||||
toolCallKey={toolCallKey}
|
||||
type={part.type}
|
||||
content={toDisplayText(
|
||||
part.type === 'tool_call_response'
|
||||
? part.response
|
||||
: part.server_tool_call_response,
|
||||
)}
|
||||
isLinkEnabled={!!toolCallId && !!canLinkToToolCall?.(toolCallKey)}
|
||||
onLinkClick={onToolLinkClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'blob':
|
||||
case 'file':
|
||||
case 'uri':
|
||||
return <JsonBlock data={truncateBlobContent(part)} />;
|
||||
case 'generic':
|
||||
return <TextContent text={toDisplayText(part.content)} />;
|
||||
default:
|
||||
return EMPTY_PART;
|
||||
}
|
||||
}
|
||||
|
||||
MessagePart.defaultProps = {
|
||||
canLinkToToolCall: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default MessagePart;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { MessageTone } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface PartChipProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
tone?: MessageTone;
|
||||
// Plain: no chip background, for headers like a tool call's.
|
||||
isPlain?: boolean;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function PartChip({
|
||||
label,
|
||||
value,
|
||||
tone = 'success',
|
||||
isPlain = false,
|
||||
testId,
|
||||
children,
|
||||
}: PartChipProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cx(styles.chip, isPlain && styles.isPlain)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className={styles.dot} data-tone={tone} />
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
{value !== undefined && (
|
||||
<>
|
||||
<span className={styles.chipSeparator}>—</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartChip.defaultProps = {
|
||||
value: undefined,
|
||||
tone: 'success',
|
||||
isPlain: false,
|
||||
testId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default PartChip;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface RoleLabelProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Message role in the design's Paragraph/Base/400 style. */
|
||||
function RoleLabel({ label }: RoleLabelProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text size="base" weight="normal" className={styles.roleLabel}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleLabel;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ExpandableText from './ExpandableText';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import { parseJsonObject } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface TextContentProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
/** Renders JSON text as a tree, anything else as (markdown) text. */
|
||||
function TextContent({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: TextContentProps): JSX.Element {
|
||||
const json = useMemo(() => parseJsonObject(text), [text]);
|
||||
|
||||
if (json) {
|
||||
return <JsonBlock data={json} />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.textBox}>
|
||||
<ExpandableText text={text} isMarkdown={isMarkdown} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextContent.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default TextContent;
|
||||
@@ -1,63 +0,0 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import AIThreadMessage from '../AIThreadMessage';
|
||||
import JsonBlock from '../JsonBlock';
|
||||
import { MAX_TEXT_LENGTH } from '../utils';
|
||||
|
||||
describe('AIThreadMessage', () => {
|
||||
it('shows the Output badge when the message has no role', () => {
|
||||
render(
|
||||
<AIThreadMessage
|
||||
message={{ parts: [{ type: 'text', content: 'hi' }] }}
|
||||
isOutput
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-message-output-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['blank text', { type: 'text' as const, content: ' ' }],
|
||||
['empty generic', { type: 'generic' as const, content: '' }],
|
||||
])('renders No content for a %s part', (_label, part) => {
|
||||
render(<AIThreadMessage message={{ role: 'user', parts: [part] }} />);
|
||||
|
||||
expect(screen.getByTestId('ai-message-empty-part')).toHaveTextContent(
|
||||
'No content',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JsonBlock', () => {
|
||||
const big = { value: 'x'.repeat(MAX_TEXT_LENGTH) };
|
||||
|
||||
it('collapses large JSON behind Show more', () => {
|
||||
render(<JsonBlock data={big} />);
|
||||
|
||||
const toggle = screen.getByTestId('ai-json-show-more');
|
||||
expect(toggle).toHaveTextContent('Show more');
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveTextContent('Show less');
|
||||
});
|
||||
|
||||
it('does not collapse small or non-collapsible JSON', () => {
|
||||
const { rerender } = render(<JsonBlock data={{ a: 1 }} />);
|
||||
expect(screen.queryByTestId('ai-json-show-more')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<JsonBlock data={big} isCollapsible={false} />);
|
||||
expect(screen.queryByTestId('ai-json-show-more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts with nested keys collapsed', () => {
|
||||
render(<JsonBlock data={{ outer: { inner: 'hidden-value' } }} />);
|
||||
|
||||
expect(screen.getByText(/outer/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/hidden-value/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a copy button', () => {
|
||||
render(<JsonBlock data={{ a: 1 }} />);
|
||||
|
||||
expect(screen.getByTestId('ai-json-copy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { MessagePart, MessagePartType, ThreadMessage } from './types';
|
||||
import { toDisplayText } from './utils';
|
||||
|
||||
const PART_TYPES = new Set<string>([
|
||||
'text',
|
||||
'reasoning',
|
||||
'compaction',
|
||||
'tool_call',
|
||||
'tool_call_response',
|
||||
'server_tool_call',
|
||||
'server_tool_call_response',
|
||||
'blob',
|
||||
'file',
|
||||
'uri',
|
||||
'generic',
|
||||
] satisfies MessagePartType[]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
function toPart(part: unknown): MessagePart {
|
||||
if (typeof part === 'string') {
|
||||
return { type: 'text', content: part };
|
||||
}
|
||||
if (
|
||||
!isRecord(part) ||
|
||||
typeof part.type !== 'string' ||
|
||||
!PART_TYPES.has(part.type)
|
||||
) {
|
||||
return { type: 'generic', content: part };
|
||||
}
|
||||
switch (part.type) {
|
||||
case 'text': {
|
||||
// Some SDKs write "text" in place of the schema's "content".
|
||||
const text = part.content ?? part.text;
|
||||
return {
|
||||
type: 'text',
|
||||
content: typeof text === 'string' ? text : toDisplayText(text),
|
||||
};
|
||||
}
|
||||
case 'reasoning':
|
||||
return {
|
||||
type: 'reasoning',
|
||||
content: optionalString(part.content ?? part.text),
|
||||
};
|
||||
case 'generic':
|
||||
return { type: 'generic', content: part.content ?? part };
|
||||
default:
|
||||
return part as MessagePart;
|
||||
}
|
||||
}
|
||||
|
||||
function toThreadMessage(item: unknown): ThreadMessage {
|
||||
if (!isRecord(item)) {
|
||||
return { parts: [toPart(item)] };
|
||||
}
|
||||
const body = item.parts ?? item.content;
|
||||
return {
|
||||
role: optionalString(item.role),
|
||||
name: optionalString(item.name),
|
||||
finish_reason: optionalString(item.finish_reason),
|
||||
parts: Array.isArray(body) ? body.map(toPart) : [toPart(body ?? item)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalises formatted or raw `gen_ai.*.messages` values into thread messages. */
|
||||
export function toThreadMessages(value: unknown): ThreadMessage[] {
|
||||
if (value === undefined || value === null) {
|
||||
return [];
|
||||
}
|
||||
return (Array.isArray(value) ? value : [value]).map(toThreadMessage);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import CollapsibleBlock from '../CollapsibleBlock';
|
||||
import TextContent from '../TextContent';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ThinkingPartProps {
|
||||
content?: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ThinkingPart({
|
||||
content,
|
||||
label = 'Thinking',
|
||||
testId = 'ai-message-thinking-toggle',
|
||||
}: ThinkingPartProps): JSX.Element {
|
||||
return (
|
||||
<CollapsibleBlock label={label} isBoxed testId={testId}>
|
||||
{content ? (
|
||||
<TextContent text={content} isMarkdown />
|
||||
) : (
|
||||
<span className={styles.empty}>Content not captured</span>
|
||||
)}
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
|
||||
ThinkingPart.defaultProps = {
|
||||
content: undefined,
|
||||
label: 'Thinking',
|
||||
testId: 'ai-message-thinking-toggle',
|
||||
};
|
||||
|
||||
export default ThinkingPart;
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import JsonBlock from '../JsonBlock';
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { parseJsonObject, TOOL_CALL_ID_ATTR, toToolCallKey } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolCallPartProps {
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
type: 'tool_call' | 'server_tool_call';
|
||||
}
|
||||
|
||||
function ToolCallPart({
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
type,
|
||||
}: ToolCallPartProps): JSX.Element {
|
||||
// Streamed calls can carry truncated, unparseable argument strings.
|
||||
const json = useMemo(() => {
|
||||
if (typeof args === 'string') {
|
||||
return parseJsonObject(args);
|
||||
}
|
||||
return typeof args === 'object' && args !== null ? args : undefined;
|
||||
}, [args]);
|
||||
|
||||
const toolCallAttr = {
|
||||
[TOOL_CALL_ID_ATTR]: toToolCallKey(id, type === 'server_tool_call'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
|
||||
<PartChip
|
||||
label={type}
|
||||
tone="info"
|
||||
isPlain
|
||||
value={
|
||||
<>
|
||||
<span className={styles.chipKey}>name :</span>
|
||||
<span className={styles.toolName}>{name}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{json ? (
|
||||
<JsonBlock data={json} />
|
||||
) : (
|
||||
args !== undefined &&
|
||||
args !== null && (
|
||||
<TextContent
|
||||
text={typeof args === 'string' ? args : JSON.stringify(args)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolCallPart;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolResultPartProps {
|
||||
toolCallId: string;
|
||||
toolCallKey: string;
|
||||
type: 'tool_call_response' | 'server_tool_call_response';
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
isLinkEnabled: boolean;
|
||||
onLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function ToolResultPart({
|
||||
toolCallId,
|
||||
toolCallKey,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
isError,
|
||||
isLinkEnabled,
|
||||
onLinkClick,
|
||||
}: ToolResultPartProps): JSX.Element {
|
||||
// Short plain results fit in the chip; JSON or long ones render below it.
|
||||
const isInline = useMemo(
|
||||
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const handleLinkClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
onLinkClick?.(toolCallKey);
|
||||
};
|
||||
|
||||
const chip = (
|
||||
<PartChip
|
||||
label={type}
|
||||
value={isInline ? content || '…' : name || toolCallId}
|
||||
tone={isError ? 'error' : 'success'}
|
||||
>
|
||||
{isLinkEnabled && (
|
||||
<CornerUpRight size={12} className={styles.toolLinkIcon} />
|
||||
)}
|
||||
</PartChip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.toolResult} data-testid="ai-tool-result">
|
||||
{isLinkEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toolLink}
|
||||
onClick={handleLinkClick}
|
||||
title="Go to tool call"
|
||||
data-testid="ai-tool-result-link"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
) : (
|
||||
chip
|
||||
)}
|
||||
{!isInline && <TextContent text={content} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolResultPart.defaultProps = {
|
||||
name: undefined,
|
||||
isError: false,
|
||||
onLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default ToolResultPart;
|
||||
@@ -1,52 +0,0 @@
|
||||
import type {
|
||||
AiobservabilitytypesBlobPartDTO,
|
||||
AiobservabilitytypesCompactionPartDTO,
|
||||
AiobservabilitytypesFilePartDTO,
|
||||
AiobservabilitytypesOutputMessageDTO,
|
||||
AiobservabilitytypesReasoningPartDTO,
|
||||
AiobservabilitytypesServerToolCallPartDTO,
|
||||
AiobservabilitytypesServerToolCallResponsePartDTO,
|
||||
AiobservabilitytypesTextPartDTO,
|
||||
AiobservabilitytypesToolCallRequestPartDTO,
|
||||
AiobservabilitytypesToolCallResponsePartDTO,
|
||||
AiobservabilitytypesUriPartDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Generated enum `type`s as string literals, so `part.type === 'text'` narrows.
|
||||
type Part<T extends { type: string }> = Omit<T, 'type'> & {
|
||||
type: `${T['type']}`;
|
||||
};
|
||||
|
||||
// Keys the schema marks required may still be missing.
|
||||
type LoosePart<T extends { type: string }> = Partial<Omit<T, 'type'>> & {
|
||||
type: `${T['type']}`;
|
||||
};
|
||||
|
||||
/** OTel GenAI message part; `text` content is always normalised to a string. */
|
||||
export type MessagePart =
|
||||
| Part<AiobservabilitytypesTextPartDTO>
|
||||
| LoosePart<AiobservabilitytypesReasoningPartDTO>
|
||||
| LoosePart<AiobservabilitytypesCompactionPartDTO>
|
||||
| LoosePart<AiobservabilitytypesToolCallRequestPartDTO>
|
||||
| LoosePart<AiobservabilitytypesToolCallResponsePartDTO>
|
||||
| LoosePart<AiobservabilitytypesServerToolCallPartDTO>
|
||||
| LoosePart<AiobservabilitytypesServerToolCallResponsePartDTO>
|
||||
| LoosePart<AiobservabilitytypesBlobPartDTO>
|
||||
| LoosePart<AiobservabilitytypesFilePartDTO>
|
||||
| LoosePart<AiobservabilitytypesUriPartDTO>
|
||||
| { type: 'generic'; content?: unknown };
|
||||
|
||||
export type MessagePartType = MessagePart['type'];
|
||||
|
||||
export type MediaMessagePart = Extract<
|
||||
MessagePart,
|
||||
{ type: 'blob' | 'file' | 'uri' }
|
||||
>;
|
||||
|
||||
// role: system | developer | user | assistant | tool; any other value renders headerless.
|
||||
// finish_reason: stop | tool_call | length | content_filter | compaction | error, or provider-specific.
|
||||
export type ThreadMessage = Partial<
|
||||
Omit<AiobservabilitytypesOutputMessageDTO, 'parts'>
|
||||
> & {
|
||||
parts: MessagePart[];
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { TOOL_CALL_ID_ATTR } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const HIGHLIGHT_MS = 1500;
|
||||
|
||||
interface ToolCallLinks {
|
||||
canLinkToToolCall: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
|
||||
export function useToolCallLinks(
|
||||
rootRef: RefObject<HTMLElement>,
|
||||
toolCallKeys: Set<string>,
|
||||
): ToolCallLinks {
|
||||
const canLinkToToolCall = useCallback(
|
||||
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
|
||||
[toolCallKeys],
|
||||
);
|
||||
|
||||
const onToolLinkClick = useCallback(
|
||||
(toolCallKey: string): void => {
|
||||
const target = rootRef.current?.querySelector<HTMLElement>(
|
||||
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add(styles.isHighlighted);
|
||||
window.setTimeout(
|
||||
() => target.classList.remove(styles.isHighlighted),
|
||||
HIGHLIGHT_MS,
|
||||
);
|
||||
},
|
||||
[rootRef],
|
||||
);
|
||||
|
||||
return { canLinkToToolCall, onToolLinkClick };
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { attempt, isError, isObjectLike } from 'lodash-es';
|
||||
|
||||
import { MediaMessagePart, MessagePart, ThreadMessage } from './types';
|
||||
|
||||
export const MAX_TEXT_LENGTH = 1000;
|
||||
|
||||
const MAX_BLOB_PREVIEW_LENGTH = 100;
|
||||
|
||||
export const MAX_INLINE_RESULT_LENGTH = 80;
|
||||
|
||||
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
developer: 'Developer',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tool: 'Tool',
|
||||
};
|
||||
|
||||
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
// Provider values the backend could not map fall back to info.
|
||||
const FINISH_REASON_TONES: Record<string, MessageTone> = {
|
||||
stop: 'success',
|
||||
tool_call: 'success',
|
||||
length: 'warning',
|
||||
content_filter: 'warning',
|
||||
compaction: 'info',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/** Undefined for missing or unknown roles, which render headerless. */
|
||||
export function getRoleLabel(role?: string): string | undefined {
|
||||
return role ? ROLE_LABELS[role] : undefined;
|
||||
}
|
||||
|
||||
export function getFinishReasonTone(reason?: string | null): MessageTone {
|
||||
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
|
||||
}
|
||||
|
||||
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
|
||||
export function parseJsonObject(text: string): object | undefined {
|
||||
const parsed: unknown = attempt(JSON.parse, text);
|
||||
return isObjectLike(parsed) && !isError(parsed)
|
||||
? (parsed as object)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toDisplayText(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
/** Text and generic parts with only whitespace have nothing to render. */
|
||||
export function isEmptyPart(part: MessagePart): boolean {
|
||||
if (part.type === 'text') {
|
||||
return part.content.trim() === '';
|
||||
}
|
||||
if (part.type === 'generic') {
|
||||
return toDisplayText(part.content).trim() === '';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Blob content is inline base64; only a prefix is shown. */
|
||||
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
|
||||
if (
|
||||
part.type !== 'blob' ||
|
||||
!part.content ||
|
||||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
|
||||
) {
|
||||
return part;
|
||||
}
|
||||
return {
|
||||
...part,
|
||||
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server and client calls can share an id, so links match on kind too.
|
||||
export function toToolCallKey(id: string, isServer: boolean): string {
|
||||
return `${isServer ? 'server' : 'client'}:${id}`;
|
||||
}
|
||||
|
||||
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
message.parts.flatMap((part) =>
|
||||
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
|
||||
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: TooltipProps['title'];
|
||||
tooltip: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -16,8 +16,7 @@ function EntityMetadataItem({
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
// Below the row: above it the tooltip covers the Overview / Thread tabs.
|
||||
<Tooltip title={tooltip} side="bottom">
|
||||
<Tooltip title={tooltip}>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
|
||||
@@ -8,12 +8,3 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getTotalInputTokens,
|
||||
hasValue,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -24,8 +16,6 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -42,11 +32,8 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const totalInput = tokens && getTotalInputTokens(tokens);
|
||||
const durationTooltip =
|
||||
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
|
||||
// Single source of duration formatting so both rows label units identically.
|
||||
@@ -104,24 +91,6 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{hasValue(cost) && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,8 +102,6 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import { AiTokenCounts } from '../utils/aiTraceUtils';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: AiTokenCounts;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="normal"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,43 +0,0 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.accordion {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center !important;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-4) !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
@include custom-scrollbar;
|
||||
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: flex;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { MouseEvent, ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import { Collapse } from 'antd';
|
||||
|
||||
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../../AIThreadMessage/types';
|
||||
import MessagesSection from '../../SpanMessages/MessagesSection';
|
||||
import { RawMessagesValue } from '../../SpanMessages/utils';
|
||||
import { ThreadView } from '../../TraceDetailsThread/types';
|
||||
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
|
||||
|
||||
import styles from './MessagesAccordion.module.scss';
|
||||
|
||||
const PANEL_KEY = 'messages';
|
||||
|
||||
interface MessagesAccordionProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'isOutput' | 'canLinkToToolCall' | 'onToolLinkClick'
|
||||
> {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesAccordion({
|
||||
icon,
|
||||
title,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
isOutput,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesAccordionProps): JSX.Element | null {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [view, setView] = useState(ThreadView.Formatted);
|
||||
const canFormat = messages.length > 0;
|
||||
const effectiveView = canFormat ? view : ThreadView.Json;
|
||||
|
||||
if (!canFormat && !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stopToggle = (event: MouseEvent): void => event.stopPropagation();
|
||||
|
||||
return (
|
||||
<section data-testid={testId}>
|
||||
<Collapse
|
||||
className={styles.accordion}
|
||||
activeKey={isOpen ? [PANEL_KEY] : []}
|
||||
onChange={(): void => setIsOpen((prev) => !prev)}
|
||||
expandIconPosition="end"
|
||||
expandIcon={({ isActive }): JSX.Element => (
|
||||
<span className={styles.chevron} data-testid={`${testId}-toggle`}>
|
||||
{isActive ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
key: PANEL_KEY,
|
||||
label: (
|
||||
<span className={styles.title}>
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
extra: isOpen &&
|
||||
canFormat && (
|
||||
// The view toggle lives in the header; its clicks must not fold the panel.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<span onClick={stopToggle}>
|
||||
<ThreadViewToggle
|
||||
value={effectiveView}
|
||||
onChange={setView}
|
||||
testId={`${testId}-view-toggle`}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<MessagesSection
|
||||
view={effectiveView}
|
||||
messages={messages}
|
||||
raw={raw}
|
||||
testId={`${testId}-body`}
|
||||
isOutput={isOutput}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesAccordion.defaultProps = {
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesAccordion;
|
||||
@@ -1,7 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import { getSpanMessages, hasAnyMessages } from '../../SpanMessages/utils';
|
||||
import { ThreadSpan } from '../../TraceDetailsThread/types';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
import MessagesAccordion from './MessagesAccordion';
|
||||
|
||||
import styles from './SpanAIMessages.module.scss';
|
||||
|
||||
interface SpanAIMessagesProps {
|
||||
span: ThreadSpan;
|
||||
}
|
||||
|
||||
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
|
||||
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
|
||||
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
|
||||
rootRef,
|
||||
toolCallKeys,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef} data-testid="span-ai-messages">
|
||||
<MessagesAccordion
|
||||
icon={<ArrowUpRight size={14} />}
|
||||
title="Input"
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
testId="span-ai-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
<MessagesAccordion
|
||||
icon={<ArrowDownRight size={14} />}
|
||||
title="Output"
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
isOutput
|
||||
testId="span-ai-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanAIMessages;
|
||||
@@ -34,15 +34,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Inside the Overview tab there's no flex gap, so match .panelBody's 16px.
|
||||
.tabAiMessages {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
|
||||
// a true fixed footer flush with the panel's bottom edge — always visible
|
||||
// regardless of how the body is scrolled.
|
||||
|
||||
@@ -48,7 +48,6 @@ import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
@@ -282,7 +281,6 @@ function SpanDetailsContent({
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
const aiMessages = <SpanAIMessages span={selectedSpan} />;
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
const spanDetailTabs: TabsItemProps[] = [
|
||||
@@ -293,7 +291,6 @@ function SpanDetailsContent({
|
||||
children: (
|
||||
<>
|
||||
{isWide && summary}
|
||||
{isWide && <div className={styles.tabAiMessages}>{aiMessages}</div>}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
@@ -372,7 +369,6 @@ function SpanDetailsContent({
|
||||
<>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
{!isWide && aiMessages}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
<Tabs
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -33,10 +31,6 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,8 +63,6 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={aiUsage && convertToAiTokens(aiUsage)}
|
||||
cost={aiUsage?.cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -95,26 +95,4 @@ describe('SpanSummary', () => {
|
||||
|
||||
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens and cost from the gen_ai usage attributes', () => {
|
||||
const span = {
|
||||
...createMockSpan(),
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000009,
|
||||
},
|
||||
};
|
||||
render(<SpanSummary selectedSpan={span} />);
|
||||
|
||||
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits tokens and cost for non-AI spans', () => {
|
||||
render(<SpanSummary selectedSpan={createMockSpan()} />);
|
||||
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Badge
|
||||
variant="outlined"
|
||||
color={color}
|
||||
testId={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: BadgeColorType }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'success' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'warning' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'highlight-danger' },
|
||||
};
|
||||
@@ -8,12 +8,16 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -9,17 +9,28 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -8,11 +7,6 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -39,7 +33,6 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -49,60 +42,33 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
className={styles.title}
|
||||
size="small"
|
||||
weight="normal"
|
||||
style={{ color }}
|
||||
>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="normal">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow
|
||||
label="status"
|
||||
value={hasError ? 'error' : 'ok'}
|
||||
testId="span-hover-card-status"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
testId="span-hover-card-start"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
testId="span-hover-card-duration"
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<TraceTooltipSection>
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<TraceTooltipRow
|
||||
<div
|
||||
key={row.key}
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</TraceTooltipSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -164,7 +130,6 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
reasoning={reasoningTokens}
|
||||
/>
|
||||
{hasValue(cost) && (
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -1,68 +0,0 @@
|
||||
import AIThreadMessage, {
|
||||
AIThreadMessageProps,
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadView } from '../TraceDetailsThread/types';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
type MessageProps = Pick<
|
||||
AIThreadMessageProps,
|
||||
'isOutput' | 'canLinkToToolCall' | 'onToolLinkClick'
|
||||
>;
|
||||
|
||||
interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
isOutput,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{isFormatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
// Messages have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
message={message}
|
||||
isOutput={isOutput}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
@@ -1,21 +0,0 @@
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface RawMessagesProps {
|
||||
value: RawMessagesValue;
|
||||
}
|
||||
|
||||
function RawMessages({ value }: RawMessagesProps): JSX.Element {
|
||||
if (typeof value === 'object') {
|
||||
return <JsonBlock data={value} isCollapsible={false} />;
|
||||
}
|
||||
return (
|
||||
<pre className={styles.raw} data-testid="span-raw-messages">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default RawMessages;
|
||||
@@ -1,51 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.raw {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--l1-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.missing {
|
||||
padding: var(--spacing-10) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import { getSpanMessages, hasFormattedMessages, hasRawMessages } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface SpanMessagesProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'canLinkToToolCall' | 'onToolLinkClick'
|
||||
> {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Input and output messages of one AI span, shared by Thread and Span Details. */
|
||||
function SpanMessages({
|
||||
span,
|
||||
view,
|
||||
className,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
|
||||
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.missing, className)}
|
||||
data-testid="span-messages-empty"
|
||||
>
|
||||
This span has no input or output.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFormatted) {
|
||||
return (
|
||||
<div className={cx(styles.root, className)} data-testid="span-messages-json">
|
||||
<JsonBlock
|
||||
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
|
||||
expandDepth={Infinity}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, className)}>
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
testId="span-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
isOutput
|
||||
testId="span-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SpanMessages.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default SpanMessages;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
|
||||
import SpanMessages from '../SpanMessages';
|
||||
|
||||
const makeSpan = (overrides: Partial<ThreadSpan>): ThreadSpan =>
|
||||
({ span_id: 's1', attributes: {}, ...overrides }) as unknown as ThreadSpan;
|
||||
|
||||
describe('SpanMessages', () => {
|
||||
it('shows the empty state when there are no formatted messages', () => {
|
||||
const span = makeSpan({
|
||||
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Formatted} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-empty')).toHaveTextContent(
|
||||
'This span has no input or output.',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state in JSON view when there are no raw messages', () => {
|
||||
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders raw messages in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens every key in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
|
||||
},
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses large JSON behind Show more in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
|
||||
{ role: 'user', content: 'x'.repeat(2000) },
|
||||
]),
|
||||
},
|
||||
} as Partial<ThreadSpan>);
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('ai-json-show-more')).toHaveTextContent(
|
||||
'Show more',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
|
||||
import SpanMessages from '../SpanMessages';
|
||||
|
||||
const makeSpan = (overrides: Partial<ThreadSpan>): ThreadSpan =>
|
||||
({
|
||||
span_id: 'span-1',
|
||||
name: 'chat gpt-4o',
|
||||
attributes: {},
|
||||
...overrides,
|
||||
}) as unknown as ThreadSpan;
|
||||
|
||||
const LARGE_ARGUMENTS = {
|
||||
query: 'weather forecast',
|
||||
locations: Array.from({ length: 12 }, (_, index) => ({
|
||||
city: `City ${index + 1}`,
|
||||
coordinates: { lat: 10 + index, lon: 20 + index },
|
||||
units: 'metric',
|
||||
})),
|
||||
};
|
||||
|
||||
const RAW_INPUT = [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [{ type: 'text', content: 'You are a weather bot.' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', content: 'What is the weather in every city?' }],
|
||||
},
|
||||
];
|
||||
|
||||
const RAW_OUTPUT = [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_1',
|
||||
name: 'get_weather',
|
||||
arguments: LARGE_ARGUMENTS,
|
||||
},
|
||||
],
|
||||
finish_reason: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const rawAttributes = {
|
||||
[GEN_AI_KEYS.inputMessages]: JSON.stringify(RAW_INPUT),
|
||||
[GEN_AI_KEYS.outputMessages]: JSON.stringify(RAW_OUTPUT),
|
||||
};
|
||||
|
||||
const clickShowMore = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const toggles =
|
||||
await within(canvasElement).findAllByTestId('ai-json-show-more');
|
||||
await toggles.reduce(
|
||||
(previous, toggle) => previous.then(() => userEvent.click(toggle)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
};
|
||||
|
||||
// Input and output messages of one AI span, as rendered on a Thread card.
|
||||
// Each story isolates one behaviour: the empty state, role-less output,
|
||||
// empty parts, and JSON blocks (copy, Show more, initial key expansion).
|
||||
const meta = {
|
||||
title: 'Pages/Traces/Trace Details/Span Messages',
|
||||
component: SpanMessages,
|
||||
args: { view: ThreadView.Formatted, span: makeSpan({}) },
|
||||
argTypes: {
|
||||
view: { control: 'inline-radio', options: Object.values(ThreadView) },
|
||||
span: { control: false },
|
||||
},
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<div style={{ maxWidth: 720, padding: 24 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof SpanMessages>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** No formatted messages: the dashed "no input or output" box. */
|
||||
export const NoInputOrOutput: Story = {};
|
||||
|
||||
/** JSON view with no raw attributes: the same empty box. */
|
||||
export const NoInputOrOutputJson: Story = {
|
||||
args: { view: ThreadView.Json },
|
||||
};
|
||||
|
||||
/** Formatted view while only raw attributes exist: still the empty box. */
|
||||
export const RawOnlyFormatted: Story = {
|
||||
args: { span: makeSpan({ attributes: rawAttributes }) },
|
||||
};
|
||||
|
||||
/** An output message with no role keeps its Output badge. */
|
||||
export const OutputWithoutRole: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_output: [
|
||||
{
|
||||
parts: [{ type: 'text', content: 'Answer from a role-less message.' }],
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Blank text, empty generic and an unknown part each render "No content". */
|
||||
export const EmptyParts: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'text', content: ' ' },
|
||||
{ type: 'generic', content: '' },
|
||||
{ type: 'unknown_kind' } as never,
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Large tool-call JSON: keys closed, clamped behind Show more, with copy. */
|
||||
export const LargeJsonPart: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_1',
|
||||
name: 'get_weather',
|
||||
arguments: LARGE_ARGUMENTS,
|
||||
},
|
||||
],
|
||||
finish_reason: 'tool_call',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** The large tool-call JSON after Show more. */
|
||||
export const LargeJsonPartExpanded: Story = {
|
||||
...LargeJsonPart,
|
||||
play: async ({ canvasElement }): Promise<void> => clickShowMore(canvasElement),
|
||||
};
|
||||
|
||||
/** Small JSON part: no Show more, copy still available. */
|
||||
export const SmallJsonPart: Story = {
|
||||
args: {
|
||||
span: makeSpan({
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_2',
|
||||
name: 'get_time',
|
||||
arguments: { tz: 'UTC' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** JSON tab: every key open, clamped behind Show more, with copy. */
|
||||
export const JsonView: Story = {
|
||||
args: { view: ThreadView.Json, span: makeSpan({ attributes: rawAttributes }) },
|
||||
};
|
||||
|
||||
/** The JSON tab after Show more. */
|
||||
export const JsonViewExpanded: Story = {
|
||||
...JsonView,
|
||||
play: async ({ canvasElement }): Promise<void> => clickShowMore(canvasElement),
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user