mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-23 02:20:41 +01:00
Compare commits
1 Commits
main
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
886e1dde9d |
@@ -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.
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -75,7 +75,6 @@ jobs:
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
- metricsexplorer
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -41,6 +41,7 @@ linters:
|
||||
no-global: all
|
||||
context: all
|
||||
static-msg: true
|
||||
key-naming-case: snake
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -24,7 +24,6 @@ describe('FormAlertChannels', () => {
|
||||
onTypeChangeHandler={jest.fn()}
|
||||
onSaveHandler={jest.fn()}
|
||||
onTestHandler={jest.fn()}
|
||||
onDiscardHandler={jest.fn()}
|
||||
testingState={false}
|
||||
savingState={false}
|
||||
editing
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { formUrlParams } from 'utils/traceUtils';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
@@ -21,7 +20,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
}
|
||||
|
||||
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
|
||||
tab: TraceDetailsTab.Thread,
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -45,10 +44,7 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={{
|
||||
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
|
||||
search: `?tab=${TraceDetailsTab.Thread}`,
|
||||
}}
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -1 +1,2 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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' }),
|
||||
});
|
||||
|
||||
@@ -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$/);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { MessageTone } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface PartChipProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
tone?: MessageTone;
|
||||
// Plain: no chip background, for headers like a tool call's.
|
||||
isPlain?: boolean;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function PartChip({
|
||||
label,
|
||||
value,
|
||||
tone = 'success',
|
||||
isPlain = false,
|
||||
testId,
|
||||
children,
|
||||
}: PartChipProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cx(styles.chip, isPlain && styles.isPlain)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className={styles.dot} data-tone={tone} />
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
{value !== undefined && (
|
||||
<>
|
||||
<span className={styles.chipSeparator}>—</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartChip.defaultProps = {
|
||||
value: undefined,
|
||||
tone: 'success',
|
||||
isPlain: false,
|
||||
testId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default PartChip;
|
||||
@@ -1,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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolResultPartProps {
|
||||
toolCallId: string;
|
||||
toolCallKey: string;
|
||||
type: 'tool_call_response' | 'server_tool_call_response';
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
isLinkEnabled: boolean;
|
||||
onLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function ToolResultPart({
|
||||
toolCallId,
|
||||
toolCallKey,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
isError,
|
||||
isLinkEnabled,
|
||||
onLinkClick,
|
||||
}: ToolResultPartProps): JSX.Element {
|
||||
// Short plain results fit in the chip; JSON or long ones render below it.
|
||||
const isInline = useMemo(
|
||||
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const handleLinkClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
onLinkClick?.(toolCallKey);
|
||||
};
|
||||
|
||||
const chip = (
|
||||
<PartChip
|
||||
label={type}
|
||||
value={isInline ? content || '…' : name || toolCallId}
|
||||
tone={isError ? 'error' : 'success'}
|
||||
>
|
||||
{isLinkEnabled && (
|
||||
<CornerUpRight size={12} className={styles.toolLinkIcon} />
|
||||
)}
|
||||
</PartChip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.toolResult} data-testid="ai-tool-result">
|
||||
{isLinkEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toolLink}
|
||||
onClick={handleLinkClick}
|
||||
title="Go to tool call"
|
||||
data-testid="ai-tool-result-link"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
) : (
|
||||
chip
|
||||
)}
|
||||
{!isInline && <TextContent text={content} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolResultPart.defaultProps = {
|
||||
name: undefined,
|
||||
isError: false,
|
||||
onLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default ToolResultPart;
|
||||
@@ -1,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',
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { TOOL_CALL_ID_ATTR } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const HIGHLIGHT_MS = 1500;
|
||||
|
||||
interface ToolCallLinks {
|
||||
canLinkToToolCall: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
|
||||
export function useToolCallLinks(
|
||||
rootRef: RefObject<HTMLElement>,
|
||||
toolCallKeys: Set<string>,
|
||||
): ToolCallLinks {
|
||||
const canLinkToToolCall = useCallback(
|
||||
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
|
||||
[toolCallKeys],
|
||||
);
|
||||
|
||||
const onToolLinkClick = useCallback(
|
||||
(toolCallKey: string): void => {
|
||||
const target = rootRef.current?.querySelector<HTMLElement>(
|
||||
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add(styles.isHighlighted);
|
||||
window.setTimeout(
|
||||
() => target.classList.remove(styles.isHighlighted),
|
||||
HIGHLIGHT_MS,
|
||||
);
|
||||
},
|
||||
[rootRef],
|
||||
);
|
||||
|
||||
return { canLinkToToolCall, onToolLinkClick };
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { attempt, isError, isObjectLike } from 'lodash-es';
|
||||
|
||||
import { MediaMessagePart, MessagePart, ThreadMessage } from './types';
|
||||
|
||||
export const MAX_TEXT_LENGTH = 1000;
|
||||
|
||||
const MAX_BLOB_PREVIEW_LENGTH = 100;
|
||||
|
||||
export const MAX_INLINE_RESULT_LENGTH = 80;
|
||||
|
||||
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
developer: 'Developer',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tool: 'Tool',
|
||||
};
|
||||
|
||||
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
// Provider values the backend could not map fall back to info.
|
||||
const FINISH_REASON_TONES: Record<string, MessageTone> = {
|
||||
stop: 'success',
|
||||
tool_call: 'success',
|
||||
length: 'warning',
|
||||
content_filter: 'warning',
|
||||
compaction: 'info',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/** Undefined for missing or unknown roles, which render headerless. */
|
||||
export function getRoleLabel(role?: string): string | undefined {
|
||||
return role ? ROLE_LABELS[role] : undefined;
|
||||
}
|
||||
|
||||
export function getFinishReasonTone(reason?: string | null): MessageTone {
|
||||
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
|
||||
}
|
||||
|
||||
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
|
||||
export function parseJsonObject(text: string): object | undefined {
|
||||
const parsed: unknown = attempt(JSON.parse, text);
|
||||
return isObjectLike(parsed) && !isError(parsed)
|
||||
? (parsed as object)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toDisplayText(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
/** Text and generic parts with only whitespace have nothing to render. */
|
||||
export function isEmptyPart(part: MessagePart): boolean {
|
||||
if (part.type === 'text') {
|
||||
return part.content.trim() === '';
|
||||
}
|
||||
if (part.type === 'generic') {
|
||||
return toDisplayText(part.content).trim() === '';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Blob content is inline base64; only a prefix is shown. */
|
||||
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
|
||||
if (
|
||||
part.type !== 'blob' ||
|
||||
!part.content ||
|
||||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
|
||||
) {
|
||||
return part;
|
||||
}
|
||||
return {
|
||||
...part,
|
||||
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server and client calls can share an id, so links match on kind too.
|
||||
export function toToolCallKey(id: string, isServer: boolean): string {
|
||||
return `${isServer ? 'server' : 'client'}:${id}`;
|
||||
}
|
||||
|
||||
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
message.parts.flatMap((part) =>
|
||||
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
|
||||
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -34,15 +34,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Inside the Overview tab there's no flex gap, so match .panelBody's 16px.
|
||||
.tabAiMessages {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
|
||||
// a true fixed footer flush with the panel's bottom edge — always visible
|
||||
// regardless of how the body is scrolled.
|
||||
|
||||
@@ -48,7 +48,6 @@ import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
@@ -282,7 +281,6 @@ function SpanDetailsContent({
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
const aiMessages = <SpanAIMessages span={selectedSpan} />;
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
const spanDetailTabs: TabsItemProps[] = [
|
||||
@@ -293,7 +291,6 @@ function SpanDetailsContent({
|
||||
children: (
|
||||
<>
|
||||
{isWide && summary}
|
||||
{isWide && <div className={styles.tabAiMessages}>{aiMessages}</div>}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
@@ -372,7 +369,6 @@ function SpanDetailsContent({
|
||||
<>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
{!isWide && aiMessages}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
<Tabs
|
||||
|
||||
@@ -1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,10 +29,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user