Compare commits

..

5 Commits

Author SHA1 Message Date
Abhi Kumar
5afc3c1439 refactor(dashboard): group panel type picker into components and hooks 2026-10-16 23:56:35 +05:30
Aditya Singh
dccd75e1de feat(saved-views): gate saved views on their permissions (#13027)
#### Description
- Saved views now follow the `saved-view` permissions:
  - `list` gates the views panel.
  - `create` gates `+`, "Save as new view" and "Create view".
- `read` gates opening a view. List rows for views the user cannot read
are disabled, with the reason on hover.
  - `update` and `delete` gate the row menu and the `✓` menu, per view.
- Opening a view checks `read` first. Without it, the view is not
fetched and the header shows "No view access".
- Denied buttons and menu items stay visible. They are disabled and show
the reason on hover. They use `AuthZButton` and `AuthZDropdown`.
- The row menu has "Copy ID", so a user can copy a view's id to use in a
role.
- The authz generator now includes `saved-view`, and roles settings gets
a Saved Views panel.
- Stories for the header and the views panel are under `Components/Saved
Views`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/76


#### Screenshot / Screenrecording



https://github.com/user-attachments/assets/c6505fb1-5a57-4c61-ad7e-09531d500002

<img width="490" height="982" alt="Screenshot 2026-10-15 at 12 13 00 PM"
src="https://github.com/user-attachments/assets/b8ab7760-c0c9-4ee7-8201-c8dae8cdfd57"
/>


<img width="349" height="567" alt="Screenshot 2026-10-15 at 12 13 32 PM"
src="https://github.com/user-attachments/assets/ee7e0ebc-e929-4ff1-a624-e605fe09cb95"
/>


<img width="693" height="955" alt="Screenshot 2026-10-15 at 12 14 10 PM"
src="https://github.com/user-attachments/assets/023c022a-5105-471a-acce-835251f9ea3f"
/>


<img width="1458" height="2016" alt="image"
src="https://github.com/user-attachments/assets/c33d5e65-dae7-454e-a5e0-2b7796e204cc"
/>


#### Additional Information
- The change is behind `SAVED_VIEW_ENABLED`.
- On staging, the per view update and delete checks do not show in the
AuthZ DevTools. List and create work. The backend team must check this
before the PR moves out of draft.
- The save as new prompt and the unsaved changes nudge are not in this
PR.
2026-10-16 17:32:31 +00:00
Nikhil Mantri
4f3e7fa50e feat(ruler): add clone alert rule API (#13093)
#### Description

- Adds `POST /api/v2/rules/{id}/clone`, a server-side clone for alert
rules. The caller passes only the source rule's id and gets back the
newly created rule.
- Until now, cloning was done entirely by the frontend, which re-posted
the whole rule it had in memory to the create endpoint. The new endpoint
copies the rule from what is actually persisted, so clones are
consistent regardless of the client.
- The clone keeps every modeled field of the source, including its
enabled or disabled state, and is owned by the user who cloned it.
Cloning fails with a 400 that names the source rule when it no longer
passes create validation, for example when its channel has been deleted.
- The name follows the convention introduced by the dashboard clone API:
`My Alert - Copy`, then `My Alert - Copy (2)`, `My Alert - Copy (3)`
when a copy is cloned again. The same suffix logic was adopted, minus
the length cap, since alert names have no limit today.
- The endpoint reuses the existing create path, so validation,
notification routing and evaluator start-up behave exactly as a manual
create.
- Includes unit tests for the naming and copy logic and an integration
test covering clone, clone of a clone, unknown id and malformed id.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#228

#### Additional Information

- Backend only. The frontend still uses the client-side approach and can
switch to this endpoint in a follow-up.
- Names are not de-duplicated against existing rules, matching the
dashboard clone behaviour. Rule names are not unique today, so a
collision is harmless.
2026-10-16 17:23:32 +00:00
Nityananda Gohain
531504c523 fix(tracedetail): keep litellm tool calls and AI SDK JSON answers, mark redacted media (#13166)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- AI SDK: a JSON answer such as `{"answer": 42}` was dropped, and a JSON
list was read as nameless tool calls. The output is now tool calls only
when every item has `toolName`;
  anything else is kept as text.
- litellm: parallel calls (`gen_ai.completion.N` numbers tool calls, not
choices), a call next to text, and the history's `tool_calls` /
`tool_call_id` kept beside `parts` now become tool call and tool
response parts.
- Redaction placeholders (OpenInference's `__REDACTED__`, OpenRouter's
`[OR_REDACTED]`) no longer become `uri` parts: media without a URL
scheme is a generic part, with a warning.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of
https://github.com/SigNoz/nerve-pod/issues/266#issuecomment-6198620761

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- Checked against real spans from litellm 1.105.1, OpenInference 0.1.49
and AI SDK 5.0.277 run against a local stub server. Replaying the
static-telemetry-generator captures changes only the four affected
spans.
- OpenInference redacts `input.value` too, so a redacted image cannot be
recovered from the raw request.
2026-10-16 14:23:02 +00:00
Nikhil Soni
b770c249a0 feat(querier): read traces json numbers as toFloat64OrNull and index that (#13101)
#### Description

- The querier reads a traces JSON number as
`toFloat64OrNull(p::String)`: every numeric type and a numeric string
read as Float64. Replaces the `dynamicType` gate around
`accurateCastOrNull`.
- The `toFloat64` collision cast leaves a read already starting
`toFloat64OrNull(` bare.

Why avoiding the `toFloat64` wrapper on json number queries?
The wrapper exists for the map (`Bad get: has UInt64, requested
Float64`) and hid the read from its skip index.



#### Additional Information

- A number-typed key now matches a value stored as a numeric string
(`"404"`), and an integer above 2^53 reads rounded instead of NULL.

#### Performance Impact
Almost none

```
case: num = all values already numbers
case: mixed = values are numbers and stringified mixed
rows: 20M
┌──────────────────────────────────────────────────────┬───────┬─────────────┬─────────┐
│                         Read                         │  num  │    mixed    │   ≈     │
│                                                      │       │             │ ns/row  │
├──────────────────────────────────────────────────────┼───────┼─────────────┼─────────┤
│ new toFloat64OrNull(p::String)                       │ 0.56s │ 0.56s       │ 28      │
├──────────────────────────────────────────────────────┼───────┼─────────────┼─────────┤
│ old dynamicType gate (the QB wrapped it in           │ 0.57s │ 0.77s       │ 29 / 38 │
│ toFloat64)                                           │       │             │         │
└──────────────────────────────────────────────────────┴───────┴─────────────┴─────────┘
```
2026-10-16 14:04:08 +00:00
203 changed files with 3268 additions and 6237 deletions

View File

@@ -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{}

View File

@@ -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

View File

@@ -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(),

View File

@@ -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{

View File

@@ -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

View File

@@ -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',

View File

@@ -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();

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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 };

View File

@@ -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',

View File

@@ -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,

View File

@@ -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()}
>

View File

@@ -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',

View File

@@ -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.',

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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 => {

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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), []);

View File

@@ -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}
/>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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();
});

View File

@@ -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),
);
});
});

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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();

View File

@@ -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;

View File

@@ -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');
}

View File

@@ -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,
},
];

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View 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) }),
};

View 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);
},
};

View 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()}`;
};

View File

@@ -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,

View File

@@ -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',

View File

@@ -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}`);

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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'

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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[];
};

View File

@@ -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 };
}

View File

@@ -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')]
: [],
),
);
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -1,7 +0,0 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex-shrink: 0;
min-width: 0;
}

View File

@@ -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;

View File

@@ -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.

View File

@@ -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

View File

@@ -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}>

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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' },
};

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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),
},
};
}, [

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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',
);
});
});

View File

@@ -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