Compare commits

..

1 Commits

Author SHA1 Message Date
aks07
886e1dde9d feat(traces): show columns scope in traces list export 2026-10-21 11:35:39 +05:30
207 changed files with 1840 additions and 9158 deletions

View File

@@ -105,13 +105,6 @@ is there to look at. Shooting two narrow runs and diffing those against each
other has the same problem. Either shoot the full set twice, or skip `--noise`
and explain the few movers by hand.
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the
noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run
hides a real 8% change in the real one. Compare the two numbers before trusting
the mark. When most of the flaky marks are hiding real changes, run the diff
without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing
real is filtered out of the reviewer's view.
That is the trade: a second full capture costs about as long as the first, so on
a small diff it is often quicker to crop the two or three movers and say what
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
@@ -145,15 +138,6 @@ eye.
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how
many to shoot at once by sampling free memory, and off Linux it reads
`os.freemem()`, which counts only truly free pages. macOS parks almost
everything in "inactive" instead, which is reclaimable on demand, so the
sampler can read ~100 MB on a 25 GB machine and shrink to one forever.
`--jobs` does not help: it is only an upper cap. Check `parallelism` in
`<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes
inactive, that is this. A 124-shot run takes about 90 minutes pinned and about
6 minutes at four at a time, so it is worth spotting before a long capture.
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.

View File

@@ -75,7 +75,6 @@ jobs:
- ttl
- clickhousecluster
- metricreduction
- metricsexplorer
sqlstore-provider:
- postgres
- sqlite

View File

@@ -41,6 +41,7 @@ linters:
no-global: all
context: all
static-msg: true
key-naming-case: snake
exclusions:
generated: lax
presets:

View File

@@ -3581,7 +3581,6 @@ components:
- meter-metrics
- logs-field
- traces-field
- metrics-field
type: string
CoretypesObject:
properties:
@@ -20638,9 +20637,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics-field:list
- VIEWER
- tokenizer:
- metrics-field:list
- VIEWER
summary: List metric names
tags:
- metrics
@@ -20707,9 +20706,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: Get metric alerts
tags:
- metrics
@@ -20791,9 +20790,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics-field:read
- VIEWER
- tokenizer:
- metrics-field:read
- VIEWER
summary: Get metric attributes
tags:
- metrics
@@ -20861,9 +20860,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: Get metric highlights
tags:
- metrics
@@ -20920,9 +20919,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: Inspect raw metric data points
tags:
- metrics
@@ -20990,9 +20989,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics-field:read
- VIEWER
- tokenizer:
- metrics-field:read
- VIEWER
summary: Get metric metadata
tags:
- metrics
@@ -21036,9 +21035,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics-field:update
- EDITOR
- tokenizer:
- metrics-field:update
- EDITOR
summary: Update metric metadata
tags:
- metrics
@@ -21084,9 +21083,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: Check if non-SigNoz metrics have been received
tags:
- metrics
@@ -21143,9 +21142,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: Get metrics statistics
tags:
- metrics
@@ -21202,9 +21201,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: Get metrics treemap
tags:
- metrics
@@ -28343,9 +28342,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- VIEWER
- tokenizer:
- dashboard:list
- VIEWER
summary: Get metric dashboards (v2)
tags:
- metrics

View File

@@ -43,25 +43,6 @@ type Resource interface {
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
### Telemetry resources and metaresources
Almost every feature resource is one of these two types, and they answer different questions.
- A **telemetry resource** (`logs`, `traces`, `metrics`, `meter-metrics`, `audit-logs`) is the data itself. It only supports `read`. Its selector is not an ID but the query: the route wires a `TelemetryResourceDef` whose extractor turns the caller's filter into `<query_type>/<key>/<value>` IDs, and the check ladders each one through `<query_type>/<key>/*`, `<query_type>/*` and `*`. A grant such as `builder_query/signoz.workspace.key.id/key-a` therefore decides which rows a role may read; a query that does not pin a granted key resolves to `<query_type>/*` and is denied for key-scoped roles. Managed roles hold `*`.
- A **metaresource** (`dashboard`, `rule`, `logs-field`, ...) is an object that describes or configures the system. It supports whichever of `create`, `read`, `update`, `delete` and `list` the resource declares, its selectors are object IDs or `*`, and routes wire a `BasicResourceDef`. It says nothing about rows: `dashboard:read` on `*` does not grant the telemetry a dashboard's panels query.
The metrics explorer uses both on one surface:
| Route | Resource | Why |
|---|---|---|
| `POST /api/v2/metrics/stats` | `metrics:read` (telemetry) | Returns sample and series counts, i.e. the data. The selector comes from `filter.expression`, so a role scoped to a workspace key sees only its own samples. |
| `GET /api/v2/metrics` | `metrics-field:list` (meta) | Lists metric names, types and units: the catalogue, not the samples. |
| `POST /api/v2/metrics/metadata` | `metrics-field:update` (meta) | Edits a metric's type, unit and description. |
The two are independent: a role holding `metrics-field` `list` and `read` can browse the catalogue but cannot read a sample, and a role scoped to `builder_query/signoz.workspace.key.id/key-a` on `metrics` can query its samples but cannot list the catalogue.
For a new route, ask what it returns. Rows or aggregates over them go on the signal's telemetry resource with an extractor; anything that names, describes or configures telemetry goes on a metaresource.
### Selector
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
@@ -161,7 +142,6 @@ Because both paths go through the same middleware and the same `ResourceDef` dec
## What should I remember?
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
- Telemetry resources are the data: `read` only, selector derived from the query. Metaresources are objects about the data: CRUD and list, ID or wildcard selectors. A route that returns rows or aggregates is telemetry; one that names or configures them is meta, as `metrics` versus `metrics-field` shows.
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.

View File

@@ -116,7 +116,7 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "toUInt64(1)"
reducedExpr := "1"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {

View File

@@ -12,7 +12,6 @@
"section_connection": "Connection",
"section_message": "Message",
"section_advanced": "Advanced",
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
"section_recipients": "Recipients",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
@@ -187,49 +186,6 @@
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
"button_discard_channel": "Discard",
"button_discard_confirm": "Discard",
"discard_changes_title": "Discard your changes?",
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
"tooltip_save_no_changes": "Nothing has changed yet.",
"entry_slack_field": "Field",
"entry_slack_action": "Action",
"entry_incidentio_metadata": "Entry",
"label_slack_field_title": "Title",
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
"label_slack_field_value": "Value",
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
"label_incidentio_metadata_key": "Key",
"label_incidentio_metadata_value": "Value",
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
"tooltip_slack_action_text": "The label on the button.",
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
"telegram_tip_link": "Learn how",
"field_telegram_bot_token": "Bot token",
"tooltip_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA...",
"field_telegram_chat_id": "Chat ID",
"tooltip_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
"placeholder_telegram_chat_id": "e.g. -1001234567890",
"field_telegram_message_thread_id": "Message thread ID",
"tooltip_telegram_message_thread_id": "Posts into one message thread of a forum group. Leave empty to post in the group itself.",
"field_telegram_title": "Title",
"tooltip_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped.",
"field_telegram_message": "Message",
"tooltip_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
"field_telegram_api_url": "Bot API URL",
"tooltip_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
"field_telegram_disable_notifications": "Send silently",
"tooltip_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices."
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}

View File

@@ -3291,7 +3291,6 @@ export enum CoretypesKindDTO {
'meter-metrics' = 'meter-metrics',
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
}
export enum CoretypesTypeDTO {
user = 'user',

View File

@@ -15,12 +15,12 @@
.title {
font-size: 16px;
font-weight: 500;
color: var(--l1-foreground);
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--l2-foreground);
color: var(--text-vanilla-400);
max-width: 400px;
}

View File

@@ -9,18 +9,18 @@
}
.icon {
color: var(--l2-foreground);
color: var(--text-vanilla-400);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--l1-foreground);
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--l2-foreground);
color: var(--text-vanilla-400);
max-width: 400px;
}

View File

@@ -1,24 +0,0 @@
/* The space sits between the way back and the title, rather than above both of
them, so the two read as separate steps. */
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-height: 26px;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--l1-foreground);
}
.divider {
--divider-color: var(--l1-border);
--divider-margin: 10px 0 16px 0;
}

View File

@@ -1,56 +0,0 @@
import { ReactNode } from 'react';
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './BackHeader.module.scss';
export interface BackHeaderProps {
/** Where the page came from, named as the place rather than the action. */
backLabel: string;
backRoute: string;
title: ReactNode;
testId?: string;
}
/**
* A way back on its own line, then the page's own title. The two rows keep the
* title clear of whatever sits beside it, such as a copy button.
*/
function BackHeader({
backLabel,
backRoute,
title,
testId,
}: BackHeaderProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
return (
<>
<div className={styles.header} data-testid={testId}>
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<ArrowLeft size={12} />}
onClick={(e: React.MouseEvent): void => {
safeNavigate(backRoute, { newTab: isModifierKeyPressed(e) });
}}
testId="back-link"
>
{backLabel}
</Button>
<div className={styles.title}>{title}</div>
</div>
<Divider className={styles.divider} />
</>
);
}
BackHeader.defaultProps = {
testId: undefined,
};
export default BackHeader;

View File

@@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import configureStore from 'redux-mock-store';
import { PANEL_TYPES } from 'constants/queryBuilder';
import store from 'store';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -78,11 +79,14 @@ const createMockStagedQuery = (dataSource: DataSource): Query => ({
clickhouse_sql: [],
});
const renderWithStore = (dataSource: DataSource): void => {
const renderWithStore = (
dataSource: DataSource,
panelType?: PANEL_TYPES,
): void => {
const mockReduxStore = createMockReduxStore();
render(
<Provider store={mockReduxStore}>
<DownloadOptionsMenu dataSource={dataSource} />
<DownloadOptionsMenu dataSource={dataSource} panelType={panelType} />
</Provider>,
);
};
@@ -117,11 +121,14 @@ describe.each([
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
if (dataSource === DataSource.TRACES) {
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
} else {
expect(screen.getByText('Columns')).toBeInTheDocument();
}
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('hides columns scope in the trace view', () => {
renderWithStore(dataSource, PANEL_TYPES.TRACE);
fireEvent.click(screen.getByTestId(testId));
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
});
it('allows changing export format', () => {
@@ -151,17 +158,6 @@ describe.each([
});
it('allows changing columns scope', () => {
if (dataSource === DataSource.TRACES) {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
expect(screen.queryByRole('radio', { name: 'All' })).not.toBeInTheDocument();
expect(
screen.queryByRole('radio', { name: 'Selected' }),
).not.toBeInTheDocument();
return;
}
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
@@ -228,12 +224,7 @@ describe.each([
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
// For traces, column scope is always Selected and the radio is hidden
if (dataSource !== DataSource.TRACES) {
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
}
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
@@ -250,11 +241,6 @@ describe.each([
});
it('sends no selectFields when column scope is All', async () => {
// For traces, column scope is always Selected — this test only applies to other sources
if (dataSource === DataSource.TRACES) {
return;
}
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));

View File

@@ -45,9 +45,7 @@ export default function DownloadOptionsMenu({
await handleExportRawData({
format: exportFormat,
rowLimit,
clearSelectColumns:
dataSource !== DataSource.TRACES &&
columnsScope === DownloadColumnsScopes.ALL,
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
selectedColumns,
});
}, [
@@ -56,7 +54,6 @@ export default function DownloadOptionsMenu({
columnsScope,
selectedColumns,
handleExportRawData,
dataSource,
]);
const popoverContent = useMemo(
@@ -96,7 +93,7 @@ export default function DownloadOptionsMenu({
/>
</div>
{dataSource !== DataSource.TRACES && (
{panelType !== PANEL_TYPES.TRACE && (
<>
<div className="horizontal-line" />
@@ -134,7 +131,7 @@ export default function DownloadOptionsMenu({
columnsScope,
isDownloading,
handleExport,
dataSource,
panelType,
],
);

View File

@@ -2,11 +2,8 @@ import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
LogViewMode,
FontSize,
OptionsMenuConfig,
} from 'container/OptionsMenu/types';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
Check,
ChevronLeft,

View File

@@ -2,7 +2,6 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export { default as ColumnSelector } from './ColumnSelector';
export * from './TanStackHoverTooltip';
export * from './TanStackTableStateContext';
export * from './types';

View File

@@ -24,7 +24,7 @@ export const REACT_QUERY_KEY = {
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',

View File

@@ -101,16 +101,6 @@ const SPECS: Record<string, Record<string, unknown>> = {
metadata: { team: 'ops' },
sendResolved: true,
},
telegram: {
apiUrl: 'https://api.telegram.org',
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 't',
message: 'm',
disableNotifications: true,
sendResolved: true,
},
};
describe('load then save round trip', () => {

View File

@@ -22,26 +22,13 @@
min-width: 0;
}
/* Laid out like the alert pages: discard on the left, test and save together on
the right. Sticky rather than fixed, so it stays inside the page's own scroll
instead of adding another offset over the bottom strip. */
.actions {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l2-border);
background: var(--l1-background);
}
.actionGroup {
display: flex;
align-items: center;
gap: var(--spacing-3);
border-top: 1px solid var(--l1-border);
}
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
@@ -49,21 +36,3 @@
opacity: 0.5;
cursor: not-allowed;
}
/* The @signozhq inputs leave their surface transparent and take the card's
colour. antd paints its textareas instead, so beside them they read as a
different control, most visibly in light mode. */
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
.fields :global(textarea.ant-input),
.fields :global(textarea.ant-input:hover),
.fields :global(textarea.ant-input:focus),
.fields :global(textarea.ant-input-focused) {
background: var(--input-background, transparent);
border-width: var(--input-border-width, 1px);
border-style: var(--input-border-style, solid);
border-color: var(--input-border-color, var(--border));
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
box-shadow: var(--input-box-shadow, none);
color: inherit;
}

View File

@@ -1,8 +1,8 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
margin-bottom: var(--spacing-6);
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
}
.label {

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button } from '@signozhq/ui/button';
import { Input as AntdInput } from 'antd';
@@ -10,7 +10,6 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
const { TextArea } = AntdInput;
@@ -140,19 +139,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
help={t('help_incidentio_metadata')}
>
{metadataRows.map((row, index) => (
<RowEntry
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
noun={t('entry_incidentio_metadata')}
removeLabel={t('button_incidentio_remove_metadata')}
removeTestId={`incidentio-metadata-remove-${index}`}
onRemove={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
>
<FormField label={t('label_incidentio_metadata_key')}>
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.row}>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_key')}
value={row.key}
@@ -165,9 +155,8 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-key-${index}`}
/>
</FormField>
<FormField label={t('label_incidentio_metadata_value')}>
</div>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_value')}
value={row.value}
@@ -180,10 +169,22 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-value-${index}`}
/>
</FormField>
</RowEntry>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('button_incidentio_remove_metadata')}
onClick={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
testId={`incidentio-metadata-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
<div className={styles.add}>
<Button
variant="dashed"

View File

@@ -1,10 +1,3 @@
/* One height for every control a repeating row renders, so the remove button,
the add button and the inputs beside them agree. */
.entry,
.add {
--row-control-height: 28px;
}
.row {
display: flex;
align-items: center;
@@ -30,9 +23,7 @@
}
.add {
/* Sits with the blocks it adds to, not against the next field's label. */
margin-top: var(--spacing-2);
--button-height: var(--row-control-height);
margin-top: var(--spacing-1);
}
@media (max-width: 720px) {
@@ -44,52 +35,3 @@
max-width: none;
}
}
/* One entry per block, every input labelled. A row of bare boxes gave no way to
tell which column was which once a value was typed. The block keeps a readable
width while the rest of the form fills the page, so its two columns do not
stretch into a row of six. */
.entry {
max-width: 760px;
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--l2-background);
padding: var(--spacing-6) var(--spacing-7);
margin-bottom: var(--spacing-4);
}
.entryTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
/* The remove control is an icon button, so give it the height the design
system gives a small control rather than letting the glyph set it. */
--button-height: var(--row-control-height);
--button-icon-size: 14px;
}
.entryIndex {
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.entryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 var(--spacing-6);
}
/* The toggle reads as a sentence, so it takes the whole width rather than
sitting in a column sized for an input. */
.entryWide {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.entryGrid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -1,50 +0,0 @@
import { ReactNode } from 'react';
import { Minus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './RowEditor.module.scss';
interface RowEntryProps {
/** Zero-based; shown to the reader as a count from one. */
index: number;
/** Singular noun for one entry, such as "Field" or "Action". */
noun: string;
removeLabel: string;
removeTestId: string;
onRemove: () => void;
children: ReactNode;
}
function RowEntry({
index,
noun,
removeLabel,
removeTestId,
onRemove,
children,
}: RowEntryProps): JSX.Element {
return (
<div className={styles.entry}>
<div className={styles.entryTop}>
<Typography.Text size="sm" className={styles.entryIndex}>
{noun} {index + 1}
</Typography.Text>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={removeLabel}
onClick={onRemove}
testId={removeTestId}
>
<Minus size={14} />
</Button>
</div>
<div className={styles.entryGrid}>{children}</div>
</div>
);
}
export default RowEntry;

View File

@@ -42,9 +42,7 @@ function Slack(): JSX.Element {
>
<Input {...register('channel')} data-testid="slack-channel-textbox" />
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
@@ -84,12 +82,7 @@ function Slack(): JSX.Element {
)}
/>
</FormField>
</FormCard>
<FormCard
title={t('section_advanced')}
description={t('description_slack_advanced')}
>
<FormField
label={t('field_slack_color')}
help={t('help_slack_color')}

View File

@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
/** `confirm` is an object on the DTO, so its one editable field is reached by path. */
@@ -29,37 +29,49 @@ function SlackActions(): JSX.Element {
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
const labelFor = (column: ActionColumn): string =>
t(`label_slack_action_${column.replace('.text', '')}`);
return (
<FormField
label={t('field_slack_actions')}
tooltip={t('tooltip_slack_actions')}
>
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
<div>
{fields.map((row, index) => (
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_action')}
removeLabel={t('remove_slack_action')}
removeTestId={`slack-action-remove-${index}`}
onRemove={(): void => remove(index)}
>
{fields.length > 0 && (
<div className={styles.row}>
{COLUMNS.map((column) => (
<FormField
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
>
<div key={column} className={styles.field}>
<Typography.Text size="sm" className={styles.heading}>
{labelFor(column)}
</Typography.Text>
</div>
))}
</div>
)}
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
{COLUMNS.map((column) => (
<div key={column} className={styles.field}>
<Input
{...register(`actions.${index}.${column}` as const)}
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
testId={`slack-action-${keyFor(column)}-${index}`}
placeholder={t(
`placeholder_slack_action_${column.replace('.text', '')}`,
)}
testId={`slack-action-${column.replace('.text', '')}-${index}`}
/>
</FormField>
</div>
))}
</RowEntry>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_action')}
onClick={(): void => remove(index)}
testId={`slack-action-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
</div>

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
@@ -8,7 +8,6 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
@@ -18,61 +17,52 @@ function SlackFields(): JSX.Element {
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
<FormField
label={t('field_slack_fields')}
tooltip={t('tooltip_slack_fields')}
>
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
<div>
{fields.map((row, index) => (
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_field')}
removeLabel={t('remove_slack_field')}
removeTestId={`slack-field-remove-${index}`}
onRemove={(): void => remove(index)}
>
<FormField
label={t('label_slack_field_title')}
tooltip={t('tooltip_slack_field_title')}
>
<div key={row.id} className={styles.row}>
<div className={styles.field}>
<Input
{...register(`fields.${index}.title`)}
placeholder={t('placeholder_slack_field_title')}
testId={`slack-field-title-${index}`}
/>
</FormField>
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
>
</div>
<div className={styles.field}>
<Input
{...register(`fields.${index}.value`)}
placeholder={t('placeholder_slack_field_value')}
testId={`slack-field-value-${index}`}
/>
</FormField>
<div className={styles.entryWide}>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
</div>
</RowEntry>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_field')}
onClick={(): void => remove(index)}
testId={`slack-field-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
</div>

View File

@@ -1,174 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input as AntdInput } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
const { TextArea } = AntdInput;
/**
* The API models the chat and the topic as integers. An empty or half-typed box
* has to become `undefined`, not `NaN`: `omitBlank` keeps `NaN`, which
* serialises as `null` and the API rejects it. react-hook-form also runs this
* over the value already in the form, which arrives as a number, not a string.
*/
const asOptionalNumber = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isNaN(value) ? undefined : value;
}
if (typeof value !== 'string' || value.trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
function TelegramSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="telegram-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('telegram_tip')}{' '}
<Typography.Link
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
>
{t('telegram_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_telegram_bot_token')}
tooltip={t('tooltip_telegram_bot_token')}
error={errors.botToken?.message}
required
>
<Input
{...register('botToken')}
aria-required
type="password"
data-testid="telegram-bot-token-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_chat_id')}
tooltip={t('tooltip_telegram_chat_id')}
error={errors.chatId?.message}
required
>
<Input
{...register('chatId', { setValueAs: asOptionalNumber })}
aria-required
type="number"
placeholder={t('placeholder_telegram_chat_id')}
data-testid="telegram-chat-id-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_message_thread_id')}
tooltip={t('tooltip_telegram_message_thread_id')}
error={errors.messageThreadId?.message}
>
<Input
{...register('messageThreadId', { setValueAs: asOptionalNumber })}
type="number"
data-testid="telegram-message-thread-id-textbox"
/>
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField
label={t('field_telegram_title')}
tooltip={t('tooltip_telegram_title')}
error={errors.title?.message}
>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="telegram-title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField
label={t('field_telegram_message')}
tooltip={t('tooltip_telegram_message')}
error={errors.message?.message}
>
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="telegram-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<FormCard title={t('section_advanced')}>
<FormField
label={t('field_telegram_api_url')}
tooltip={t('tooltip_telegram_api_url')}
error={errors.apiUrl?.message}
>
<Input {...register('apiUrl')} data-testid="telegram-api-url-textbox" />
</FormField>
<FormField
label={t('field_telegram_disable_notifications')}
tooltip={t('tooltip_telegram_disable_notifications')}
>
<Controller
control={control}
name="disableNotifications"
render={({ field }): JSX.Element => (
<Switch
color="primary"
textPlacement="right"
testId="telegram-disable-notifications-switch"
value={!!field.value}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
</>
);
}
export default TelegramSettings;

View File

@@ -24,7 +24,6 @@ describe('FormAlertChannels', () => {
onTypeChangeHandler={jest.fn()}
onSaveHandler={jest.fn()}
onTestHandler={jest.fn()}
onDiscardHandler={jest.fn()}
testingState={false}
savingState={false}
editing

View File

@@ -1,7 +1,6 @@
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
@@ -21,7 +20,6 @@ import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import TelegramSettings from './Settings/Telegram';
import WebhookSettings from './Settings/Webhook';
import { Button } from '@signozhq/ui/button';
@@ -40,7 +38,6 @@ const CHANNEL_TYPE_OPTIONS = [
{ value: ChannelKind.jira, label: 'Jira' },
{ value: ChannelKind.jsmops, label: 'Jira Service Management Ops' },
{ value: ChannelKind.incidentio, label: 'incident.io' },
{ value: ChannelKind.telegram, label: 'Telegram' },
];
function FormAlertChannels({
@@ -49,12 +46,10 @@ function FormAlertChannels({
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
onDiscardHandler,
savingState,
testingState,
editing = false,
readOnly = false,
hasChanges = true,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
@@ -85,8 +80,6 @@ function FormAlertChannels({
return <IncidentIOSettings />;
case ChannelKind.opsgenie:
return <OpsgenieSettings />;
case ChannelKind.telegram:
return <TelegramSettings />;
case ChannelKind.email:
return <EmailSettings />;
default:
@@ -118,10 +111,7 @@ function FormAlertChannels({
/>
</FormField>
<FormField
label={t('field_send_resolved')}
tooltip={t('tooltip_send_resolved')}
>
<FormField label={t('field_send_resolved')}>
<Controller
control={control}
name="sendResolved"
@@ -137,11 +127,7 @@ function FormAlertChannels({
/>
</FormField>
<FormField
label={t('field_channel_type')}
tooltip={t('tooltip_channel_type')}
htmlFor="channel-type"
>
<FormField label={t('field_channel_type')} htmlFor="channel-type">
<SelectSimple
id="channel-type"
items={CHANNEL_TYPE_OPTIONS}
@@ -158,45 +144,30 @@ function FormAlertChannels({
</fieldset>
<div className={styles.actions}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="discard-channel-button"
prefix={<X size={14} />}
onClick={onDiscardHandler}
>
{t('button_discard_channel')}
</Button>
<div className={styles.actionGroup}>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
disabled={!hasChanges}
disabledTooltip={t('tooltip_save_no_changes')}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
</div>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
</div>
</form>
</FormProvider>
@@ -209,15 +180,12 @@ interface FormAlertChannelsProps {
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: () => void;
onTestHandler: () => void;
onDiscardHandler: () => void;
testingState: boolean;
savingState: boolean;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
hasChanges?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
@@ -227,7 +195,6 @@ interface FormAlertChannelsProps {
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
hasChanges: true,
saveChecks: [],
testChecks: [],
};

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

@@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -122,26 +121,22 @@ describe('ListAlertRules — actions menu', () => {
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST to clone endpoint and opens the cloned rule returned by the API', async () => {
let capturedRuleId: string | null = null;
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
server.use(
rest.post(
'http://localhost/api/v2/rules/:id/clone',
async (req, res, ctx) => {
capturedRuleId = req.params.id as string;
return res(
ctx.status(201),
ctx.json({
data: {
...alertRulesFixture[0],
id: 'cloned-from-server',
alert: 'High CPU Alert - Copy',
},
status: 'success',
}),
);
},
),
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
);
renderListAlertRules();
@@ -150,7 +145,9 @@ describe('ListAlertRules — actions menu', () => {
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedRuleId).toBe('rule-1');
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
});
// The id from the server response round-trips into the navigate URL — this

View File

@@ -6,10 +6,10 @@ import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
invalidateListRules,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
@@ -62,9 +62,10 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
const handleClone = useCallback((): void => {
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
toast.promise(
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
createRule({
...rule,
alert: `${rule.alert} - Copy`,
} as RuletypesPostableRuleDTO).then(async (response) => {
await invalidateListRules(queryClient);
const newRule = response.data;
if (newRule) {

View File

@@ -3,21 +3,23 @@ import { Columns3 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import type { TableColumnDef } from 'components/TanStackTableView';
import {
hideColumn,
showColumn,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './ColumnSelector.module.scss';
import type { TableColumnDef } from './types';
import { hideColumn, showColumn, useHiddenColumnIds } from './useColumnStore';
interface ColumnSelectorProps<TData> {
columns: TableColumnDef<TData>[];
/** Shared with the table's `columnStorageKey`, which is what it writes to. */
storageKey: string;
testId?: string;
}
function ColumnSelector<TData>({
columns,
storageKey,
testId = 'column-selector-button',
}: ColumnSelectorProps<TData>): JSX.Element {
const hiddenColumnIds = useHiddenColumnIds(storageKey);
@@ -45,7 +47,7 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
testId={testId}
testId="alert-columns-button"
>
Columns
</Button>
@@ -75,8 +77,4 @@ function ColumnSelector<TData>({
);
}
ColumnSelector.defaultProps = {
testId: 'column-selector-button',
};
export default ColumnSelector;

View File

@@ -1 +1,2 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -15,7 +15,7 @@ import { useTimezone } from 'providers/Timezone';
import TextToolTip from 'components/TextToolTip';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import { ActionsMenu } from './components';
import { ActionsMenu, ColumnSelector } from './components';
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
@@ -134,11 +134,7 @@ function ListAlertRules(): JSX.Element {
{!isEmptyNoRules && (
<div className={styles.header}>
<div className={styles.refreshRow}>
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
/>
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
{addNewAlert && (
<Button
variant="solid"

View File

@@ -1,58 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useCopySpanLink } from '../useCopySpanLink';
const mockCopy = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn() },
}));
// The router only knows the stale URL; nuqs has since written ?tab=thread.
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
{children}
</MemoryRouter>
);
const clickEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent<HTMLElement>;
describe('useCopySpanLink', () => {
beforeEach(() => {
mockCopy.mockClear();
__setSearchParamsGetterForTest(
() => new URLSearchParams('tab=thread&spanId=old'),
);
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('builds the link from the live URL, keeping its tab', () => {
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
wrapper,
});
result.current.onSpanCopy(clickEvent);
const link = new URL(mockCopy.mock.calls[0][0]);
expect(link.pathname).toBe('/trace/abc');
expect(link.searchParams.get('tab')).toBe('thread');
expect(link.searchParams.get('spanId')).toBe('new');
});
});

View File

@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import useUrlQuery from 'hooks/useUrlQuery';
import { getAbsoluteUrl } from 'utils/basePath';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
@@ -15,6 +15,7 @@ interface SpanLike {
export const useCopySpanLink = (
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
@@ -27,22 +28,21 @@ export const useCopySpanLink = (
event.preventDefault();
event.stopPropagation();
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
urlQuery.delete('spanId');
const id = span.span_id || span.spanId;
if (id) {
params.set('spanId', id);
urlQuery.set('spanId', id);
}
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', {
position: 'top-right',
});
},
[span, pathname, setCopy],
[span, urlQuery, pathname, setCopy],
);
return {

View File

@@ -22,17 +22,12 @@ export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
const keyColor = token(el, '--bg-vanilla-400');
const valueColor = token(el, '--bg-robin-400');
// Both token colours are picked to read on a dark surface, so on a light one
// they wash out, the keys worst of all. Monaco's own `vs` palette is built
// for that surface, so let it keep its colours there, as the role viewer does.
const rules: { token: string; foreground: string }[] = [];
if (isDark(background)) {
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
const colors: Record<string, string> = {};

View File

@@ -7,8 +7,8 @@ import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import {
useAlertRuleClone,
useAlertRuleDelete,
useAlertRuleDuplicate,
useAlertRuleStatusToggle,
useAlertRuleUpdate,
} from 'pages/AlertDetails/hooks';
@@ -38,7 +38,9 @@ function AlertActionButtons({
const isDarkMode = useIsDarkMode();
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
const { handleAlertClone } = useAlertRuleClone({ ruleId });
const { handleAlertDuplicate } = useAlertRuleDuplicate({
alertDetails: alertDetails as unknown as AlertDef,
});
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
alertDetails: alertDetails as unknown as AlertDef,
@@ -70,10 +72,10 @@ function AlertActionButtons({
menuItems.push(
{
type: 'item',
value: 'clone-rule',
label: 'Clone',
value: 'duplicate-rule',
label: 'Duplicate',
prefix: <Copy size={16} color={Color.BG_VANILLA_400} />,
onClick: handleAlertClone,
onClick: handleAlertDuplicate,
},
{
type: 'item',

View File

@@ -6,7 +6,7 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
cloneRuleByID,
createRule,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
@@ -17,6 +17,7 @@ import {
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
useGetRuleHistoryTopContributors,
useListRules,
} from 'api/generated/services/rules';
import {
Querybuildertypesv5OrderDirectionDTO,
@@ -477,32 +478,39 @@ export const useAlertRuleStatusToggle = ({
return { handleAlertStateToggle };
};
export const useAlertRuleClone = ({
ruleId,
export const useAlertRuleDuplicate = ({
alertDetails,
}: {
ruleId: string;
alertDetails: AlertDef;
}): {
handleAlertClone: () => void;
handleAlertDuplicate: () => void;
} => {
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const params = useUrlQuery();
const { refetch } = useListRules({
query: { cacheTime: 0 },
});
const { showErrorModal } = useErrorModal();
const { mutate: cloneAlert } = useMutation(
[REACT_QUERY_KEY.CLONE_ALERT_RULE],
() => cloneRuleByID({ id: ruleId }),
const { mutate: duplicateAlert } = useMutation(
[REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
(args: { data: AlertDef }) =>
createRule(toPostableRuleDTOFromAlertDef(args.data)),
{
onSuccess: async (response) => {
onSuccess: async () => {
notifications.success({
message: `Success`,
});
await invalidateListRules(queryClient);
const { data: allAlertsData } = await refetch();
params.set(QueryParams.ruleId, response.data.id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
const rules = allAlertsData?.data;
if (rules && rules.length > 0) {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}
},
onError: (error) =>
showErrorModal(
@@ -511,13 +519,15 @@ export const useAlertRuleClone = ({
},
);
const handleAlertClone = (): void => {
cloneAlert();
const handleAlertDuplicate = (): void => {
const args = {
data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') },
};
duplicateAlert(args);
};
return { handleAlertClone };
return { handleAlertDuplicate };
};
export const useAlertRuleUpdate = ({
alertDetails,
setAlertRuleName,

View File

@@ -107,7 +107,7 @@ export const alertRulesMocks = defineStoryMocks({
})),
),
rest.post('http://localhost/api/v2/rules/:id/clone', (_req, res, ctx) =>
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
actionResponse(values.cloneState, res, ctx, () => ({
status: 'success',
data: alertRulesResponse(1, {

View File

@@ -58,12 +58,6 @@ export const Default: Story = {};
/** A workspace with nowhere to send an alert yet. */
export const NoChannels: Story = {
args: { channels: 0 },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByTestId('channels-empty-state', undefined, UNTIL_LOADED);
await canvas.findByTestId('channels-empty-create');
},
};
/** Data: the channel list's existing loading spinner. */
@@ -111,24 +105,10 @@ export const KindFilter: Story = {
UNTIL_LOADED,
);
await canvas.findByText('sre-email');
// the heading and the create button both name the filtered type
await canvas.findByText('All Email channels');
},
};
/** Interaction: the column menu, which turns table columns off and on. */
export const ColumnSelector: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByTestId('channels-columns'));
await screen.findByText('Toggle Columns');
},
};
/** Interaction: a search that matches no channel. */
/** Interaction: a search matching nothing offers to clear itself. */
export const NoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
@@ -140,21 +120,6 @@ export const NoResults: Story = {
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
},
};
/** Interaction: clearing the search from the empty state brings the list back. */
export const ClearSearch: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.type(
canvas.getByTestId('channels-search'),
'no-such-channel',
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByRole('button', { name: 'Clear Search' }));
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
},

View File

@@ -90,16 +90,13 @@ export const SaveFailure: Story = {
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
// Save is off until the form is edited, so the failure is reached through
// a real change rather than an empty submit.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, untilLoaded),
'-edited',
await userEvent.click(
await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
untilLoaded,
),
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Storybook forced channel failure');
},
};
@@ -181,8 +178,3 @@ export const JiraServiceManagementOps: Story = {
export const IncidentIO: Story = {
args: { channelType: 'incidentio' },
};
/** A Telegram channel, with its bot token masked behind the password field. */
export const Telegram: Story = {
args: { channelType: 'telegram' },
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import { expect, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -82,12 +82,6 @@ export const NoCreate: Story = {
await expectDisabled(
await canvas.findByTestId('test-channel-button', undefined, PAGE_LOAD),
);
// Save is also off until the form is edited, so the edit comes first and
// what is left is the permission.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
false,
@@ -112,39 +106,6 @@ export const ReadOnly: Story = {
},
};
/**
* The same denial on a Telegram channel. Its fields are the kind the form gained
* last, including the only toggle a settings panel renders, so this is where a
* gap in the gating would show first.
*
* The switch is checked by behaviour, not by an attribute: it is a button inside
* the disabled fieldset, so it carries no `disabled` of its own and an
* attribute check would pass whether or not the browser actually turned it off.
*/
export const TelegramReadOnly: Story = {
args: { channelType: 'telegram', revoked: [UPDATE, CREATE, DELETE] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(
await canvas.findByTestId(
'telegram-bot-token-textbox',
undefined,
PAGE_LOAD,
),
);
await expectDisabled(await canvas.findByTestId('telegram-chat-id-textbox'));
await expectDisabled(await canvas.findByTestId('telegram-message-textarea'));
const toggle = await canvas.findByTestId(
'telegram-disable-notifications-switch',
);
const before = toggle.getAttribute('aria-checked');
await userEvent.click(toggle);
await expect(toggle.getAttribute('aria-checked')).toBe(before);
},
};
/** No channel permission at all: the page is denied before the form exists. */
export const NoChannelAccess: Story = {
args: { revoked: NOTIFICATION_CHANNEL },
@@ -174,13 +135,12 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
await expectDisabled(save, false);
},
};

View File

@@ -16,11 +16,6 @@ import {
} from '../../stories/__story_mockdata__/alerts';
const ACTIONS = 'Channel · actions';
const ROUTE = 'Channel · route';
/** The `?kind=` values worth reaching: a known type, and one the form rejects. */
const URL_KINDS = ['none', 'jira', 'not-a-kind'] as const;
type UrlKind = (typeof URL_KINDS)[number];
const resolveChannelCreated: MockResolver = (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null }));
@@ -34,8 +29,7 @@ const rejectChannelAction: MockResolver = (_req, res, ctx) =>
/**
* The form holds the channel type in component state, so the type itself is
* stories with a `play` that picks one rather than a control; saving and
* testing the channel are. The type can also arrive in the URL, which the list
* uses to carry its filter over, so that is a control on the route.
* testing the channel are.
*/
export const channelsNewMocks = defineStoryMocks({
controls: {
@@ -49,11 +43,6 @@ export const channelsNewMocks = defineStoryMocks({
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
kindInUrl: choiceControl<UrlKind>('Channel type in the URL', {
group: ROUTE,
options: URL_KINDS,
value: 'none',
}),
},
handlers: (values) => [
rest.post(
@@ -66,10 +55,5 @@ export const channelsNewMocks = defineStoryMocks({
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
),
],
config: (values) => ({
route:
values.kindInUrl === 'none'
? '/alerts/channels/new'
: `/alerts/channels/new?kind=${values.kindInUrl}`,
}),
config: () => ({ route: '/alerts/channels/new' }),
});

View File

@@ -58,16 +58,9 @@ export const ChannelTypeSelectOpen: Story = {
/** Mutation: saving without a channel name shows the form's required-field feedback. */
export const InvalidRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
// Save stays off until the form is touched, so filling the webhook first is
// how a missing name is reached.
await userEvent.type(
await canvas.findByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
await userEvent.click(
await within(canvasElement).findByTestId('save-channel-button'),
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Channel name is mandatory');
},
};
@@ -185,38 +178,6 @@ export const IncidentIO: Story = {
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Telegram$/);
},
};
/**
* Telegram's own required fields. The chat id is the form's first numeric field,
* and the API treats 0 as missing, so an empty box has to report the same error
* as a blank one rather than sending a zero.
*/
export const TelegramRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Telegram$/);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox'),
'alerts-telegram',
);
await userEvent.click(await canvas.findByTestId('save-channel-button'));
await screen.findByText(
'Bot token is mandatory for creating a telegram channel',
);
await screen.findByText(
'Chat ID is mandatory for creating a telegram channel',
);
},
};
/**
* The fields the API rejects a save without are marked before anything is
* submitted, so a long form does not have to be guessed at.
@@ -238,38 +199,3 @@ export const RequiredFields: Story = {
);
},
};
/**
* The type carried in the URL. Filtering the list by a type and pressing its
* create button lands here with `?kind=`, so the form opens on that type with
* nothing to pick.
*/
export const KindFromUrl: Story = {
args: { kindInUrl: 'jira' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Jira$/);
},
};
/**
* A `?kind=` the app does not have. The form opens on its own default rather
* than on an empty type, so a stale or hand-edited link still works.
*/
export const UnknownKindInUrl: Story = {
args: { kindInUrl: 'not-a-kind' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Slack$/);
},
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import { expect, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -81,13 +81,12 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox', undefined, PAGE_LOAD),
'check-failed',
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
await expectDisabled(save, false);
},
};

View File

@@ -11,12 +11,13 @@
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
padding: var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
@@ -24,7 +25,7 @@
.commandHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
gap: 12px;
}
.headingBlock {

View File

@@ -7,11 +7,10 @@ import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
@@ -26,9 +25,7 @@ import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import ChannelsEmptyState from './components/ChannelsEmptyState/ChannelsEmptyState';
import {
CHANNEL_KIND_LABEL,
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
@@ -159,10 +156,6 @@ function NotificationChannels(): JSX.Element {
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
// With a kind picked, creating one of that kind is the likely next step, so
// the button offers it rather than making the type be chosen again.
const filteredKind =
kind !== KIND_FILTER_ALL ? (kind as ChannelKind) : undefined;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
@@ -176,38 +169,27 @@ function NotificationChannels(): JSX.Element {
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
{filteredKind
? `All ${CHANNEL_KIND_LABEL[filteredKind]} channels`
: 'All notification channels'}
All notification channels
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
{!isEmpty && (
<ColumnSelector
columns={columnsWithActions}
storageKey="notification-channels-columns"
testId="channels-columns"
/>
)}
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void => formView.openCreate(filteredKind)}
testId="channels-create"
>
{filteredKind
? `New ${CHANNEL_KIND_LABEL[filteredKind]} Channel`
: 'New Channel'}
</AuthZButton>
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</AuthZButton>
</div>
<div className={styles.filtersRow}>
@@ -233,8 +215,12 @@ function NotificationChannels(): JSX.Element {
</header>
<div ref={containerRef} className={styles.tableContainer}>
{/* Fails closed, unlike the guard's default: the fetch is gated on the
same check, so falling open would render an empty table shell with
no reason given. The header and filters stay, as on dashboards. */}
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): JSX.Element => (
<PermissionDeniedCallout
deniedPermissions={deniedPermissions}
@@ -259,10 +245,6 @@ function NotificationChannels(): JSX.Element {
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmpty ? (
<ChannelsEmptyState
onCreate={(): void => formView.openCreate(filteredKind)}
/>
) : (
<TanStackTable<Channel>
data={channels}

View File

@@ -10,7 +10,7 @@ const FIND = { timeout: 5000 };
interface PrefillCase {
kind: string;
spec: Record<string, unknown>;
fields: [string, string | number][];
fields: [string, string][];
}
const CASES: PrefillCase[] = [
@@ -186,25 +186,6 @@ const CASES: PrefillCase[] = [
['incidentio-description-textarea', 'incident description'],
],
},
{
kind: 'telegram',
spec: {
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 'telegram title',
message: 'telegram message',
apiUrl: 'https://api.telegram.org',
},
fields: [
['telegram-bot-token-textbox', '123456789:AA-token'],
['telegram-chat-id-textbox', -1001234567890],
['telegram-message-thread-id-textbox', 42],
['telegram-title-textarea', 'telegram title'],
['telegram-message-textarea', 'telegram message'],
['telegram-api-url-textbox', 'https://api.telegram.org'],
],
},
];
describe('ChannelForm prefill', () => {

View File

@@ -72,52 +72,6 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'sends no telegram message, so the backend keeps its own template',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Telegram$/ }));
await user.type(
screen.getByTestId('channel-name-textbox'),
'telegram alerts',
);
await user.type(screen.getByTestId('telegram-bot-token-textbox'), '123:abc');
await user.type(screen.getByTestId('telegram-chat-id-textbox'), '-100123');
// the box is empty on screen, and an empty box must not become an empty
// string in the request: that would overwrite the backend's default
expect(screen.getByTestId('telegram-message-textarea')).toHaveValue('');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
const spec = (body as { config: { spec: Record<string, unknown> } }).config
.spec;
expect(spec).not.toHaveProperty('message');
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
@@ -208,20 +162,15 @@ describe('ChannelForm save', () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
const channel = await screen.findByTestId('slack-channel-textbox', {}, FIND);
await waitFor(() => expect(channel).toBeEnabled(), FIND);
// Save is off until something changes, so one edit is what tells a
// settled failure apart from a denial
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(channel, '-edited');
const save = screen.getByTestId('save-channel-button');
// Save is disabled while the checks are in flight and settles once they
// resolve, so waiting on it means the failure has landed
const save = await screen.findByTestId('save-channel-button', {}, FIND);
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
expect(screen.getByTestId('slack-channel-textbox')).toBeEnabled();
expect(
screen.queryByTestId('channel-read-only-notice'),
).not.toBeInTheDocument();
@@ -229,69 +178,6 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'keeps save off until something is edited',
async () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
// Save is also off while the permission checks are in flight. Test is
// gated on those checks alone, so it settling means Save is only off
// because nothing has changed.
const testButton = await screen.findByTestId(
'test-channel-button',
{},
FIND,
);
await waitFor(
() => expect(testButton).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
const save = screen.getByTestId('save-channel-button');
expect(save).toHaveAttribute('aria-disabled', 'true');
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
},
TEST_TIMEOUT,
);
it(
'asks before discarding an edited form, and leaves straight away otherwise',
async () => {
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const discard = await screen.findByTestId(
'discard-channel-button',
{},
FIND,
);
await user.click(discard);
expect(onDone).toHaveBeenCalledTimes(1);
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await user.click(discard);
// the test harness stubs `t`, so the modal renders the keys themselves
await screen.findByText('discard_changes_title', {}, FIND);
expect(onDone).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole('button', { name: 'button_discard_confirm' }),
);
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(2));
},
TEST_TIMEOUT,
);
it(
'explains a channel the v2 API cannot model instead of showing a blank form',
async () => {

View File

@@ -6,27 +6,16 @@ import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/pe
import ChannelFormBody from './ChannelFormBody';
import styles from './ChannelForm.module.scss';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useChannelDraft } from './useChannelDraft';
interface ChannelFormProps {
channelId?: string;
/** Preselects the type when creating, so a filtered list can offer its kind. */
initialKind?: ChannelKind;
onDone: () => void;
}
function ChannelForm({
channelId,
initialKind,
onDone,
}: ChannelFormProps): JSX.Element {
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
const { t } = useTranslation('channels');
const { draft, isLoading, loadError } = useChannelDraft(
channelId,
initialKind,
);
const { draft, isLoading, loadError } = useChannelDraft(channelId);
const {
canEdit,

View File

@@ -1,6 +1,3 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import FormAlertChannels from 'container/FormAlertChannels';
import { ChannelFormState } from 'container/CreateAlertChannels/channelFormValues';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
@@ -27,54 +24,23 @@ function ChannelFormBody({
saveChecks,
testChecks,
}: ChannelFormBodyProps): JSX.Element {
const { t } = useTranslation('channels');
const { contextHolder, confirmDelete } = useDeleteConfirm();
const {
form,
type,
isDirty,
onTypeChange,
onSave,
onTest,
isSaving,
isTesting,
} = useChannelFormState({ draft, target, onDone });
const onDiscard = (): void => {
if (!isDirty) {
onDone();
return;
}
confirmDelete({
title: (
<Typography.Title level={5}>{t('discard_changes_title')}</Typography.Title>
),
content: t('discard_changes_body'),
confirmText: t('button_discard_confirm'),
onConfirm: onDone,
});
};
const { form, type, onTypeChange, onSave, onTest, isSaving, isTesting } =
useChannelFormState({ draft, target, onDone });
return (
<>
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
onDiscardHandler={onDiscard}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
hasChanges={isDirty}
saveChecks={saveChecks}
testChecks={testChecks}
/>
{contextHolder}
</>
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
saveChecks={saveChecks}
testChecks={testChecks}
/>
);
}

View File

@@ -10,7 +10,6 @@ import {
ChannelInitialConfig,
DEFAULT_CHANNEL_KIND,
} from 'container/CreateAlertChannels/defaults';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { toAPIError } from 'utils/errorUtils';
export interface ChannelDraftResult {
@@ -23,10 +22,7 @@ export interface ChannelDraftResult {
* Kept apart from the form so the form is built once its values are known,
* rather than mounted empty and reset.
*/
export function useChannelDraft(
channelId?: string,
initialKind?: ChannelKind,
): ChannelDraftResult {
export function useChannelDraft(channelId?: string): ChannelDraftResult {
const isEditing = !!channelId;
const { data, error: loadFailure } = useGetNotificationChannel(
@@ -38,10 +34,10 @@ export function useChannelDraft(
channelId
? null
: {
kind: initialKind ?? DEFAULT_CHANNEL_KIND,
kind: DEFAULT_CHANNEL_KIND,
values: {
sendResolved: true,
...ChannelInitialConfig[initialKind ?? DEFAULT_CHANNEL_KIND],
...ChannelInitialConfig[DEFAULT_CHANNEL_KIND],
},
},
);

View File

@@ -31,8 +31,6 @@ import { toAPIError } from 'utils/errorUtils';
export interface ChannelFormStateResult {
form: UseFormReturn<ChannelFormValues>;
type: ChannelKind;
/** False until something is edited, which is what gates saving. */
isDirty: boolean;
onTypeChange: (value: string) => void;
onSave: () => void;
onTest: () => void;
@@ -86,13 +84,10 @@ export function useChannelFormState({
setTypeState(nextType);
// shouldDirty, because the type lives outside the form: without it
// picking a different type leaves the form pristine and unsavable.
Object.entries(ChannelInitialConfig[nextType]).forEach(([field, value]) => {
form.setValue(
field as keyof ChannelFormValues,
value as ChannelFormValues[keyof ChannelFormValues],
{ shouldDirty: true },
);
});
},
@@ -182,7 +177,6 @@ export function useChannelFormState({
return {
form,
type,
isDirty: form.formState.isDirty,
onTypeChange,
onSave: form.handleSubmit(submitSave),
onTest: form.handleSubmit(submitTest),

View File

@@ -1,48 +0,0 @@
/* The same shape as the page's no-matches state, so the two empty screens read
as the same product rather than two designs. */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--l2-foreground);
}
.title {
color: var(--l1-foreground);
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
.subtitle {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
max-width: 420px;
}
/* The button sizes to its label here, so "New channel" is never clipped. */
.actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-1);
}
.docsLink {
color: var(--l2-foreground);
font-size: 13px;
text-decoration: none;
&:hover {
color: var(--l1-foreground);
text-decoration: underline;
}
}

View File

@@ -1,54 +0,0 @@
import { Bell, Plus } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import styles from './ChannelsEmptyState.module.scss';
const CREATE_CHECKS = [NotificationChannelCreatePermission];
const DOCS_URL = 'https://signoz.io/docs/setup-alerts-notification/';
interface ChannelsEmptyStateProps {
onCreate: () => void;
}
function ChannelsEmptyState({
onCreate,
}: ChannelsEmptyStateProps): JSX.Element {
return (
<div className={styles.emptyState} data-testid="channels-empty-state">
<Bell className={styles.icon} size={16} />
<Typography.Text className={styles.title}>
No notification channels yet
</Typography.Text>
<Typography.Text className={styles.subtitle}>
A channel is where an alert ends up. Create one and your alert rules and
routing policies can point at it.
</Typography.Text>
<div className={styles.actions}>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={onCreate}
testId="channels-empty-create"
>
New channel
</AuthZButton>
</div>
<a
className={styles.docsLink}
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="channels-empty-docs"
>
Read the setup guide
</a>
</div>
);
}
export default ChannelsEmptyState;

View File

@@ -4,7 +4,7 @@ import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
export interface ChannelFormView {
openCreate: (kind?: string) => void;
openCreate: () => void;
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
}
@@ -22,10 +22,7 @@ export function useChannelFormView(): ChannelFormView {
};
return {
openCreate: (kind?: string): void =>
history.push(
kind ? `${ROUTES.CHANNELS_NEW}?kind=${kind}` : ROUTES.CHANNELS_NEW,
),
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
openEdit,
};
}

View File

@@ -81,7 +81,6 @@ export const CHANNEL_TYPES = [
'jira',
'jsmops',
'incidentio',
'telegram',
] as const;
export type ChannelType = (typeof CHANNEL_TYPES)[number];
@@ -791,22 +790,6 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
// one of each so the repeating row editors render with content
fields: [
{ title: 'Severity', value: '{{ .CommonLabels.severity }}', short: true },
{
title: 'Service',
value: '{{ .CommonLabels.service_name }}',
short: false,
},
],
actions: [
{
type: 'button',
text: 'Runbook',
url: 'https://runbook.example.com/{{ .CommonLabels.alertname }}',
},
],
sendResolved: true,
},
webhook: {
@@ -864,13 +847,6 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
telegram: {
botToken: '123456789:AA-story-telegram-token',
chatId: -1001234567890,
title: '{{ .CommonLabels.alertname }}',
message: '{{ range .Alerts -}}{{ .Annotations.description }}\n{{ end }}',
sendResolved: true,
},
};
/** The v2 fetch by id, which is what the redesigned screens read. */

View File

@@ -2,6 +2,12 @@
padding: var(--spacing-6);
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
}
.crumbName {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}

View File

@@ -1,7 +1,6 @@
import { Spinner } from '@signozhq/ui/spinner';
import { toast } from '@signozhq/ui/sonner';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import BackHeader from 'components/BackHeader/BackHeader';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
@@ -14,45 +13,42 @@ interface ChannelsEditPageProps {
}
function ChannelsEditPage({ channelId }: ChannelsEditPageProps): JSX.Element {
const { data, isLoading } = useGetNotificationChannel({ id: channelId });
const { data } = useGetNotificationChannel({ id: channelId });
return (
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={ROUTES.ALL_CHANNELS}
testId="channel-edit-header"
title={
isLoading ? (
<Spinner
size={16}
aria-label="Loading channel"
testId="channel-name-loading"
/>
) : (
<>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{
title: (
<span className={styles.crumbName}>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
</span>
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
</span>
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
</>
)
}
),
isLast: true,
},
]}
/>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
<div className={styles.content}>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
);
}

View File

@@ -1,46 +1,30 @@
import { useLocation } from 'react-router-dom';
import { Skeleton } from 'antd';
import BackHeader from 'components/BackHeader/BackHeader';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
import { KIND_KEY } from 'pages/AlertList/NotificationChannels/constants';
import styles from './styles.module.scss';
function ChannelsNew(): JSX.Element {
const requested = new URLSearchParams(useLocation().search).get('kind');
// an unknown kind in the URL falls back to the form's own default
const initialKind = (Object.values(ChannelKind) as string[]).includes(
requested ?? '',
)
? (requested as ChannelKind)
: undefined;
// Leaving goes back to the list the way it was left: filtered by the kind
// that opened this form.
const listRoute = initialKind
? `${ROUTES.ALL_CHANNELS}&${KIND_KEY}=${initialKind}`
: ROUTES.ALL_CHANNELS;
return (
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={listRoute}
title="New channel"
testId="channel-new-header"
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: 'New channel', isLast: true },
]}
/>
<ChannelForm
initialKind={initialKind}
onDone={(): void => {
history.replace(listRoute);
}}
/>
</div>
<div className={styles.content}>
<ChannelForm
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
);
}

View File

@@ -2,6 +2,6 @@
padding: var(--spacing-6);
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
}

View File

@@ -1,44 +0,0 @@
import ReactMarkdown, { uriTransformer } from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
// Model output is untrusted: never add `rehype-raw`, so raw HTML renders as text.
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS: Components = {
a: ({ node: _node, children, ...props }): JSX.Element => (
<a {...props} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
// Images become links so viewing a message never fetches a third-party URL.
img: ({ src, alt }): JSX.Element => (
<a href={src} target="_blank" rel="noopener noreferrer">
{alt || src}
</a>
),
};
interface AIMarkdownProps {
content: string;
className?: string;
}
function AIMarkdown({ content, className }: AIMarkdownProps): JSX.Element {
return (
<ReactMarkdown
className={className}
remarkPlugins={MD_PLUGINS}
// Images render as links, so their URIs need the same sanitising links get.
transformImageUri={uriTransformer}
components={MD_COMPONENTS}
>
{content}
</ReactMarkdown>
);
}
AIMarkdown.defaultProps = {
className: undefined,
};
export default AIMarkdown;

View File

@@ -1,376 +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);
}
}
.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 {
overflow: hidden;
}
.jsonCopy {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-2);
z-index: 1;
}

View File

@@ -1,98 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { MessageType, 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;
messageType?: MessageType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function AIThreadMessage({
message,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: AIThreadMessageProps): JSX.Element {
const { role, parts: messageParts, finish_reason: finishReason } = message;
const roleLabel = getRoleLabel(role);
const tone = getFinishReasonTone(finishReason);
const isOutput = messageType === MessageType.Output;
const parts = (
<div className={styles.parts}>
{messageParts.length === 0 ? (
<span className={styles.empty}>No content</span>
) : (
messageParts.map((part, index) => (
<MessagePart
// 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="outlined"
color="secondary"
textTransform="none"
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 = {
messageType: MessageType.Input,
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 stopToggleFromSelectingCard = (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={stopToggleFromSelectingCard}>
<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,39 +0,0 @@
import { MouseEvent, useState } from 'react';
import AIMarkdown from './AIMarkdown';
import { MAX_TEXT_LENGTH } from './utils';
import styles from './AIThreadMessage.module.scss';
interface ExpandableTextProps {
text: string;
}
function ExpandableText({ text }: 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 (
<>
<AIMarkdown className={styles.text} content={visibleText} />
{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>
)}
</>
);
}
export default ExpandableText;

View File

@@ -1,79 +0,0 @@
import { MouseEvent, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { PrettyView } from 'periscope/components/PrettyView';
import { useResizeObserver } from 'hooks/useDimensions';
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: [] };
// 10 tree rows: 18px line-height plus 2px padding top and bottom.
const COLLAPSED_MAX_HEIGHT = 10 * 22;
interface JsonBlockProps {
data: object;
}
// Expanding tree nodes must not count as a click on the message.
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
const treeRef = useRef<HTMLDivElement>(null);
const { height: treeHeight } = useResizeObserver(treeRef);
const isLong = treeHeight > COLLAPSED_MAX_HEIGHT;
const isClamped = isLong && !isExpanded;
const stopTreeToggleFromSelectingCard = (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, isClamped && styles.jsonCollapsed)}
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
onClick={stopTreeToggleFromSelectingCard}
data-testid="ai-json-block"
>
<span className={styles.jsonCopy}>
<CopyButton
value={text}
size={12}
ariaLabel="Copy JSON"
testId="ai-json-copy"
/>
</span>
<div ref={treeRef}>
<PrettyView
data={data as Record<string, unknown>}
searchable={false}
drawerKey="ai-thread-message"
visibleActions={NO_ACTIONS}
/>
</div>
</div>
{isLong && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-json-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
);
}
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} />;
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 <TextContent text={toDisplayText(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,17 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './AIThreadMessage.module.scss';
interface RoleLabelProps {
label: string;
}
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,27 +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;
}
/** Renders JSON text as a tree, anything else as markdown. */
function TextContent({ text }: TextContentProps): JSX.Element {
const json = useMemo(() => parseJsonObject(text), [text]);
if (json) {
return <JsonBlock data={json} />;
}
return (
<div className={styles.textBox}>
<ExpandableText text={text} />
</div>
);
}
export default TextContent;

View File

@@ -1,97 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import AIThreadMessage from '../AIThreadMessage';
import JsonBlock from '../JsonBlock';
import { MessageType } from '../types';
// jsdom has no layout, so the tree height JsonBlock measures is set per test.
let mockTreeHeight = 0;
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 0,
height: mockTreeHeight,
}),
}));
beforeEach(() => {
mockTreeHeight = 0;
});
describe('AIThreadMessage', () => {
it('shows the Output badge when the message has no role', () => {
render(
<AIThreadMessage
message={{ parts: [{ type: 'text', content: 'hi' }] }}
messageType={MessageType.Output}
/>,
);
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',
);
});
it('hides system message parts until the toggle is clicked', async () => {
const user = userEvent.setup();
render(
<AIThreadMessage
message={{ role: 'system', parts: [{ type: 'text', content: 'be brief' }] }}
/>,
);
expect(screen.queryByText('be brief')).not.toBeInTheDocument();
await user.click(screen.getByTestId('ai-message-system-toggle'));
expect(screen.getByText('be brief')).toBeInTheDocument();
});
it('shows the finish reason', () => {
render(
<AIThreadMessage
message={{
role: 'assistant',
parts: [{ type: 'text', content: 'hi' }],
finish_reason: 'stop',
}}
/>,
);
expect(screen.getByTestId('ai-message-finish-reason')).toHaveTextContent(
'stop',
);
});
});
describe('JsonBlock', () => {
it('collapses JSON taller than 10 rows behind Show more', async () => {
mockTreeHeight = 300;
const user = userEvent.setup();
render(<JsonBlock data={{ a: 1 }} />);
const toggle = screen.getByTestId('ai-json-show-more');
expect(toggle).toHaveTextContent('Show more');
await user.click(toggle);
expect(toggle).toHaveTextContent('Show less');
});
it('does not collapse JSON of 10 rows or fewer', () => {
mockTreeHeight = 220;
render(<JsonBlock data={{ a: 1 }} />);
expect(screen.queryByTestId('ai-json-show-more')).not.toBeInTheDocument();
});
it('renders a copy button', () => {
render(<JsonBlock data={{ a: 1 }} />);
expect(screen.getByTestId('ai-json-copy')).toBeInTheDocument();
});
});

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} />
) : (
<span className={styles.empty}> - </span>
)}
</CollapsibleBlock>
);
}
ThinkingPart.defaultProps = {
content: undefined,
label: 'Thinking',
testId: 'ai-message-thinking-toggle',
};
export default ThinkingPart;

View File

@@ -1,56 +0,0 @@
import { useMemo } from 'react';
import JsonBlock from '../JsonBlock';
import PartChip from '../PartChip';
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} />
) : (
<span className={styles.empty}> - </span>
)}
</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,58 +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>
// The API's generic part is an untyped index signature, which defeats narrowing on `type`.
| { 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[];
};
export enum MessageType {
Input = 'input',
Output = 'output',
}

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

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

@@ -1,46 +0,0 @@
@use '../../../../styles/scrollbar' as *;
.accordion {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
// Direct children only, so nested collapses keep their own styles.
> :global(.ant-collapse-item) {
border: none;
}
> :global(.ant-collapse-item > .ant-collapse-header) {
align-items: center !important;
height: 40px;
padding: 0 var(--spacing-4) !important;
}
> :global(.ant-collapse-item > .ant-collapse-content) {
background: transparent;
border-top: 1px solid var(--l2-border);
}
> :global(
.ant-collapse-item > .ant-collapse-content > .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,102 +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 '../../utils/spanMessages';
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,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
> {
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
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`}
messageType={messageType}
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,58 +0,0 @@
import { useMemo, useRef } from 'react';
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
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}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesAccordion
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
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,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/spanMessages';
import styles from './SpanMessages.module.scss';
type MessageProps = Pick<
AIThreadMessageProps,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
>;
interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesSection({
title,
view,
messages,
raw,
testId,
messageType,
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}
messageType={messageType}
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/spanMessages';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<span className={styles.rawUnavailable} data-testid="span-raw-messages">
Unable to render messages
</span>
);
}
export default RawMessages;

View File

@@ -1,43 +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;
}
.rawUnavailable {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.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,86 +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 { MessageType } from '../AIThreadMessage/types';
import MessagesSection from './MessagesSection';
import {
getSpanMessages,
hasFormattedMessages,
hasRawMessages,
} from '../utils/spanMessages';
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 ?? [] }}
/>
</div>
);
}
return (
<div className={cx(styles.root, className)}>
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
SpanMessages.defaultProps = {
className: undefined,
};
export default SpanMessages;

View File

@@ -1,94 +0,0 @@
import { render, screen, within } from 'tests/test-utils';
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
import SpanMessages from '../SpanMessages';
// jsdom has no layout; report a JSON tree taller than the 10-row clamp.
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 0,
height: 300,
}),
}));
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"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Formatted} />);
expect(screen.getByTestId('span-messages-empty')).toHaveTextContent(
'This span has no input or output.',
);
});
it('renders formatted input and output messages', () => {
const span = makeSpan({
formattedInput: [
{ role: 'user', parts: [{ type: 'text', content: 'question' }] },
],
formattedOutput: [
{ role: 'assistant', parts: [{ type: 'text', content: 'answer' }] },
],
});
render(<SpanMessages span={span} view={ThreadView.Formatted} />);
expect(
within(screen.getByTestId('span-messages-input')).getByText('question'),
).toBeInTheDocument();
expect(
within(screen.getByTestId('span-messages-output')).getByText('answer'),
).toBeInTheDocument();
});
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"}]' },
});
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"}]',
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
});
it('collapses tall JSON behind Show more in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
{ role: 'user', content: 'hi' },
]),
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('ai-json-show-more')).toHaveTextContent(
'Show more',
);
});
});

View File

@@ -29,10 +29,6 @@
}
}
.tabs {
flex-shrink: 0;
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -13,20 +13,16 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
import MissingSpansBanner from './MissingSpansBanner';
import { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -64,17 +60,6 @@ function TraceDetailsHeader({
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID);
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
// The filter query lives in Filters' local state, which unmounts with the
// Overview tab; drop its result too so both start empty on return.
useEffect(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
onFilteredSpansChange([], false);
}, [tab, onFilteredSpansChange]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -108,14 +93,13 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
useTraceDetailsStripInfo({
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
});
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
: 0;
return (
<div className={styles.wrapper}>
<div className={styles.header}>
@@ -136,10 +120,9 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
{showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -155,7 +138,6 @@ function TraceDetailsHeader({
icon
color="secondary"
aria-label="Analytics"
testId="trace-analytics-button"
onClick={handleToggleAnalytics}
>
<ChartPie size={14} />
@@ -190,20 +172,16 @@ function TraceDetailsHeader({
)}
</div>
{(showTraceDetails || !isOverview) && (
{showTraceDetails && (
<div className={styles.subHeader}>
{!traceSummary ? (
<DetailsLoader />
) : (
{traceSummary ? (
<EntityMetadataRow
entity="trace"
service={{
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
}
durationMs={durationMs}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
@@ -211,11 +189,13 @@ function TraceDetailsHeader({
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -1,43 +0,0 @@
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
const TAB_ITEMS = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
},
];
function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
void setTab(value as TraceDetailsTab);
};
return (
<div className={styles.tabs}>
<ToggleGroup
variant="outlined"
color="secondary"
type="single"
value={tab}
onChange={handleChange}
size="sm"
items={TAB_ITEMS}
/>
</div>
);
}
export default TraceDetailsTabs;

View File

@@ -50,7 +50,6 @@ function TraceOptionsMenu({
type: 'item',
value: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
testId: 'toggle-trace-details',
onClick: onToggleTraceDetails,
},
{
@@ -128,7 +127,6 @@ function TraceOptionsMenu({
icon
color="secondary"
aria-label="Trace options"
testId="trace-options-button"
>
<Settings2 size={14} />
</Button>

View File

@@ -2,7 +2,6 @@ import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
@@ -316,98 +315,3 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});
const metadataText = /Missing Span/;
describe('TraceDetailsHeader – tabs', () => {
beforeEach(() => {
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...SUMMARY,
rootServiceEntryPoint: 'Missing Span',
hasMissingSpans: true,
},
isLoading: false,
});
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
// In-memory URL state, so a tab switch can't leak into the next test.
const renderHeader = (): void => {
render(
<NuqsTestingAdapter hasMemory>
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
</NuqsTestingAdapter>,
);
};
it('shows the Overview-only sections on the Overview tab', () => {
renderHeader();
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
expect(screen.getByTestId('trace-analytics-button')).toBeInTheDocument();
expect(screen.getByTestId('trace-options-button')).toBeInTheDocument();
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
});
it('keeps only the metadata row on the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
expect(
screen.queryByTestId('trace-analytics-button'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('trace-options-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-options-button'));
await user.click(await screen.findByTestId('toggle-trace-details'));
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('clears the filter result when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
baseProps.onFilteredSpansChange.mockClear();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(baseProps.onFilteredSpansChange).toHaveBeenCalledWith([], false);
});
it('closes the Analytics panel when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-analytics-button'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'true',
);
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'false',
);
});
});

View File

@@ -1,505 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import NoData from '../NoData/NoData';
import {
setTraceStoreAvailableColorByFields,
useTraceStore,
} from '../stores/traceStore';
import {
SPAN_DETAILS_PANEL_MIN_WIDTH,
SpanDetailVariant,
} from '../SpanDetailsPanel/constants';
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from '../TraceWaterfall/types';
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from '../utils';
import cx from 'classnames';
import styles from './TraceDetailsOverview.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
interface TraceDetailsOverviewProps {
filteredSpanIds: string[];
isFilterActive: boolean;
onHasTraceDataChange: (hasTraceData: boolean) => void;
dataLoadedFiredForRef: MutableRefObject<string>;
}
function TraceDetailsOverview({
filteredSpanIds,
isFilterActive,
onHasTraceDataChange,
dataLoadedFiredForRef,
}: TraceDetailsOverviewProps): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
safeNavigate({ search: params.toString() }, { replace: true });
}, [safeNavigate]);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
dataLoadedFiredForRef,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const hasTraceData = !showNoData && allSpans.length > 0;
useEffect(() => {
onHasTraceDataChange(hasTraceData);
return (): void => onHasTraceDataChange(false);
}, [hasTraceData, onHasTraceDataChange]);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
return (
<>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</>
);
}
export default TraceDetailsOverview;

Some files were not shown because too many files have changed in this diff Show More